Tourismustal: iPad-Test-Feedback Runde 1

- Bikepark: echtes 2x2-Footprint (canPlace/place/demolish/upgrade/Report
  ankerbasiert, buildingRef auf Teilflaechen), Sprite ohne Schatten neu
  generiert, zentriert gross gezeichnet. Baukriterium gelockert: Bahn-Naehe
  ersetzt die Zonen-Regel (wie Hotel im Bahn-Umkreis).
- Zeit halbiert: 1 Tag = 4 s bei 1x (Absturz-Rate entsprechend angepasst).
- Problem-Sprechblasen alle ~3 min am Gebaeude (Personal gesucht / kein
  Schnee), erster Hinweis nach 15-35 s.
- Heli: sichtbares Abheben/Landen am Pad (aus vorigem Commit) bleibt.
- Test-Harnesse (model-test, strategien) wandern in App/sims/tourismustal/tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:20:06 +02:00
parent 2b4a92e614
commit 16c562ed1d
7 changed files with 276 additions and 22 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 50 KiB

+2 -2
View File
@@ -60,8 +60,8 @@ export const BUILDINGS = {
},
bikepark: {
name: 'Bikepark', emoji: '🚵', cost: 10000, upkeep: 80,
zone: 'mid', staff: 2, summerCap: 160, trailLen: 5,
desc: 'Flowtrails am Hang: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.',
zone: 'mid', staff: 2, summerCap: 160, trailLen: 5, size: 2,
desc: 'Großes Basis-Areal (2×2 Felder) mit Flowtrails: bis 160 Sommergäste. Braucht eine Bahn in der Nähe (max. 4 Felder), die die Bikes hinaufbringt.',
},
ropes: {
name: 'Hochseilgarten', emoji: '🧗', cost: 6500, upkeep: 45,
+38 -3
View File
@@ -35,7 +35,7 @@ const SPRITE_DEFS = {
access: { w: 52, bottom: 8 },
rescue: { w: 60, bottom: 9 },
snow: { w: 44, bottom: 7 },
bikepark: { w: 58, bottom: 8 },
bikepark: { w: 108, bottom: 14 }, // 2×2-Areal, zentriert gezeichnet
ropes: { w: 58, bottom: 8 },
bikerental: { w: 50, bottom: 8 },
platform: { w: 62, bottom: 9 },
@@ -629,7 +629,8 @@ export class IsoRenderer {
const incident = this.heli || this.guests.some(g => g.mode === 'crashed');
if (!incident) {
for (const g of this.guests) {
if (g.mode === 'ski' && Math.random() < 0.02 * dt) {
// (0.01: Tage sind seit der Zeit-Halbierung doppelt so lang)
if (g.mode === 'ski' && Math.random() < 0.01 * dt) {
g.mode = 'crashed'; g.crashAt = this.simTime;
g.route = []; g.nextLift = null; g.moving = false;
this._say(g, '\u{1F198}', 9e8); // 🆘 bleibt sichtbar
@@ -783,6 +784,32 @@ export class IsoRenderer {
ctx.lineWidth = 1;
}
// Problem-Sprechblasen: Gebäude mit akutem Problem melden sich alle ~3
// Minuten (Echtzeit) mit einer aufsteigenden Blase z. B. „Personal
// gesucht!" damit man Ursachen auch ohne Klick ins Gebäude sieht.
_problemBubbles() {
const s = this.state;
const now = this.time;
if (!this._probNext) this._probNext = {};
const staffGap = s.staffNeeded > s.staffAvailable;
for (const b of s.buildings) {
const def = BUILDINGS[b.type];
let msg = null;
if (staffGap && (def.staff || 0) > 0) msg = '👷 Personal gesucht!';
else if ((b.type === 'lift' || b.type === 'gondola')
&& s.season === 'Winter' && !liftSkiable(s, b.alt ?? 3)) msg = '❄️ Kein Schnee!';
const k = `${b.c},${b.r}`;
if (!msg) { delete this._probNext[k]; continue; }
if (!this._probNext[k]) {
// erster Hinweis nach 1535 s, danach im 3-Minuten-Takt
this._probNext[k] = now + 15000 + Math.random() * 20000;
} else if (now >= this._probNext[k]) {
this._probNext[k] = now + 180000;
this.addFloat(msg, '#c0392b', b.c + 0.5, b.r - 0.4, true);
}
}
}
// Sichtbarer Umbau: ~10 s Bauzeit (Spielzeit), danach wird die neue Stufe
// scharf geschaltet. Läuft für ALLE Baustellen (auch außerhalb des Bildes).
CONSTRUCT_MS = 10000;
@@ -894,6 +921,7 @@ export class IsoRenderer {
this._drawBus();
this._drawFestivals(winter);
this._drawStaffAlerts();
this._problemBubbles();
this._drawConstruction();
this._drawRiders();
this._drawStaff();
@@ -1084,7 +1112,8 @@ export class IsoRenderer {
this._drawTree(x, y, snowy, tile.tint);
}
if (tile.deco) this._drawDeco(tile.deco, x, y);
if (tile.building) this._drawBuilding(tile.building, x, y, tile);
// Große Gebäude (2×2) zeichnet nur die Anker-Kachel zentriert übers Areal
if (tile.building && !tile.buildingRef) this._drawBuilding(tile.building, x, y, tile);
// Noch nicht erschlossene Hochlagen liegen unter Nebel
if (tile.r < this.state.stageRow) {
@@ -1635,6 +1664,12 @@ export class IsoRenderer {
_drawBuilding(type, x, y, tile) {
const ctx = this.ctx;
const level = tile.level || 1;
// 2×2-Gebäude: zentriert über dem Areal zeichnen (Anker = links oben)
if ((BUILDINGS[type]?.size || 1) > 1) {
const half = (BUILDINGS[type].size - 1) / 2;
const ctr = this.tileToWorld(tile.c + half, tile.r + half, tile.elev);
x = ctr.x; y = ctr.y;
}
// KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch)
const spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type;
const spriteScale = type === 'hotel'
+1 -1
View File
@@ -11,7 +11,7 @@ import { player } from './player.js';
window.__ttLoaded = true; // Signal an den file://-Fallback in index.html
const MS_PER_DAY = 2000; // 1 Tag = 2 s bei Geschwindigkeit 1× ruhiges Tempo
const MS_PER_DAY = 4000; // 1 Tag = 4 s bei 1× ruhiges Tempo (halbiert 2026-07-11)
// Ereignis-Pacing: Schonfrist zum Ankommen + Mindestabstand zwischen
// Ereignis-/Entscheidungskarten, damit am Anfang nicht mehrere Karten im
+52 -16
View File
@@ -248,20 +248,31 @@ export function canPlace(state, type, c, r) {
const def = BUILDINGS[type];
const tile = state.map[r]?.[c];
if (!def || !tile) return { ok: false, reason: 'Außerhalb der Karte.' };
if (!inCore(c, r)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge im Tal bauen.' };
if (!state.unlocked.has(type)) return { ok: false, reason: 'Noch nicht freigeschaltet.' };
if (tile.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' };
if (tile.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' };
if (tile.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' };
if (tile.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' };
if (tile.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' };
if (r < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' };
if (state.money < def.cost) return { ok: false, reason: 'Nicht genug Geld.' };
// Footprint prüfen: 1×1 Standard, große Gebäude (size 2 = 2×2) brauchen
// jede Teilfläche frei. Anker ist die Kachel links oben (c, r).
const size = def.size || 1;
for (let dr = 0; dr < size; dr++) {
for (let dc = 0; dc < size; dc++) {
const t = state.map[r + dr]?.[c + dc];
const platz = size > 1 ? ` (${def.name} braucht ${size}×${size} freie Felder)` : '';
if (!t) return { ok: false, reason: 'Außerhalb der Karte.' + platz };
if (!inCore(c + dc, r + dr)) return { ok: false, reason: 'Hier ist unwegsames Hochgebirge im Tal bauen.' + platz };
if (t.building) return { ok: false, reason: 'Hier steht schon ein Gebäude.' + platz };
if (t.terrain === 'rock') return { ok: false, reason: 'Auf Fels kann nicht gebaut werden.' + platz };
if (t.terrain === 'water') return { ok: false, reason: 'Im Fluss kann nicht gebaut werden.' + platz };
if (t.terrain === 'village') return { ok: false, reason: 'Hier steht das alte Dorf.' + platz };
if (t.road) return { ok: false, reason: 'Auf der Straße kann nicht gebaut werden.' + platz };
if (r + dr < state.stageRow) return { ok: false, reason: 'Die Hochlagen sind noch nicht erschlossen.' };
}
}
if (def.zone !== 'any') {
const [lo, hi] = ZONE_RANGE[def.zone];
// Lift/Gondel wirken wie eine eigene Basis: in ihrem Umkreis (4 Felder)
// darf auch am Gebirge alles gebaut werden (außer Bus & Bad).
const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse'].includes(type)
// darf auch am Gebirge alles gebaut werden (außer Bus & Bad). Der
// Bikepark hängt ohnehin an der Bahn Bahn-Nähe ersetzt die Zonen-Regel.
const nearLift = ['lift', 'gondola', 'restaurant', 'hotel', 'staffhouse', 'bikepark'].includes(type)
&& state.buildings.some(b =>
(b.type === 'lift' || b.type === 'gondola') && dist(b, { c, r }) <= 4);
if (!nearLift && (tile.elev < lo || tile.elev > hi)) {
@@ -296,11 +307,21 @@ export function place(state, type, c, r) {
const def = BUILDINGS[type];
const tile = state.map[r][c];
const wasForest = tile.terrain === 'forest';
tile.building = type;
state.buildings.push({ type, c, r, alt: tile.elev });
const size = def.size || 1;
state.buildings.push({ type, c, r, alt: tile.elev, size });
state.money -= def.cost;
// Bauen kostet Naturwert Rodung von Wald deutlich mehr
state.nature = Math.max(15, state.nature - (wasForest ? 5 : 2));
// Alle Teilflächen belegen; Nicht-Anker-Kacheln zeigen auf den Anker
// (buildingRef), damit Klick/Abriss überall auf dem Areal funktioniert.
for (let dr = 0; dr < size; dr++) {
for (let dc = 0; dc < size; dc++) {
const t = state.map[r + dr]?.[c + dc];
if (!t) continue;
t.building = type;
if (dr || dc) t.buildingRef = { c, r };
// Bauen kostet Naturwert Rodung von Wald deutlich mehr
state.nature = Math.max(15, state.nature - (t.terrain === 'forest' ? 5 : 2));
}
}
if (def.pisteLen) {
// Piste entsteht hangaufwärts über der Talstation (Lift & Gondel)
@@ -328,9 +349,12 @@ export function place(state, type, c, r) {
}
export function demolish(state, c, r) {
const tile = state.map[r][c];
let tile = state.map[r][c];
if (!tile.building) return { ok: false, reason: 'Hier steht kein Gebäude.' };
// Klick auf eine Teilfläche eines großen Gebäudes → auf den Anker umlenken
if (tile.buildingRef) { ({ c, r } = tile.buildingRef); tile = state.map[r][c]; }
const def = BUILDINGS[tile.building];
const size = def.size || 1;
const refund = Math.round(def.cost * 0.3);
state.money += refund;
state.nature = Math.min(100, state.nature + 1);
@@ -338,8 +362,15 @@ export function demolish(state, c, r) {
state.pistes = state.pistes.filter(p => !(p.owner.c === c && p.owner.r === r));
state.trails = state.trails.filter(t => !(t.owner.c === c && t.owner.r === r));
const name = def.name;
tile.building = null;
tile.level = null;
for (let dr = 0; dr < size; dr++) {
for (let dc = 0; dc < size; dc++) {
const t = state.map[r + dr]?.[c + dc];
if (!t) continue;
t.building = null;
t.level = null;
delete t.buildingRef;
}
}
return { ok: true, refund, name };
}
@@ -380,6 +411,9 @@ export function seasonFactor(type, season) {
// Voller Bericht zu EINEM Gebäude: Werte, Auslastung, Tageskosten, Nutzen.
// Grundlage der Inspektions-/Ausbaukarte (Lernprogramm: alles sichtbar).
export function buildingReport(state, c, r) {
// Teilfläche eines großen Gebäudes → Anker
const ref = state.map[r]?.[c]?.buildingRef;
if (ref) ({ c, r } = ref);
const b = state.buildings.find(x => x.c === c && x.r === r);
if (!b) return null;
const def = BUILDINGS[b.type];
@@ -430,6 +464,8 @@ export function upgradeCost(type, level) {
}
export function upgrade(state, c, r) {
const ref = state.map[r]?.[c]?.buildingRef;
if (ref) ({ c, r } = ref);
const tile = state.map[r]?.[c];
const b = state.buildings.find(x => x.c === c && x.r === r);
if (!tile?.building || !b) return { ok: false, reason: 'Hier steht kein Gebäude.' };
@@ -0,0 +1,76 @@
// Modell-Langzeittest ohne Browser: spielt eine typische Schüler-Strategie
// über 600 Tage (5 Jahre) durch inkl. Freischalt-Reihenfolge und prüft,
// ob Balancing und Meilensteine plausibel bleiben.
import { createState, simulateDay, canPlace, place, MAP_COLS, MAP_ROWS } from '../js/model.js';
import { MILESTONES } from '../js/data.js';
const s = createState();
// Bau-Wunschliste in sinnvoller Reihenfolge; gebaut wird, sobald
// freigeschaltet, leistbar und ein Platz gefunden ist.
const queue = ['restaurant', 'lift', 'access', 'hotel', 'staffhouse',
'restaurant', 'lift', 'snow'];
function tryBuildNext() {
const type = queue[0];
if (!type) return;
for (let r = MAP_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < MAP_COLS; c++) {
if (canPlace(s, type, c, r).ok) {
place(s, type, c, r);
queue.shift();
console.log(` Tag ${s.day + 1}: ${type} gebaut @ ${c},${r}`);
return;
}
}
}
}
let goalsDay = null;
const milestonesFired = new Set();
for (let d = 1; d <= 600; d++) {
// Meilensteine wie im Spiel freischalten
for (const m of MILESTONES) {
if (!milestonesFired.has(m.id) && m.when(s)) {
milestonesFired.add(m.id);
s.unlocked.add(m.unlock);
console.log(` Tag ${d}: Meilenstein ${m.id}${m.unlock} frei`);
}
}
tryBuildNext();
simulateDay(s);
if (s.goalsDone && goalsDay === null) goalsDay = d;
if (!Number.isFinite(s.money) || !Number.isFinite(s.nature)) {
console.error('NaN am Tag', d); process.exit(1);
}
if (d % 120 === 0) {
console.log(`Tag ${d} (${s.season} ${s.year}): Geld ${s.money} € · Gäste ${s.guestsToday} · maxGäste ${s.stats.maxGuests} · Zufr. ${s.satisfaction} % · Natur ${Math.round(s.nature)} %`);
}
}
console.log('---');
console.log('Nicht gebaut:', queue.length ? queue : 'alles gebaut');
console.log('Meilensteine:', [...milestonesFired].join(', ') || 'KEINE');
console.log('Ziele erreicht an Tag:', goalsDay ?? 'NICHT erreicht');
console.log('Endstand:', `${s.money}`, `· maxGäste ${s.stats.maxGuests}`, `· Natur ${Math.round(s.nature)} %`, `· Zufriedenheit ${s.satisfaction} %`);
// --- Kurzcheck der weiteren Szenen: Vorbauten + 240 Tage ohne Ausreißer ---
for (const scene of ['boom', 'klimawandel']) {
const st = createState(scene);
console.log(`\n[${scene}] Vorbauten:`, st.buildings.map(b => b.type).join(', '),
`· Start: ${st.money} € · Natur ${st.nature} % · freigeschaltet: ${[...st.unlocked].join(', ')}`);
// im Klimawandel-Szenario einen Panoramaweg dazubauen
if (scene === 'klimawandel') {
outer:
for (let r = MAP_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < MAP_COLS; c++) {
if (canPlace(st, 'trail', c, r).ok) { place(st, 'trail', c, r); break outer; }
}
}
}
for (let d = 1; d <= 240; d++) {
simulateDay(st);
if (!Number.isFinite(st.money) || !Number.isFinite(st.nature)) {
console.error(`[${scene}] NaN am Tag`, d); process.exit(1);
}
}
console.log(`[${scene}] Tag 240 (${st.season} ${st.year}): Geld ${st.money} € · maxGäste ${st.stats.maxGuests} · maxSommer ${st.stats.maxSummerGuests} · Natur ${Math.round(st.nature)} % · Zufr. ${st.satisfaction} %`);
}
+107
View File
@@ -0,0 +1,107 @@
// Strategie-Durchspiel: prüft, was das Modell belohnt und bestraft.
// Beantwortet: Funktioniert "Panzer-Rush" (alles sofort bauen)? Braucht man
// Rücklagen für die einmaligen Entscheidungs-Karten (Skirennen …)? Was
// passiert bei Preis-Extremen? Winter-only vs. Ganzjahr in Szene 3?
// node tests/strategien.mjs
import { createState, simulateDay, canPlace, place, MAP_COLS, MAP_ROWS } from '../js/model.js';
import { BUILDINGS, MILESTONES, DECISIONS } from '../js/data.js';
function fireMilestones(s, fired) {
for (const m of MILESTONES) {
if (fired.has(m.id)) continue;
if (!m.when(s)) continue;
fired.add(m.id);
const unlocks = Array.isArray(m.unlock) ? m.unlock : [m.unlock];
unlocks.forEach(u => s.unlocked.add(u));
m.apply?.(s);
}
}
// Entscheidungs-Karten wie im Spiel: einmalig, Ja nur wenn leistbar.
function handleDecisions(s, st) {
for (const d of DECISIONS) {
if (s.firedEvents.has(d.id)) continue;
if (!d.when(s)) continue;
s.firedEvents.add(d.id);
st.offered.push(d.id);
if (!d.yes.can || d.yes.can(s)) { d.yes.apply(s); st.taken.push(d.id); }
else { st.missed.push(`${d.id} (Tag ${s.day}, nur ${s.money} €)`); d.no.apply(s); }
}
}
function findSpot(s, type) {
for (let r = MAP_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < MAP_COLS; c++) {
if (canPlace(s, type, c, r).ok) return { c, r };
}
}
return null;
}
function run(name, sceneId, days, buildFn, priceFn) {
const s = createState(sceneId);
const fired = new Set();
const st = { offered: [], taken: [], missed: [], minMoney: s.money, lossDays: 0 };
for (let d = 1; d <= days; d++) {
fireMilestones(s, fired);
buildFn(s, d);
if (priceFn) s.price = priceFn(s, d);
simulateDay(s);
handleDecisions(s, st);
st.minMoney = Math.min(st.minMoney, s.money);
if (s.lastNet < 0) st.lossDays++;
if (!Number.isFinite(s.money)) { console.error(name, 'NaN Tag', d); process.exit(1); }
}
const goals = s.scenario.goals.filter(g => g.check(s)).length;
console.log(
`${name.padEnd(22)} Geld ${String(s.money).padStart(8)} € · min ${String(st.minMoney).padStart(7)}`
+ `· maxG ${String(s.stats.maxGuests).padStart(3)} · maxSo ${String(s.stats.maxSummerGuests).padStart(3)} `
+ `· Zufr ${s.satisfaction} % · Natur ${Math.round(s.nature)} % · Ziele ${goals}/${s.scenario.goals.length} `
+ `· Verlusttage ${st.lossDays} · Geb. ${s.buildings.length}`);
if (st.missed.length) console.log(` ⚠️ verpasste Chancen: ${st.missed.join(' · ')}`);
return { s, st };
}
const QUEUE = ['restaurant', 'lift', 'access', 'hotel', 'staffhouse',
'restaurant', 'lift', 'snow'];
function queueBuilder(queue, reserve = 0) {
const q = [...queue];
return s => {
if (!q.length) return;
const type = q[0];
if (!BUILDINGS[type] || s.money - BUILDINGS[type].cost < reserve) return;
const spot = findSpot(s, type);
if (spot) { place(s, type, spot.c, spot.r); q.shift(); }
};
}
// Panzer-Rush: baut JEDEN Tag alles, was geht (freigeschaltet + leistbar).
function rushBuilder() {
return s => {
let built = true;
while (built) {
built = false;
for (const type of Object.keys(BUILDINGS)) {
if (!s.unlocked.has(type)) continue;
const spot = findSpot(s, type);
if (spot) { place(s, type, spot.c, spot.r); built = true; break; }
}
}
};
}
console.log('=== SZENE 1 · Pionierzeit, 600 Tage ===');
run('Referenz (Plan)', 'pionierzeit', 600, queueBuilder(QUEUE));
run('Panzer-Rush', 'pionierzeit', 600, rushBuilder());
run('Rücklage 8000 €', 'pionierzeit', 600, queueBuilder(QUEUE, 8000));
run('Geizhals (2 Geb.)', 'pionierzeit', 600, queueBuilder(['restaurant', 'lift']));
run('Preis 45 € (Abzocke)', 'pionierzeit', 600, queueBuilder(QUEUE), () => 45);
run('Preis 15 € (Dumping)', 'pionierzeit', 600, queueBuilder(QUEUE), () => 15);
console.log('\n=== SZENE 3 · Schneegrenze 2035, 600 Tage ===');
run('Nur Winter ausbauen', 'klimawandel', 600,
queueBuilder(['gondola', 'snow', 'snow', 'staffhouse', 'hotel', 'restaurant']));
run('Ganzjahres-Strategie', 'klimawandel', 600,
queueBuilder(['badesee', 'sled', 'trail', 'staffhouse', 'hotel', 'bikepark', 'playground', 'bikerental']));
run('Rush Klimawandel', 'klimawandel', 600, rushBuilder());