Compare commits

...

3 Commits

Author SHA1 Message Date
Adminator 0555a2d245 Lehrer-Aktivitätsstreifen: Farbe je Sim, Höhe=Erfolg, Rahmen=Zustand
Tages-Balken neu kodiert (nach Thomas' Spec):
- Eine Farbe pro Simulation (simColor, stabil per Hash).
- Balkenhöhe = Maß für den Erfolg (mehr Sterne → höher) → Leistungen auf einen
  Blick vergleichbar.
- Rahmen kodiert den Zustand: fertig gespielt = geschlossener dünner Rahmen ·
  noch dran = rechts offener Rahmen · Abbruch = gestrichelt + ✖ am Ende.
- Beschriftung: 3-Buchstaben-Kürzel (eng) → Wort → Wort+Lektion (Platz).
- Legende entsprechend erneuert.

Datenbasis vorhanden: live.php summary liefert success (inkl. 'aborted'),
stars, completed pro Session. Offen für v2: „läuft noch"-Balken aus aktiven
live_sessions einblenden (Tag-View ist bisher assessments-basiert = Abschlüsse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:14:56 +02:00
Adminator 5f603cb193 Tourismusregion: Umwelt beißt jetzt + Schüler-Erklärung (Balance-Pass 1)
Diagnose per headless-Sim-Harness (.dontDeploy/tools/tr-balance-harness.mjs):
„alles zubauen" senkte die Umwelt nur 75→50 (über der 45-Schwelle) und der
Exit-Umwelt-Malus war TOTER Flag-Code → man konnte der Umwelt kaum schaden.

- model.js: (1) Exit-Umwelt-Malus wirklich angewandt — env<60 zieht bei
  umweltbewussten Gästen Zufriedenheit ab (je tiefer, desto mehr). (2) Über-
  tourismus-Drift im 10-s-Takt: dichte Bebauung (>6 Bauwerke) + Verkehr zehren
  die Umwelt aus. (3) Einmalige Lern-Warnkarte, wenn Umwelt < 50 kippt.
- data.js: Naturschutzgebiet-Beschreibung klar (rettet die Umwelt +20, Natur-
  Attraktionen +15 %, sperrt Bauplätze — Naturschutz vs. Ausbau abwägen).
- main.js: Willkommenskarte erklärt die Umwelt-Mechanik für Schüler.

Messung nachher: „alles zubauen" → Umwelt 75→37 (unter 45), Umweltziel
verfehlt, Ø-Sterne fallen. Feintuning Sterne-Kurve/Ansprüche-Eskalation
bleibt für Thomas' Playtest (Game-Feeling headless nicht bewertbar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:10:42 +02:00
Adminator 913f161502 Admin: Weltküche-Herkunftsübersicht (QA-Seite)
Neue admin-geschützte Seite admin-weltkueche.html: spiegelt die Spiel-Logik
(Heimat + Handels-Gravitation Menge÷Entfernung + Markt-Tags) und zeigt pro
Gericht × Zutat die vom Spiel akzeptierten Herkunftsländer (🟢 richtig) und
die Distraktoren (🔴). Filter nach Kategorie + Suche + „nur Auffällige".
Auffällig-Heuristik: Heimat-Zweig-Zutat mit „richtigem" Land > 1500 km
(surft echte Ausreißer, ohne Welt-Zutaten wie Pfeffer falsch zu flaggen) —
aktuell 5 Kandidaten. In Admin-Nav + Backend-Werkzeuge verlinkt.
Verifiziert: 30 Gerichte, Kartoffel/Eier → [AT, DE] (nicht Frankreich).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 18:01:50 +02:00
7 changed files with 339 additions and 27 deletions
+73
View File
@@ -0,0 +1,73 @@
// Headless-Balance-Messung (temporär). node sim-harness.mjs
import { createState, place, upgrade, callNextWave, tick, GOALS } from '../../App/sims/tourismusregion/js/model.js';
import { BUILDINGS, GAME_LEN } from '../../App/sims/tourismusregion/js/data.js';
const ALL_TYPES = Object.keys(BUILDINGS);
function upgradeAllMax(s) {
for (const b of s.buildings) {
const def = BUILDINGS[b.type];
let guard = 0;
while (b.level < def.cost.length && s.budget >= def.cost[b.level] && guard++ < 6) {
const r = upgrade(s, b);
if (!r || !r.ok) break;
}
}
}
function run(strategy, mapId = 'talschleife') {
const s = createState({ mapId, seed: 12345 });
const dt = 0.5;
let t = 0;
function tryBuildAll() {
for (let i = 0; i < s.slots.length; i++) {
const slot = s.slots[i];
if (slot.building || slot.blocked) continue;
for (const type of ALL_TYPES) {
const def = BUILDINGS[type];
if (!def || s.budget < def.cost[0]) continue;
const r = place(s, type, i);
if (r && r.ok) break;
}
}
}
function buildOneCafe() {
// Minimal-Strategie: genau EIN Café (Thomas' „5-Sterne-Café macht alles grün")
if (s.buildings.length) return;
for (let i = 0; i < s.slots.length; i++) {
if (s.slots[i].building || s.slots[i].blocked) continue;
const r = place(s, 'cafe', i);
if (r && r.ok) return;
}
}
let guard = 0;
while (t < GAME_LEN && s.phase < 8 && guard++ < 2000) {
if (strategy === 'buildAll' || strategy === 'maxedAll') tryBuildAll();
if (strategy === 'maxedAll') upgradeAllMax(s);
if (strategy === 'oneCafe') buildOneCafe();
try { callNextWave(s); } catch (_) {}
for (let k = 0; k < 40 && t < GAME_LEN; k++) { tick(s, dt); t += dt; }
}
if (strategy === 'maxedAll') upgradeAllMax(s);
// Restzeit auslaufen lassen
while (t < GAME_LEN && guard++ < 6000) { tick(s, dt); t += dt; }
const stars = s.stats.all.count ? s.stats.all.stars / s.stats.all.count : 0;
return {
strategy,
env: Math.round(s.env),
avgStars: +stars.toFixed(2),
visitorsExited: s.stats.all.count,
budget: Math.round(s.budget),
buildings: s.buildings.length,
traffic: Math.round(s.traffic || 0),
goalsMet: GOALS.filter(g => g.check(s)).length + '/' + GOALS.length,
};
}
console.log('=== Tourismusregion Balance-Messung (talschleife) ===');
for (const strat of ['maxedAll', 'buildAll', 'oneCafe']) {
const r = run(strat);
console.log(`${strat.padEnd(9)} → env ${String(r.env).padStart(3)} · Ø${r.avgStars}★ · ${r.buildings} Geb. · Verkehr ${r.traffic} · Ziele ${r.goalsMet} · Budget ${r.budget}`);
}
+2
View File
@@ -44,6 +44,7 @@
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
<a href="admin.html" class="btn-sm">Logout</a>
</div>
@@ -55,6 +56,7 @@
<a class="btn btn-outline" href="sims/tourismustal/editor.php">🏠 Tourismustal-Sprite-Editor</a>
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
</div>
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
</div>
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoGraSim — Weltküche-Herkunftsübersicht (Admin)</title>
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh;color:#2a2a2a}
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20}
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
.topbar h1 img{height:28px}
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
.wrap{max-width:1100px;margin:1rem auto;padding:0 1rem}
.lead{font-size:.82rem;color:#6a6a6a;margin-bottom:.8rem;line-height:1.5}
.lead b{color:#3a3a3a}
.filters{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
.filters input{padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.12);border-radius:7px;font-size:.8rem;font-family:inherit;min-width:220px}
.chip{padding:.3rem .7rem;border-radius:99px;border:1.5px solid rgba(0,0,0,.12);background:#fff;font-size:.72rem;font-weight:600;cursor:pointer;color:#4a4a4a}
.chip.on{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
.dish{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);border:1px solid rgba(0,0,0,.05);margin-bottom:1rem;overflow:hidden}
.dish-h{padding:.7rem 1rem;background:#f0f5f6;border-bottom:1px solid rgba(0,0,0,.05);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.dish-h .nm{font-size:1rem;font-weight:800;color:#2a4a52}
.dish-h .heimat{font-size:.72rem;color:#6a6a6a}
.dish-h .cat{font-size:.6rem;text-transform:uppercase;letter-spacing:.05em;background:#e8e4d8;color:#7a6540;padding:.1rem .5rem;border-radius:99px;font-weight:700}
table{width:100%;border-collapse:collapse;font-size:.78rem}
th{text-align:left;padding:.45rem .7rem;font-size:.62rem;text-transform:uppercase;letter-spacing:.04em;color:#8a8a8a;background:#fafaf7;border-bottom:1px solid rgba(0,0,0,.05)}
td{padding:.5rem .7rem;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:top}
tr:last-child td{border-bottom:none}
.ing-nm{font-weight:700;color:#3a3a3a;white-space:nowrap}
.ing-nm small{display:block;color:#9a9a9a;font-weight:400;font-size:.66rem}
.cty{display:inline-flex;align-items:center;gap:.25rem;padding:.12rem .45rem;border-radius:99px;font-size:.72rem;font-weight:600;margin:.1rem .2rem .1rem 0;white-space:nowrap}
.cty.ok{background:#dceadd;color:#2f6b3a;border:1px solid #b6d8bd}
.cty.dist{background:#f6e2dc;color:#a3472f;border:1px solid #e6c3b4}
.cty.other{background:#eee;color:#7a7a7a;border:1px solid #ddd}
.cty .g{opacity:.6;font-weight:400;font-size:.64rem}
.warn{background:#fff5e6;border-left:3px solid #e0a020;padding:.35rem .6rem;border-radius:5px;font-size:.72rem;color:#8a6510;margin-top:.35rem}
.colhead{font-size:.6rem;color:#8a8a8a;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.2rem}
.legend{font-size:.72rem;color:#6a6a6a;margin:.5rem 0 1rem;display:flex;gap:1rem;flex-wrap:wrap}
#status{padding:2rem;text-align:center;color:#8a8a8a}
</style>
</head>
<body>
<div class="topbar">
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Weltküche — Herkunftsübersicht (Admin)</h1>
<a href="admin-licenses.html" class="btn-sm">← Admin</a>
<a href="admin.html" class="btn-sm">Logout</a>
</div>
<div class="wrap">
<p class="lead">QA-Werkzeug: spiegelt die <b>Spiel-Logik</b> (Heimat + Handels-Gravitation Menge÷Entfernung, Markt-Tags). Pro Gericht &amp; Zutat siehst du, welche Länder das Spiel als <b>richtig</b> akzeptiert und welche als <b>Distraktoren</b> im Topf sind. Ziel: kein „Warum Frankreich?", keine „Bananen aus Deutschland".</p>
<div class="legend">
<span><span class="cty ok">🟢 richtig</span> akzeptierte Herkunft</span>
<span><span class="cty dist">🔴 Distraktor</span> falsch (fern/klimafremd/kein Lieferant)</span>
<span><span class="cty other">⚪ nicht angeboten</span> Produzent, aber nicht im Spiel</span>
<span><b>g</b> = Relevanz (Menge÷Entfernung zur Heimat)</span>
</div>
<div class="filters">
<input id="q" placeholder="Suche Gericht oder Zutat…" oninput="render()">
<span class="chip on" data-cat="" onclick="setCat(this,'')">Alle</span>
<span class="chip" data-cat="regional-at" onclick="setCat(this,'regional-at')">AT-regional</span>
<span class="chip" data-cat="dach" onclick="setCat(this,'dach')">DACH</span>
<span class="chip" data-cat="europa" onclick="setCat(this,'europa')">Europa</span>
<span class="chip" data-cat="exotisch" onclick="setCat(this,'exotisch')">Exotisch</span>
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem"><input type="checkbox" id="onlyWarn" onchange="render()"> nur Auffällige ⚠</label>
</div>
<div id="status">Lade Daten…</div>
<div id="list"></div>
</div>
<script>
var BASE = location.pathname.replace(/\/admin-weltkueche\.html$/, '');
var D = 'sims/weltkueche/assets/data/';
var C = {}, PM = {}, DISHES = [];
var _cat = '';
// ---- Spiel-Logik gespiegelt (siehe game.html buildTaskFor) ----
function mrank(m){ return m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1; }
function wkDist(a, b){
var A = C[a], B = C[b];
if (!A || !B || A.lat == null || B.lat == null) return 3000;
var R = 6371, toR = Math.PI/180;
var dLat = (B.lat-A.lat)*toR, dLng = (B.lng-A.lng)*toR;
var s = Math.pow(Math.sin(dLat/2),2) + Math.cos(A.lat*toR)*Math.cos(B.lat*toR)*Math.pow(Math.sin(dLng/2),2);
return Math.max(60, 2*R*Math.asin(Math.min(1, Math.sqrt(s))));
}
// Klassifiziert alle Produzenten einer Zutat für ein Gericht.
// Gibt { correct:[keys], distractors:[keys], branch } zurück (Pools, ohne Zufalls-Ziehung).
function classify(ingKey, heimat){
var ing = PM[ingKey]; if (!ing) return null;
var producers = ing.producers || {};
var keys = Object.keys(producers).filter(function(c){ return C[c]; });
// Heimat-Zweig: Heimat erzeugt die Zutat selbst
if (heimat && C[heimat] && producers[heimat]) {
var grav = function(c){ return (producers[c].t || 0) / wkDist(heimat, c); };
var realistic = keys.filter(function(c){ return c !== heimat && mrank(producers[c].market) < 2; })
.sort(function(a,b){ return (mrank(producers[a].market)-mrank(producers[b].market)) || (grav(b)-grav(a)); });
var correct = [heimat]; if (realistic[0]) correct.push(realistic[0]);
if (correct.length < 2){ var any = keys.filter(function(c){ return c!==heimat && correct.indexOf(c)<0; })
.sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); }); if(any[0]) correct.push(any[0]); }
var farP = keys.filter(function(c){ return correct.indexOf(c)<0 && mrank(producers[c].market)===2; });
var climate = (ing.climate_possible||[]).filter(function(c){ return C[c] && correct.indexOf(c)<0; });
return { correct: correct, distractors: farP.concat(climate), branch:'heimat', grav: grav };
}
// Normal-Zweig (exotisch / Heimat kein Produzent)
var TIER = { eu:0, global:1, 'world-far':2 };
var pool = keys.filter(function(c){ var m = producers[c].market || 'global'; return m==='eu'||m==='global'||m==='world-far'; })
.sort(function(a,b){ var ma=producers[a].market||'global', mb=producers[b].market||'global';
return ((TIER[ma]!=null?TIER[ma]:2)-(TIER[mb]!=null?TIER[mb]:2)) || ((producers[b].t||0)-(producers[a].t||0)); });
var correct2 = pool.slice(0, Math.min(2, pool.length));
var climate2 = (ing.climate_possible||[]).filter(function(c){ return C[c] && correct2.indexOf(c)<0; });
var localFar = keys.filter(function(c){ var m=producers[c].market; return (m==='local'||m==='world-far') && correct2.indexOf(c)<0; });
return { correct: correct2, distractors: localFar.concat(climate2), branch:'welt', grav: function(c){ return producers[c].t||0; } };
}
function fmtT(t){ if(!t) return ''; if(t>=1e6) return (t/1e6).toFixed(t>=1e7?0:1)+' Mio t'; if(t>=1e3) return Math.round(t/1e3)+' Tsd t'; return t+' t'; }
function ctyChip(key, cls, ing, heimat, showG){
var c = C[key]; if(!c) return '';
var g = '';
if (showG && heimat && C[heimat] && key!==heimat){
var t = (PM[ing].producers[key]||{}).t||0; var d = Math.round(wkDist(heimat,key));
g = ' <span class="g">'+Math.round(t/Math.max(1,d))+'</span>';
}
return '<span class="cty '+cls+'" title="'+(c.n)+(PM[ing].producers[key]?(' · '+fmtT(PM[ing].producers[key].t)+' · Markt: '+(PM[ing].producers[key].market||'global')):'')+(heimat&&key!==heimat?(' · '+Math.round(wkDist(heimat,key))+' km'):'')+'">'+c.f+' '+c.n+g+'</span>';
}
function render(){
var q = (document.getElementById('q').value||'').toLowerCase().trim();
var onlyWarn = document.getElementById('onlyWarn').checked;
var host = document.getElementById('list'); var html = '';
var shown = 0;
DISHES.forEach(function(d){
if (_cat && d.category !== _cat) return;
var heimat = d.heimat || null;
var ings = (d.ings||[]).filter(function(k){ return PM[k]; });
// Filter Suche
var matchDish = !q || (d.name||'').toLowerCase().indexOf(q)>=0 || ings.some(function(k){ return (PM[k].nm||k).toLowerCase().indexOf(q)>=0; });
if (!matchDish) return;
var rows = ''; var dishHasWarn = false;
ings.forEach(function(k){
if (q && (d.name||'').toLowerCase().indexOf(q)<0 && (PM[k].nm||k).toLowerCase().indexOf(q)<0) return;
var cl = classify(k, heimat); if(!cl) return;
// Auffällig NUR im Heimat-Zweig: die Zutat ist lokal erzeugbar (Heimat baut
// sie an), trotzdem ist ein „richtiges" Land > 1500 km weg → echter Ausreißer.
// Welt-Zutaten (Pfeffer aus Vietnam etc.) sind zu Recht fern → keine Warnung.
var warn = '';
if (heimat && C[heimat] && cl.branch === 'heimat'){
var farCorrect = cl.correct.filter(function(c){ return c!==heimat && wkDist(heimat,c) > 1500; });
if (farCorrect.length){ warn = '<div class="warn">⚠ „richtig" trotz &gt; 1500 km von '+C[heimat].n+': '+farCorrect.map(function(c){return C[c].n+' ('+Math.round(wkDist(heimat,c))+' km)';}).join(', ')+'</div>'; dishHasWarn=true; }
}
if (onlyWarn && !warn) return;
var okChips = cl.correct.map(function(c){ return ctyChip(c,'ok',k,heimat,true); }).join('');
var distChips = cl.distractors.slice(0,6).map(function(c){ return ctyChip(c,'dist',k,heimat,false); }).join('');
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')+'</small></td>'
+ '<td><div class="colhead">richtig</div>'+(okChips||'—')+warn+'</td>'
+ '<td><div class="colhead">Distraktoren (Pool)</div>'+(distChips||'—')+'</td></tr>';
});
if (!rows) return;
if (onlyWarn && !dishHasWarn) return;
shown++;
html += '<div class="dish"><div class="dish-h"><span class="nm">'+(d.name||d.id)+'</span>'
+ (heimat&&C[heimat]?'<span class="heimat">Heimat: '+C[heimat].f+' '+C[heimat].n+'</span>':'<span class="heimat">ohne Heimat (Welt-Gericht)</span>')
+ (d.category?'<span class="cat">'+d.category+'</span>':'')+'</div>'
+ '<table><thead><tr><th style="width:26%">Zutat</th><th style="width:37%">🟢 Richtig</th><th style="width:37%">🔴 Distraktoren</th></tr></thead><tbody>'+rows+'</tbody></table></div>';
});
document.getElementById('status').style.display = 'none';
host.innerHTML = html || '<div id="status" style="display:block">Keine Treffer.</div>';
}
function setCat(el, cat){ _cat = cat; document.querySelectorAll('.chip').forEach(function(c){ c.classList.toggle('on', c===el); }); render(); }
// Auth + Daten laden
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
.then(function(r){return r.json();})
.then(function(r){
if (!r.authenticated){ location.href = 'admin.html'; return; }
return Promise.all([
fetch(D+'countries-full.json').then(function(r){return r.json();}),
fetch(D+'production-matrix.json').then(function(r){return r.json();}),
fetch(D+'dishes.json').then(function(r){return r.json();}),
]).then(function(a){
C = a[0]; PM = a[1];
DISHES = Object.keys(a[2]).filter(function(k){return k[0]!=='_';}).map(function(id){ return Object.assign({id:id}, a[2][id]); });
render();
});
})
.catch(function(e){ document.getElementById('status').textContent = 'Fehler: '+e.message; });
</script>
</body>
</html>
+1 -1
View File
@@ -283,7 +283,7 @@ export const BUILDINGS = {
},
naturschutz: {
name: 'Naturschutzgebiet', emoji: '🌿', cat: 'nature', unlockPhase: 4, maxLevel: 1, fx: '🦋', sound: 'pling',
desc: 'Umwelt +20, Naturerlebnis +15 % sperrt aber Bauplätze im Umkreis.',
desc: 'Hebt die Umwelt sofort um +20 und macht Natur-Attraktionen 15 % attraktiver. So rettest du die Landschaft, wenn zu viel Bauen die Umwelt gedrückt hat Wanderer und Naturfreunde danken es dir. Preis: sperrt umliegende Bauplätze (Naturschutz vor Ausbau).',
cost: [300],
sat: [6],
slow: [0.05],
+4
View File
@@ -112,6 +112,10 @@ function startGame() {
+ 'See, Berg und drei Wege, auf denen Gäste ankommen.\n\n'
+ 'Ziel: zufriedene Gäste (Ø 3,5 ★), regionale Wertschöpfung (2500 €), '
+ 'intakte Umwelt (≥ 45) und ein solides Budget.\n\n'
+ '🌲 Die Umwelt ist deine Landschaft: Wanderer und Naturfreunde lieben sie intakt. '
+ 'Zu viel Bauen und Verkehr lässt die Umwelt sinken fällt sie zu tief, vergraulst '
+ 'du genau diese Gäste. Ein 🌿 Naturschutzgebiet hebt die Umwelt wieder und zieht '
+ 'Naturgäste an, sperrt aber Bauplätze in der Nähe. Abwägen ist alles!\n\n'
+ 'Gebaut wird auf den markierten Bauplätzen am Weg starte mit einem Wirtshaus!',
});
if (!localStorage.getItem('tourismusregion_seen')) {
+32 -1
View File
@@ -119,6 +119,30 @@ export function tick(state, dt) {
cost /= 6; // pro Minute → pro 10 s
state.budget -= cost;
state.opCostTotal += cost;
// Übertourismus (§10 Raumnutzungskonflikt): dichte Bebauung + viel Verkehr
// zehren die Umwelt langsam aus. Ab ~6 fertigen Bauwerken beginnt der Druck.
// „Alles zubauen" drückt die Umwelt so über die Partie klar unter die
// 45er-Schwelle — vorher blieb sie fast konstant. Naturschutzgebiet (+20)
// und maßvolles Bauen sind die Gegenmittel.
const builtCount = state.buildings.reduce((n, b) => n + (b.construction <= 0 ? 1 : 0), 0);
const overbuild = Math.max(0, builtCount - 6);
const envErode = overbuild * 0.06 + state.traffic * 0.012; // pro 10 s
if (envErode > 0) state.env = clampEnv(state.env - envErode);
// Einmalige Lern-Warnung, sobald die Umwelt kippt (didaktischer Hinweis).
if (state.env < 50 && !state.envWarned) {
state.envWarned = true;
state.pendingCards.push({
kind: 'card', icon: '🌲', title: 'Die Umwelt leidet!', sound: 'card',
text: 'Deine Umwelt ist unter 50 gefallen. Umweltbewusste Gäste '
+ 'Wanderer, Naturfreunde, Radgruppen werden unzufrieden, wenn sie '
+ 'unter 45 sinkt, und bleiben irgendwann ganz weg.\n\n'
+ 'Was hilft: maßvoller bauen, auf sanfte Anreise (Bus, Rad, Bahn) '
+ 'setzen oder ein 🌿 Naturschutzgebiet bauen, das die Umwelt sofort '
+ 'um +20 hebt. Naturschutz gegen Ausbau abwägen ist der Kern des Spiels.',
});
}
}
// Verkehr: Parkplätze + aktive Auto-/Busgruppen
@@ -495,7 +519,14 @@ function exitVisitor(state, v) {
const tPen = state.traffic * 0.08 * g.trafficSens;
if (tPen > 4) malus.traffic = true;
sat -= tPen;
if (state.env < 45 && g.envSens >= 0.7) malus.env = true;
// Umwelt-Malus WIRKLICH anwenden (war zuvor nur ein toter Flag ohne Wirkung):
// eine kaputte Landschaft vergrault Gäste — je umweltbewusster, desto stärker,
// und je tiefer die Umwelt fällt, desto heftiger. Ab env<60 spürbar, <40 hart.
// Das ist der Raumnutzungskonflikt: zu viel zubauen → Umwelt kippt → Rating fällt.
if (state.env < 60 && g.envSens >= 0.4) {
sat -= g.envSens * (60 - state.env) * 0.5; // env 40 → ~6, env 20 → ~12 (× envSens)
if (state.env < 45) malus.env = true;
}
if (v.timeIn > g.maxTime) { sat *= 0.92; malus.time = true; }
const ratio = Math.max(0, sat) / g.satTarget;
+37 -25
View File
@@ -1487,6 +1487,13 @@ var SIM_LABELS = {
};
function simWord(id){ return (SIM_LABELS[id] && SIM_LABELS[id].w) || id; }
function simCode(id){ return (SIM_LABELS[id] && SIM_LABELS[id].c) || String(id||'').slice(0,3).toUpperCase(); }
// Stabile, unterscheidbare Farbe je Sim (eine Farbe pro Simulation) — Hash→Farbton.
function simColor(id){
if (SIM_LABELS[id] && SIM_LABELS[id].col) return SIM_LABELS[id].col;
var s = String(id||''), h = 0;
for (var i=0;i<s.length;i++) h = (h*33 + s.charCodeAt(i)) % 360;
return 'hsl('+h+',48%,42%)';
}
// Rückwärtskompatibel: LIVE_SIM_SHORT[id] = Wort je Sim.
var LIVE_SIM_SHORT = Object.keys(SIM_LABELS).reduce(function(o,k){ o[k]=SIM_LABELS[k].w; return o; }, {});
function _liveFmtMin(sec) {
@@ -2049,44 +2056,49 @@ async function renderLiveSummary() {
var pctL = _msToPct(sess.startMs);
var pctR = _msToPct(endMs);
var pctW = Math.max(0.8, pctR - pctL); // mindestens 0.8 % sichtbar
var simW = simWord(sess.simId);
var simC = simCode(sess.simId);
var lbl = simW + (sess.lessonLabel ? ' '+sess.lessonLabel : '');
var color = _liveSuccessColor(sess.success);
var simW = simWord(sess.simId), simC = simCode(sess.simId);
var col = simColor(sess.simId); // eine Farbe je Sim
var stars = (sess.stars != null) ? Math.max(0, Math.min(5, sess.stars)) : null;
var aborted = sess.success === 'aborted';
var completed = !aborted && stars != null; // fertig gespielt
var inProgress = !aborted && stars == null; // noch dran / kein Ergebnis
// Balkenhöhe = Maß für den Erfolg (mehr Sterne → höher). Abbruch/läuft: flach.
var barH = completed ? (9 + Math.round(stars / 5 * 15)) : (aborted ? 9 : 12);
// Rahmen kodiert den Zustand: fertig = geschlossen · noch dran = rechts offen ·
// Abbruch = gestrichelt (+ ✖ am Ende).
var border = completed ? 'border:1.5px solid rgba(20,20,20,.45);'
: inProgress ? 'border:1.5px solid rgba(20,20,20,.4);border-right:0;'
: 'border:1.5px dashed rgba(20,20,20,.45);';
var stateWord = completed ? 'fertig' : aborted ? 'abgebrochen' : 'läuft noch';
var tip = (sess.simTitle||sess.simId) + (sess.lessonLabel?' · '+sess.lessonLabel:'')
+ ' · ' + _fmtHM(sess.startMs) + '' + _fmtHM(endMs)
+ ' · ' + _liveFmtMin(realDurSec)
+ (sess.stars!=null?' · '+sess.stars+'/5★':'');
// enger Balken → 3-Buchstaben-Kürzel, mittel → Wort, breit → Wort + Lektion
var inner = (pctW < 2.5) ? '' : (pctW < 6) ? simC : (pctW < 11) ? simW : lbl;
+ ' · ' + _liveFmtMin(realDurSec) + ' · ' + stateWord
+ (stars!=null?' · '+stars+'/5★':'');
// enger Balken → Kürzel, mittel → Wort, breit → Wort + Lektion
var inner = (pctW < 2.5) ? '' : (pctW < 6) ? simC : (pctW < 11) ? simW : (simW + (sess.lessonLabel ? ' '+sess.lessonLabel : ''));
var sessKey = s.studentId + ':' + sess.simId + ':' + (sess.lessonLabel||'_') + ':' + sess.startMs;
html += '<div onclick="openLiveSessionDetail(\''+sessKey+'\')" '
+ 'title="'+esc(tip)+'" '
+ 'style="position:absolute;left:'+pctL+'%;width:'+pctW+'%;top:2px;height:24px;'
+ 'background:'+color+';color:#fff;border-radius:3px;'
+ 'padding:0 .35rem;font-size:.62rem;font-weight:700;line-height:24px;'
+ 'style="position:absolute;left:'+pctL+'%;width:'+pctW+'%;bottom:2px;height:'+barH+'px;'
+ 'background:'+col+';color:#fff;border-radius:3px;'+border
+ 'padding:0 .3rem;font-size:.6rem;font-weight:700;line-height:'+Math.max(9,barH-3)+'px;'
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;'
+ 'box-shadow:0 1px 2px rgba(0,0,0,.1)">'
+ 'text-shadow:0 1px 1px rgba(0,0,0,.35);box-shadow:0 1px 2px rgba(0,0,0,.12)">'
+ esc(inner) + '</div>';
// Nach Abschluss immer die Sterne (gold) am Balkenende zeigen
if (sess.stars != null) {
var _sc = Math.max(0, Math.min(5, sess.stars));
var _starTxt = '★'.repeat(_sc) + '<span style="color:#d9d2be">' + '★'.repeat(5-_sc) + '</span>';
var _rightEdge = pctR > 82;
html += '<span style="position:absolute;'
+ (_rightEdge ? 'right:'+(100-pctR)+'%;padding-right:4px;' : 'left:'+pctR+'%;padding-left:3px;')
+ 'top:0;height:28px;line-height:28px;font-size:.62rem;color:#e0a838;font-weight:700;'
+ 'letter-spacing:.5px;white-space:nowrap;pointer-events:none">'+_starTxt+'</span>';
if (aborted) {
html += '<span style="position:absolute;left:'+pctR+'%;bottom:-1px;height:28px;line-height:28px;'
+ 'font-size:.74rem;color:#b03a2a;font-weight:800;padding-left:1px;pointer-events:none">✖</span>';
}
});
html += '</div>';
});
html += '</div>';
html += '<div style="margin-top:.8rem;display:flex;gap:.6rem;flex-wrap:wrap;font-size:.65rem;color:#5a5a5a">';
[['good','★ ≥ 4 — gut'],['mid','★ 2-3 — mittel'],['bad','★ ≤ 1 — schwach'],['aborted','abgebrochen'],['running','läuft noch']].forEach(function(p){
html += '<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:'+_liveSuccessColor(p[0])+';vertical-align:middle;margin-right:.25rem"></span>'+p[1]+'</span>';
});
html += '<div style="margin-top:.8rem;display:flex;gap:1rem;flex-wrap:wrap;font-size:.66rem;color:#5a5a5a;align-items:center">';
html += '<span><b>Farbe</b> = Simulation · <b>Höhe</b> = Erfolg (höher = besser)</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px solid rgba(20,20,20,.45);vertical-align:middle;margin-right:.25rem"></span>fertig</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px solid rgba(20,20,20,.4);border-right:0;vertical-align:middle;margin-right:.25rem"></span>läuft noch</span>';
html += '<span><span style="display:inline-block;width:16px;height:11px;background:#8a8a8a;border:1.5px dashed rgba(20,20,20,.45);vertical-align:middle;margin-right:.15rem"></span><span style="color:#b03a2a;font-weight:800"></span> Abbruch</span>';
html += '</div>';
html += '</div>';