diff --git a/README.md b/README.md index 2178a520..050d350e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ npm test # run Vitest test suite (non-destructive: does not modify t npm run build # TypeScript check + production build -> dist/ npm run preview # serve production build locally npm run tf:generate # generate ToneForge artifacts to build/tf-synths/ + +# Smoke test: run deterministic Main Street demo (seed: smoke-1) +npx tsx scripts/demo-main-street.ts --seed "smoke-1" + +# Headless smoke test (part of npm test): +npx vitest run --project unit tests/main-street/smoke-scenario.test.ts ``` ## What Is This? @@ -72,6 +78,7 @@ tableau-card-engine/ | Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win | | Lost Cities | `example-games/lost-cities/` | Two-player expedition card game (human vs. AI). Bet on up to 5 colored expeditions across a 3-round match with investment multipliers, ascending-play rules, and cumulative scoring | | Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns | +| Scenario: Tutorial | `example-games/main-street/scenes/MainStreetTutorialScene.ts` | Guided introduction to Main Street. Non-interactive tutorial overlays walk through the market, street placement, synergies, events, and scoring. Easy difficulty, 25 turns. Accessible from the Game Selector. | More games are planned: Coloretto. diff --git a/docs/main-street/playtest-scenarios.md b/docs/main-street/playtest-scenarios.md index ca97663f..73034ae2 100644 --- a/docs/main-street/playtest-scenarios.md +++ b/docs/main-street/playtest-scenarios.md @@ -1,7 +1,7 @@ # Main Street: Playtest Scenarios > **Work item:** CG-0MMJCMVMQ1TGTM0R (Playtest Scenarios & Balance Tasks) -> **Last updated:** M2 Expanded Card Pool +> **Last updated:** M2 Expanded Card Pool + Tutorial Scenario (CG-0MM5ZGB8U02S0BFO) This document defines curated playtest scenarios for validating the M2 expanded card pool. Each scenario uses a deterministic seed so results are exactly reproducible. Designers can run these scenarios to verify balance expectations after any card or rule changes. @@ -11,6 +11,9 @@ This document defines curated playtest scenarios for validating the M2 expanded # Run a single scenario by seed npx tsx scripts/demo-main-street.ts --seed "sweep-63" +# Run the canonical smoke-test scenario (seed: smoke-1) +npx tsx scripts/demo-main-street.ts --seed "smoke-1" + # Run all 5 curated scenarios and compare results npx tsx scripts/playtest-scenarios.ts @@ -23,6 +26,48 @@ npm run monte-carlo --- +## Scenario 0: "Tutorial Smoke" (seed: `smoke-1`) ← **CI Smoke Seed** + +**Category:** Loss -- Bankruptcy +**Expected outcome:** Loss on turn 4 (bankruptcy) +**Score:** 43 | **Turns:** 4 +**Difficulty used in test:** Medium (greedy strategy baseline); Easy for Tutorial scenario UI + +### What happens + +The player purchases a Florist and a Block Party investment on turn 1, then faces a Tax Audit incident. The tight coin reserve leads to bankruptcy by turn 4. + +### Smoke test + +This seed is wired to `tests/main-street/smoke-scenario.test.ts` (included in `npm test`): + +```bash +# Run smoke test directly +npx vitest run --project unit tests/main-street/smoke-scenario.test.ts + +# Run via CLI demo (JSON output) +npx tsx scripts/demo-main-street.ts --seed "smoke-1" +``` + +**Assertions made by the smoke test:** +- Run completes without errors +- All required summary fields present (`game`, `version`, `seed`, `totalTurns`, `result`, `endReason`, `finalScore`, `turns`) +- Run is deterministic (two runs with the same seed produce identical output) +- Each turn record has the expected fields + +**Tutorial scenario (Easy):** The same seed with Easy difficulty will produce a different outcome since Easy provides more starting coins and 25 turns. This is tested in the `smoke-scenario.test.ts` as the "Tutorial scenario baseline" suite. + +### Adding or updating tutorial text + +Tutorial steps are defined in `example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts` in the `TUTORIAL_STEPS` array. Each step has: +- `title` — short heading shown in bold +- `body` — multi-line description text +- `anchor` — function that returns the `{x, y, w, h}` bounding box to highlight, or `null` for centred + +To add a step, append a new `TutorialStep` object to `TUTORIAL_STEPS`. To change copy, edit the `title` and `body` strings. All strings are localizable by replacing the string literals with i18n key lookups when i18n support is added. + +--- + ## Scenario 1: "Quick Bankruptcy" (seed: `sweep-63`) **Category:** Loss -- Bankruptcy diff --git a/example-games/main-street/MainStreetSaveLoad.ts b/example-games/main-street/MainStreetSaveLoad.ts index fd5696f8..e8dbaf8b 100644 --- a/example-games/main-street/MainStreetSaveLoad.ts +++ b/example-games/main-street/MainStreetSaveLoad.ts @@ -46,7 +46,13 @@ export const mainStreetCampaignSerializer: SaveSerializer< milestoneHistory: [], }; } - return structuredClone(data); + + // Ensure tutorialSeen flag exists on returned object for runtime code. + const cloned = structuredClone(data) as any; + if (typeof cloned.tutorialSeen === 'undefined') { + cloned.tutorialSeen = false; + } + return cloned as MainStreetCampaignProgress; }, }; @@ -61,6 +67,7 @@ export function createDefaultCampaignProgress(): MainStreetCampaignProgress { totalRuns: 0, totalWins: 0, lastUpdatedAt: new Date().toISOString(), + tutorialSeen: false, }; } diff --git a/example-games/main-street/MainStreetState.ts b/example-games/main-street/MainStreetState.ts index c6232901..d251f482 100644 --- a/example-games/main-street/MainStreetState.ts +++ b/example-games/main-street/MainStreetState.ts @@ -269,6 +269,8 @@ export interface MainStreetCampaignProgress { totalWins: number; /** ISO 8601 timestamp of the last update to this campaign data. */ lastUpdatedAt: string; + /** Whether the introductory tutorial has been completed by the player. */ + tutorialSeen?: boolean; } // ── Setup Options ─────────────────────────────────────────── diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index cbdc1a83..9e0cfc7f 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -1,5 +1,5 @@ import { setupMainStreetGame, deserializeMainStreetState } from '../MainStreetState'; -import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun } from '../MainStreetSaveLoad'; +import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun, saveCampaignProgress } from '../MainStreetSaveLoad'; import { DIFFICULTY_NAMES } from '../MainStreetDifficulty'; import { SaveLoadStore, markSceneValid, markSceneInvalid, createTfPlayer, UndoRedoManager } from '../../../src/core-engine'; import { createSingleSelectionManager, TooltipManager } from '../../../src/ui'; @@ -15,6 +15,7 @@ import { MainStreetOverlayManager } from './MainStreetOverlayManager'; import { MainStreetInputManager } from './MainStreetInputManager'; import { MainStreetSvgTextureManager } from './MainStreetSvgTextureManager'; import { SvgDomRenderer } from './SvgDomRenderer'; +import { MainStreetTutorialOverlayManager } from './MainStreetTutorialOverlayManager'; import { getEndTurnKeybind } from '../../../src/ui/SettingsStore'; export class MainStreetLifecycleManager { @@ -175,6 +176,35 @@ export class MainStreetLifecycleManager { ); }); + // UI scaffolding + s.msRenderer = new MainStreetRenderer(s); + s.msAnimator = new MainStreetAnimator(s); + s.msTurnController = new MainStreetTurnController(s); + s.msOverlayManager = new MainStreetOverlayManager(s); + s.msInputManager = new MainStreetInputManager(s); + s.msSvgTextureManager = new MainStreetSvgTextureManager(s); + s.layout = s.computeLayout(); + s.svgDebugEnabled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('msSvgDebug') === '1'; + + // Create tutorial overlay manager early so it's available to any async + // callbacks (campaign load) that may want to auto-show the tutorial. + try { + (s as any).tutorialOverlay = new MainStreetTutorialOverlayManager(s, () => { + try { + if (s.campaign) { + s.campaign.tutorialSeen = true; + if (s.saveStore) { + // Persist the updated campaign progress asynchronously + void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {}); + } + } + } catch (_) { /* ignore */ } + }); + } catch (e) { + // Ignore if DOM environment is unavailable (tests) + /* keep silent on creation failure */ + } + // Game setup -- load campaign for tier-filtered deck building s.saveStore = new SaveLoadStore(); this.loadCampaignAndSetup(); @@ -192,15 +222,6 @@ export class MainStreetLifecycleManager { // ignore if recorder cannot be created } - // UI scaffolding - s.msRenderer = new MainStreetRenderer(s); - s.msAnimator = new MainStreetAnimator(s); - s.msTurnController = new MainStreetTurnController(s); - s.msOverlayManager = new MainStreetOverlayManager(s); - s.msInputManager = new MainStreetInputManager(s); - s.msSvgTextureManager = new MainStreetSvgTextureManager(s); - s.layout = s.computeLayout(); - s.svgDebugEnabled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('msSvgDebug') === '1'; // Prewarm SVG textures once all SVG sources are loaded. // Until then the scene uses fallback cards; then we refresh with SVG textures. void s.cardSvgLoadPromise @@ -342,6 +363,68 @@ export class MainStreetLifecycleManager { s.tooltipManager = new TooltipManager(s, s.settingsPanel); } + // Create tutorial overlay manager (attached to main scene) so the tutorial + // can be shown from Settings or automatically on first run. The onComplete + // callback marks the campaign as having seen the tutorial and persists it. + try { + (s as any).tutorialOverlay = new (require('./MainStreetTutorialOverlayManager').MainStreetTutorialOverlayManager)(s, () => { + try { + if (s.campaign) { + s.campaign.tutorialSeen = true; + if (s.saveStore) { + // Persist the updated campaign progress asynchronously + void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {}); + } + } + } catch (_) { /* ignore */ } + }); + } catch (_) { + // Ignore if DOM environment is unavailable (tests) + } + + // Listen for Settings 'Play Tutorial' request and log for debugging + try { + if (typeof window !== 'undefined' && (window as any).addEventListener) { + (window as any).addEventListener('tce:play-tutorial', () => { + try { + (s as any).tutorialOverlay?.start(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[MainStreet] play-tutorial handler failed', e); + } + }); + // Replay tutorial: warn in settings, then dispatch this event to restart current run into tutorial mode + (window as any).addEventListener('tce:replay-tutorial', () => { + try { + if (s.campaign) { + s.campaign.tutorialSeen = false; + if (s.saveStore) { + // Persist change but do not block + void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {}); + } + } + + // Restart the current run as a tutorial run (force Easy difficulty) + try { + s.selectedDifficulty = 'Easy'; + s.state = setupMainStreetGame({ difficulty: 'Easy', unlockedCardIds: s.campaign?.unlockedCardIds }); + s.startDayPhase(); + // show tutorial overlay if available + try { (s as any).tutorialOverlay?.start(); } catch (_) { /* ignore */ } + } catch (e) { + // eslint-disable-next-line no-console + console.error('[MainStreet] failed to restart into tutorial', e); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('[MainStreet] replay-tutorial handler failed', e); + } + }); + } + } catch (_e) { + // ignore + } + // Global keyboard handler for End Turn (configurable via Settings) const endTurnKeyHandler = (ev: KeyboardEvent) => { try { @@ -416,7 +499,8 @@ export class MainStreetLifecycleManager { // Async: attempt to load saved campaign and re-setup if found if (s.saveStore) { - loadCampaignProgress(s.saveStore).then((saved: any) => { + // Store the load promise on the scene so other code can wait if needed + (s as any)._campaignLoadPromise = loadCampaignProgress(s.saveStore).then((saved: any) => { if (saved) { s.campaign = saved; // Re-setup with the loaded campaign's unlocked cards @@ -429,11 +513,35 @@ export class MainStreetLifecycleManager { // phase is synchronised. Without this, the engine stays in // DayStart while the UI shows market controls, blocking all // player actions and causing End Turn to hang. - s.startDayPhase(); + try { s.startDayPhase(); } catch (_) { /* ignore */ } } + // After attempting to load (saved or not) auto-show tutorial if not seen + try { + if (s.campaign && !(s.campaign as any).tutorialSeen) { + if ((s as any).tutorialOverlay) { + try { (s as any).tutorialOverlay.start(); } catch (_e) { /* ignore */ } + } + } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial check failed', e); } + return saved; }).catch(() => { // If load fails, continue with defaults (already set up above) + try { + if (s.campaign && !(s.campaign as any).tutorialSeen) { + if ((s as any).tutorialOverlay) { + try { (s as any).tutorialOverlay.start(); } catch (_e) { /* ignore */ } + } + } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial fallback failed', e); } + return null; }); + } else { + // No saveStore: if tutorial hasn't been seen, show it now (best-effort) + try { + if (s.campaign && !(s.campaign as any).tutorialSeen && (s as any).tutorialOverlay) { + try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ } + } + } catch (_) { /* ignore */ } } } diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index b2a232b8..19ff3416 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -58,6 +58,12 @@ import { STREET_ROW_GAP, } from './MainStreetConstants'; +/** Tag a Phaser game object as transient so `refreshHud()` knows to destroy it on the next refresh. */ +function markHudTransient(obj: T): T & { _hudTransient: true } { + (obj as any)._hudTransient = true; + return obj as T & { _hudTransient: true }; +} + export class MainStreetRenderer { constructor(private readonly scene: any) {} @@ -254,35 +260,46 @@ export class MainStreetRenderer { public refreshHud(): void { const s = this.scene; - s.hudContainer.removeAll(true); + + // Remove only elements tagged as transient HUD items so that persistent overlay + // objects (helpPanel, settingsPanel, buttons) that also reside in hudContainer are + // not destroyed on each refresh. Using removeAll(true) would destroy those persistent + // children, breaking their parentContainer reference and causing the SidebarOverlay test + // (and the live game) to lose the panels after the first refresh. + const hudList = [...s.hudContainer.list] as Array; + for (const child of hudList) { + if (child._hudTransient) { + s.hudContainer.remove(child, true); + } + } const score = computeScore(s.state); const { coins, reputation } = s.state.resourceBank; const { gameW, hudY } = s.layout; // Background strip - 2/3 width, centered - const strip = s.add.rectangle(gameW / 2, hudY, gameW * 0.66, 28, 0x1a1408, 0.6); + const strip = markHudTransient(s.add.rectangle(gameW / 2, hudY, gameW * 0.66, 28, 0x1a1408, 0.6)); strip.setStrokeStyle(1, BOX_STROKE, 0.5); s.hudContainer.add(strip); // Coins - centered in strip const stripWidth = gameW * 0.66; const stripLeft = (gameW - stripWidth) / 2; - const coinText = s.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins}`, { + const coinText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins}`, { fontSize: '16px', fontStyle: 'bold', color: '#ffcc44', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); + }).setOrigin(0, 0.5)); s.hudContainer.add(coinText); // Reputation - centered in strip - const repText = s.add.text(stripLeft + stripWidth * 0.5, hudY, `Rep: ${reputation}`, { + const repText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.5, hudY, `Rep: ${reputation}`, { fontSize: '16px', fontStyle: 'bold', color: '#88bbff', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); + }).setOrigin(0, 0.5)); s.hudContainer.add(repText); // Score - right side of strip - const scoreText = s.add.text(stripLeft + stripWidth * 0.85, hudY, `Score: ${score}`, { + const scoreText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.85, hudY, `Score: ${score}`, { fontSize: '16px', fontStyle: 'bold', color: '#ff8844', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); + }).setOrigin(0, 0.5)); s.hudContainer.add(scoreText); s.animateHudValueChanges({ diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 73eed3bd..ed06b5a4 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -19,6 +19,7 @@ import { MainStreetOverlayManager } from './MainStreetOverlayManager'; import { MainStreetInputManager } from './MainStreetInputManager'; import { MainStreetSvgTextureManager } from './MainStreetSvgTextureManager'; import { MainStreetLifecycleManager } from './MainStreetLifecycleManager'; +import { MainStreetTutorialOverlayManager } from './MainStreetTutorialOverlayManager'; import { type SceneLayout, STREET_ROWS, @@ -40,6 +41,7 @@ export class MainStreetScene extends CardGameScene { public msInputManager!: MainStreetInputManager; public msSvgTextureManager!: MainStreetSvgTextureManager; public msLifecycleManager!: MainStreetLifecycleManager; + public tutorialOverlay?: MainStreetTutorialOverlayManager; // Game state public state!: MainStreetState; public uiPhase: UIPhase = 'idle'; @@ -114,8 +116,8 @@ export class MainStreetScene extends CardGameScene { // Undo/Redo manager for market actions (per-scene) public undoManager!: UndoRedoManager; - constructor() { - super({ key: 'MainStreetScene' }); + constructor(config?: Partial) { + super({ key: 'MainStreetScene', ...(config ?? {}) }); this.msLifecycleManager = new MainStreetLifecycleManager(this); } diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts new file mode 100644 index 00000000..db1b944b --- /dev/null +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -0,0 +1,498 @@ +/** + * MainStreetTutorialOverlayManager -- Non-interactive tutorial overlays for Main Street. + * + * Displays a sequence of contextual tooltip hints that highlight key UI + * regions (market, street slots, hand, action controls, scoring). + * + * Overlays are purely informational: they do not block gameplay interaction. + * The player can dismiss individual hints or toggle the whole tutorial off. + * + * Usage: + * const mgr = new MainStreetTutorialOverlayManager(scene); + * mgr.showStep(0); // show first hint + * mgr.nextStep(); // advance to next hint + * mgr.dismiss(); // hide all hints + * mgr.toggle(); // show/hide tutorial + * + * @module + */ + +import { FONT_FAMILY } from '../../../src/ui'; +import { MARKET_BUSINESS_SLOTS, INCIDENT_QUEUE_SIZE } from '../MainStreetCards'; + +// ── Tutorial step definitions ──────────────────────────────── + +/** + * A single tutorial step: a title, body text, and an anchor function that + * returns the screen-space rectangle (x, y, w, h) to highlight. + * + * If `anchor` returns null the tooltip is shown centred on screen. + */ +export interface TutorialStep { + title: string; + body: string; + /** Returns {x, y, w, h} bounding box to highlight, or null for centred. */ + anchor: (scene: any) => { x: number; y: number; w: number; h: number } | null; +} + +/** The ordered set of tutorial hints shown to new players. */ +export const TUTORIAL_STEPS: TutorialStep[] = [ + { + title: 'Welcome to Main Street!', + body: + 'Build the most profitable street in town!\n' + + 'Buy businesses, place them on your street, earn\n' + + 'coins & reputation, and reach the score target.\n\n' + + 'This is "Scenario: Tutorial" — Easy difficulty,\n' + + '25 turns, and a lower score target.\n\n' + + 'Tap [Next] to learn the controls.', + anchor: () => null, + }, + { + title: 'The Market', + body: + 'The top section shows cards for sale.\n' + + 'Business cards (top row) go on your street.\n' + + 'Investment/Upgrade cards (bottom row) give\n' + + 'one-time effects or improve existing businesses.\n\n' + + 'Click a card to select it, then choose a street slot.', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + // Prefer using the rendered marketContainer bounds when available so + // the highlight precisely matches the visible market region including + // the left-side title. Fallback to layout-derived bounds otherwise. + try { + const mc = (scene as any).marketContainer; + if (mc && typeof mc.getBounds === 'function') { + const b = mc.getBounds(); + const pad = 8; + const x = Math.max(12, b.x - pad); + const y = Math.max(12, b.y - pad); + const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : l.gameW - 40; + const w = Math.max(80, Math.min(b.width + pad * 2, Math.max(80, rightLimit - x))); + const h = Math.max(40, Math.min(b.height + pad * 2, l.gameH - 40)); + return { x, y, w, h }; + } + } catch (_e) { + // ignore and fallback + } + + const startX = l.marketLabelW + 50; + const slots = MARKET_BUSINESS_SLOTS; + const totalCardsW = slots * l.marketCardW + (slots - 1) * l.marketCardGap; + const padding = 8; // small padding around the highlight + // Start at the content label X so the highlight includes the title area + const labelX = 40; + const x = Math.max(12, labelX - 8); + const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); + const desiredW = Math.max(80, (startX - labelX) + totalCardsW + padding * 2); + const w = Math.max(80, Math.min(desiredW, Math.max(80, rightLimit - x))); + const y = l.marketTop - 6; + const h = l.marketRowH * 2 + l.marketRowGap + 16; + return { x, y, w, h }; + }, + + }, + { + title: 'Upcoming Incidents', + body: + 'Blue cards show incidents that will hit at the\n' + + 'end of each turn — plan around them!\n' + + 'Negative incidents (Tax Audit, Vandalism) cost\n' + + 'coins or reputation. Positive ones help you.\n\n' + + 'Queue scrolls left: the leftmost card fires next.', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + // Prefer using rendered incident queue container bounds when available + try { + const qc = (scene as any).incidentQueueContainer; + if (qc && typeof qc.getBounds === 'function') { + const bq = qc.getBounds(); + const padq = 8; + const x = Math.max(12, bq.x - padq); + const y = Math.max(12, bq.y - padq); + const rightLimitQ = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : l.gameW - 40; + const w = Math.max(80, Math.min(bq.width + padq * 2, Math.max(80, rightLimitQ - x))); + const h = Math.max(40, Math.min(bq.height + padq * 2, l.gameH - 40)); + return { x, y, w, h }; + } + } catch (_e) { /* ignore */ } + + const labelX = 40; + const x = Math.max(12, labelX - 8); + const desiredW = Math.max(80, l.queueLabelW + INCIDENT_QUEUE_SIZE * (l.queueCardW + l.queueCardGap) + 32); + const rightLimitQ = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); + const w = Math.max(80, Math.min(desiredW, Math.max(80, rightLimitQ - x))); + const y = l.queueTop - 6; + const h = l.queueCardH + 16; + return { x, y, w, h }; + }, + }, + { + title: 'Your Street', + body: + 'The 2×5 grid is your street.\n' + + 'Place businesses here to earn income each turn.\n' + + 'Adjacent businesses that share a synergy type\n' + + '(Food, Culture, Commerce, Service, Entertainment)\n' + + 'earn bonus income — cluster them for big returns!', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + const streetH = 2 * l.slotH + l.streetRowGap + 12; + return { x: 0, y: l.streetTop - 6, w: l.gameW, h: streetH }; + }, + }, + { + title: 'Your Hand', + body: + 'You can hold one Investment event at a time.\n' + + 'When you buy an event it appears here.\n' + + 'Click the card in your hand to play it\n' + + 'for its one-time effect.', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + return { x: l.handX - 16, y: l.handY - 8, w: l.handCardW + 32, h: l.handCardH + 16 }; + }, + }, + { + title: 'Action Controls', + body: + 'Use the buttons along the bottom to:\n' + + '• End Turn — collect income and advance the day\n' + + '• Undo / Redo — step back a market action\n' + + '• Hint — get a suggested move\n' + + '• Refresh — swap the investment row (costs coins)\n\n' + + 'You can also press the keyboard shortcut for\n' + + 'End Turn (configurable in Settings ⚙).', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + return { x: 0, y: l.actionY - 8, w: l.gameW, h: l.actionButtonH + 20 }; + }, + }, + { + title: 'Challenges & Scoring', + body: + 'Each run gives you challenges to complete for\n' + + 'bonus points (visible in the Challenge Tracker).\n\n' + + 'Final Score = Coins + Reputation × multiplier\n' + + ' + Challenges × bonus\n\n' + + 'Reach the target score to win — good luck!', + anchor: (scene: any) => { + const l = scene.layout; + if (!l) return null; + if (!l.challengeX || l.challengeX < 0) return null; + // Compute challenge panel height from constants and current active challenges if available + try { + // Prefer using the rendered challenge container bounds if available + if (scene.challengeContainer && typeof (scene.challengeContainer as any).getBounds === 'function') { + const b = (scene.challengeContainer as any).getBounds(); + const pad = 8; + const x = Math.max(12, b.x - pad); + const y = Math.max(12, b.y - pad); + const w = Math.max(120, b.width + pad * 2); + const h = Math.max(80, Math.min(b.height + pad * 2, 240)); + return { x, y, w, h }; + } + + const activeCount = (scene.state && Array.isArray(scene.state.activeChallenges)) ? scene.state.activeChallenges.length : 0; + const CH = (require('../MainStreetConstants') as any).CHALLENGE_TITLE_H || 20; + const CL = (require('../MainStreetConstants') as any).CHALLENGE_LINE_H || 20; + const CP = (require('../MainStreetConstants') as any).CHALLENGE_PAD || 6; + const contentH = CH + Math.max(0, activeCount) * CL + CP * 2; + const h = Math.max(80, Math.min(contentH, 240)); + const x = Math.max(12, l.challengeX - 8); + const y = Math.max(12, l.challengeY - 8); + const w = Math.max(120, l.challengeW + 16); + return { x, y, w, h }; + } catch { + return { x: l.challengeX - 8, y: l.challengeY - 8, w: l.challengeW + 16, h: 140 }; + } + }, + }, +]; + +// ── Visual constants ───────────────────────────────────────── + +const TOOLTIP_W = 360; +const TOOLTIP_H_BASE = 170; +const TOOLTIP_DEPTH = 200; +const HIGHLIGHT_COLOR = 0x44ff44; +const HIGHLIGHT_ALPHA = 0.18; +const HIGHLIGHT_BORDER_ALPHA = 0.8; + +// ── Manager ────────────────────────────────────────────────── + +/** Manages the lifecycle of all tutorial overlay objects. */ +export class MainStreetTutorialOverlayManager { + private objects: Phaser.GameObjects.GameObject[] = []; + private currentStep = 0; + private visible = false; + private readonly onComplete: (() => void) | null; + + constructor(private readonly scene: any, onComplete?: () => void) { + this.onComplete = onComplete ?? null; + } + + /** True if the tutorial overlay is currently visible. */ + get isVisible(): boolean { + return this.visible; + } + + /** Show the tutorial from the beginning. */ + public start(): void { + this.currentStep = 0; + this.visible = true; + this.showStep(this.currentStep); + } + + /** Toggle the tutorial overlay on/off. */ + public toggle(): void { + if (this.visible) { + this.dismiss(); + } else { + this.start(); + } + } + + /** Dismiss (hide) all tutorial objects. */ + public dismiss(): void { + const wasVisible = this.visible; + this.clearObjects(); + this.visible = false; + if (wasVisible && this.onComplete) { + try { this.onComplete(); } catch (_) { /* ignore errors in callback */ } + } + } + + /** Advance to the next tutorial step (or dismiss if at end). */ + public nextStep(): void { + this.currentStep++; + if (this.currentStep >= TUTORIAL_STEPS.length) { + this.dismiss(); + } else { + this.showStep(this.currentStep); + } + } + + /** Go back to the previous step. */ + public prevStep(): void { + if (this.currentStep > 0) { + this.currentStep--; + this.showStep(this.currentStep); + } + } + + /** Show a specific tutorial step by index. */ + public showStep(index: number): void { + if (index < 0 || index >= TUTORIAL_STEPS.length) return; + this.clearObjects(); + this.currentStep = index; + this.visible = true; + + const step = TUTORIAL_STEPS[index]; + const s = this.scene; + // If the scene is not fully ready (no add/sys), retry shortly. + if (!s || !s.add) { + setTimeout(() => { + try { this.showStep(index); } catch (_) { /* ignore */ } + }, 60); + return; + } + const layout = s.layout ?? {}; + const gameW: number = layout.gameW ?? 1280; + const gameH: number = layout.gameH ?? 720; + + // ── Optional highlight rectangle (canvas) ────────────── + const anchor = step.anchor(s); + if (anchor) { + const highlight = s.add.graphics(); + highlight.setDepth(TOOLTIP_DEPTH - 1); + // Semi-transparent fill + highlight.fillStyle(HIGHLIGHT_COLOR, HIGHLIGHT_ALPHA); + highlight.fillRect(anchor.x, anchor.y, anchor.w, anchor.h); + // Bright border + highlight.lineStyle(2, HIGHLIGHT_COLOR, HIGHLIGHT_BORDER_ALPHA); + highlight.strokeRect(anchor.x, anchor.y, anchor.w, anchor.h); + this.objects.push(highlight); + } + + // Build a DOM tooltip so it can render above DOM-based card elements. + const tooltipX = gameW / 2 - TOOLTIP_W / 2; + + // Buttons and padding + const padTop = 12; + const padSides = 16; + const padBetweenTitleAndBody = 8; + const padBottom = 12; + + try { + const container = document.createElement('div'); + container.className = 'ms-tutorial-tooltip'; + container.style.width = TOOLTIP_W + 'px'; + container.style.boxSizing = 'border-box'; + container.style.padding = `${padTop}px ${padSides}px ${padBottom}px ${padSides}px`; + container.style.background = '#1a2a1a'; + container.style.border = '2px solid #44aa44'; + container.style.borderRadius = '8px'; + container.style.color = '#ddccbb'; + container.style.fontFamily = FONT_FAMILY; + container.style.fontSize = '13px'; + container.style.lineHeight = '1.25'; + container.style.overflow = 'auto'; + container.style.pointerEvents = 'auto'; + + const titleEl = document.createElement('div'); + titleEl.style.fontWeight = '700'; + titleEl.style.color = '#aaffaa'; + titleEl.style.marginBottom = `${padBetweenTitleAndBody}px`; + titleEl.textContent = step.title; + container.appendChild(titleEl); + + const bodyEl = document.createElement('div'); + bodyEl.style.whiteSpace = 'pre-wrap'; + bodyEl.style.color = '#ddccbb'; + bodyEl.textContent = step.body; + container.appendChild(bodyEl); + + const btnRow = document.createElement('div'); + btnRow.style.display = 'flex'; + btnRow.style.justifyContent = 'space-between'; + btnRow.style.alignItems = 'center'; + btnRow.style.marginTop = '12px'; + + const leftGroup = document.createElement('div'); + const dismissBtn = document.createElement('button'); + dismissBtn.textContent = 'Dismiss'; + dismissBtn.style.background = '#2a2a1a'; + dismissBtn.style.color = '#aa8866'; + dismissBtn.style.border = 'none'; + dismissBtn.style.padding = '6px 8px'; + dismissBtn.style.borderRadius = '6px'; + dismissBtn.style.cursor = 'pointer'; + dismissBtn.onclick = () => this.dismiss(); + leftGroup.appendChild(dismissBtn); + btnRow.appendChild(leftGroup); + + const middleGroup = document.createElement('div'); + if (index > 0) { + const prevBtn = document.createElement('button'); + prevBtn.textContent = '< Prev'; + prevBtn.style.background = 'transparent'; + prevBtn.style.color = '#88bbff'; + prevBtn.style.border = 'none'; + prevBtn.style.padding = '6px 8px'; + prevBtn.style.cursor = 'pointer'; + prevBtn.onclick = () => this.prevStep(); + middleGroup.appendChild(prevBtn); + } + btnRow.appendChild(middleGroup); + + const rightGroup = document.createElement('div'); + const nextBtn = document.createElement('button'); + const isLast = index === TUTORIAL_STEPS.length - 1; + nextBtn.textContent = isLast ? 'Finish' : 'Next >'; + nextBtn.style.background = isLast ? '#44ff44' : '#88ff88'; + nextBtn.style.color = '#002200'; + nextBtn.style.border = 'none'; + nextBtn.style.padding = '6px 8px'; + nextBtn.style.borderRadius = '6px'; + nextBtn.style.cursor = 'pointer'; + nextBtn.onclick = () => this.nextStep(); + rightGroup.appendChild(nextBtn); + btnRow.appendChild(rightGroup); + + container.appendChild(btnRow); + + // Measure tooltip height by temporarily attaching to the document so we can + // position it accurately relative to the anchor. Clamp to viewport height. + document.body.appendChild(container); + const measuredH = Math.min(container.offsetHeight || TOOLTIP_H_BASE, Math.max(80, gameH - 40)); + document.body.removeChild(container); + + const tooltipH = measuredH; + + // Decide tooltip position relative to anchor. Prefer placing to the right + // of the anchor to avoid obscuring the highlighted region (useful for + // the Challenges panel which sits near the left of the sidebar). + let tooltipY: number; + let domX = tooltipX; + if (anchor) { + const rightX = anchor.x + anchor.w + 12; + const leftX = anchor.x - TOOLTIP_W - 12; + const centerYBased = anchor.y + Math.floor((anchor.h - tooltipH) / 2); + + if (rightX + TOOLTIP_W < gameW - 12) { + domX = Math.max(12, rightX); + tooltipY = Math.max(12, Math.min(centerYBased, gameH - tooltipH - 12)); + } else if (leftX > 12) { + domX = Math.max(12, leftX); + tooltipY = Math.max(12, Math.min(centerYBased, gameH - tooltipH - 12)); + } else { + const belowY = anchor.y + anchor.h + 12; + const aboveY = anchor.y - tooltipH - 12; + tooltipY = belowY + tooltipH < gameH ? belowY : Math.max(12, aboveY); + } + } else { + domX = Math.max(12, Math.floor(gameW / 2 - TOOLTIP_W / 2)); + tooltipY = Math.max(12, Math.floor(gameH / 2 - tooltipH / 2)); + } + + // Add as a Phaser DOMElement at computed position (top-left) + const dom = s.add.dom(domX, tooltipY, container) as Phaser.GameObjects.DOMElement; + dom.setOrigin(0, 0); + try { dom.setDepth(TOOLTIP_DEPTH + 1000); } catch { /* ignore */ } + this.objects.push(dom); + + // Step counter badge as a small canvas text anchored to the tooltip + const stepLabel = s.add.text(domX + TOOLTIP_W - 12, tooltipY + 10, `${index + 1} / ${TUTORIAL_STEPS.length}`, { fontSize: '11px', color: '#669966', fontFamily: FONT_FAMILY }).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1); + this.objects.push(stepLabel); + } catch (e) { + // Fallback to in-canvas tooltip if DOM is not available or fails + // eslint-disable-next-line no-console + // DOM tooltip creation failed; fall back to in-canvas rendering + + const tooltipH = TOOLTIP_H_BASE; + const domX = Math.max(12, Math.floor(gameW / 2 - TOOLTIP_W / 2)); + const tooltipY = Math.max(12, Math.floor(gameH / 2 - tooltipH / 2)); + + const bg = s.add.rectangle(domX + TOOLTIP_W / 2, tooltipY + tooltipH / 2, TOOLTIP_W, tooltipH, 0x1a2a1a).setDepth(TOOLTIP_DEPTH + 1000); + const border = s.add.rectangle(domX + TOOLTIP_W / 2, tooltipY + tooltipH / 2, TOOLTIP_W, tooltipH).setStrokeStyle(2, 0x44aa44).setDepth(TOOLTIP_DEPTH + 1001); + const titleTxt = s.add.text(domX + 12, tooltipY + 12, step.title, { fontSize: '16px', color: '#aaffaa', fontFamily: FONT_FAMILY }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); + const bodyTxt = s.add.text(domX + 12, tooltipY + 40, step.body, { fontSize: '13px', color: '#ddccbb', fontFamily: FONT_FAMILY, wordWrap: { width: TOOLTIP_W - 24 } as any }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); + + const dismissBtn = s.add.text(domX + 12, tooltipY + tooltipH - 30, 'Dismiss', { fontSize: '13px', color: '#aa8866', fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); + dismissBtn.on('pointerdown', () => this.dismiss()); + + const isLast = index === TUTORIAL_STEPS.length - 1; + const nextLabel = isLast ? 'Finish' : 'Next >'; + const nextBtn = s.add.text(domX + TOOLTIP_W - 12, tooltipY + tooltipH - 30, nextLabel, { fontSize: '13px', color: '#002200', backgroundColor: isLast ? '#44ff44' : '#88ff88', padding: { left: 6, right: 6 } as any, fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1003); + nextBtn.on('pointerdown', () => this.nextStep()); + + if (index > 0) { + const prevBtn = s.add.text(domX + TOOLTIP_W / 2, tooltipY + tooltipH - 30, '< Prev', { fontSize: '13px', color: '#88bbff', fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003).setOrigin(0.5, 0); + prevBtn.on('pointerdown', () => this.prevStep()); + this.objects.push(prevBtn); + } + + this.objects.push(bg, border, titleTxt, bodyTxt, dismissBtn, nextBtn); + } + } + + // ── Private helpers ─────────────────────────────────────── + + private clearObjects(): void { + for (const obj of this.objects) { + try { obj.destroy(); } catch (e) { + // Non-fatal: Phaser may throw when destroying already-destroyed objects in tests. + // eslint-disable-next-line no-console + console.debug('[Tutorial] clearObjects: destroy failed', e); + } + } + this.objects = []; + } +} diff --git a/index.html b/index.html index 68729f6e..5fb8996e 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,39 @@
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..c5d381e1 --- /dev/null +++ b/public/404.html @@ -0,0 +1,26 @@ + + + + + + 404 — Not Found + + + +
+

404 — Page not found

+

The URL you requested appears to point to a nested file or example page that doesn't exist on the server.

+

If you were trying to open an example game, please start the site at the root and use the Game Selector:

+

/Tableau Card Engine landing page

+ Open Game Selector +

Tip: when running the dev server use http://localhost:3000/ (or the port configured in vite.config.ts).

+
+ + diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index f1080048..3b5727d3 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -155,6 +155,9 @@ export class SettingsPanel { private _awaitingEndTurnKey = false; private _endTurnCaptureListener: ((event: KeyboardEvent) => void) | null = null; + // Replay Tutorial modal + private _modalContainer: Phaser.GameObjects.Container | null = null; + private _modalOpen = false; constructor(scene: Phaser.Scene, config: SettingsPanelConfig) { this.scene = scene; @@ -535,6 +538,16 @@ export class SettingsPanel { }); tip.setDepth(DEPTH_PANEL_CONTENT); this.container.add(tip); + + // Replay Tutorial button (shows confirmation modal, then dispatches event) + const replayTutorialY = difficultyY + 56; + const replayTutorial = scene.add.text(PADDING, replayTutorialY, 'Replay Tutorial', { + fontSize: '14px', color: (HEADING_STYLE.color as string) ?? '#f0c040', fontFamily: 'Arial, sans-serif', + }); + replayTutorial.setDepth(DEPTH_PANEL_CONTENT + 1); + replayTutorial.setInteractive({ useHandCursor: true }); + replayTutorial.on('pointerdown', () => this._showReplayTutorialModal()); + this.container.add(replayTutorial); } // Scene-level pointer events for slider dragging @@ -565,6 +578,71 @@ export class SettingsPanel { this.container.setVisible(false); } + // ── Replay Tutorial modal ───────────────────────────────── + + private _showReplayTutorialModal(): void { + if (this._modalOpen) return; + this._modalOpen = true; + + // Modal dimensions + const w = Math.min(480, Math.max(320, Math.floor(this.canvasWidth * 0.5))); + const h = 160; + + // Create a centered modal container (global canvas coordinates) + const container = this.scene.add.container(this.canvasWidth / 2, this.canvasHeight / 2); + container.setDepth(DEPTH_PANEL_CONTENT + 100); + + // Full-screen dark overlay (blocks input and visually centers modal) + const bg = this.scene.add.rectangle(0, 0, this.canvasWidth, this.canvasHeight, 0x000000, 0.6); + bg.setOrigin(0.5, 0.5); + bg.setInteractive(); + // Clicking the background should close the modal (like Cancel) + bg.on('pointerdown', () => this._closeReplayTutorialModal()); + + // Modal box + const box = this.scene.add.rectangle(0, 0, w, h, 0x1a2a1a, 1); + box.setOrigin(0.5, 0.5); + + const title = this.scene.add.text(-w / 2 + 12, -h / 2 + 12, 'Replay Tutorial?', { fontSize: '16px', color: '#f0c040', fontFamily: 'Arial, sans-serif' }); + title.setOrigin(0, 0); + + const body = this.scene.add.text(-w / 2 + 12, -h / 2 + 36, 'Replaying the tutorial will end the current game and restart a tutorial run. Continue?', { fontSize: '13px', color: '#dddddd', fontFamily: 'Arial, sans-serif', wordWrap: { width: w - 24 } as any }); + body.setOrigin(0, 0); + + const cancel = this.scene.add.text(-w / 2 + 12, h / 2 - 36, 'Cancel', { fontSize: '13px', color: '#aa8866', fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }); + cancel.setOrigin(0, 0); + + const confirm = this.scene.add.text(w / 2 - 12, h / 2 - 36, 'Continue', { fontSize: '13px', color: '#002200', backgroundColor: '#88ff88', padding: { left: 6, right: 6 } as any, fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }); + confirm.setOrigin(1, 0); + + container.add([bg, box, title, body, cancel, confirm]); + + cancel.on('pointerdown', () => this._closeReplayTutorialModal()); + confirm.on('pointerdown', () => { + try { + if (typeof window !== 'undefined' && (window as any).dispatchEvent) { + const ev = new CustomEvent('tce:replay-tutorial'); + (window as any).dispatchEvent(ev); + } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:replay-tutorial', e); } + + // Close modal first then the settings panel + this._closeReplayTutorialModal(); + try { this.close(); } catch (_) { /* ignore */ } + }); + + this._modalContainer = container; + } + + private _closeReplayTutorialModal(): void { + if (!this._modalOpen) return; + this._modalOpen = false; + try { + this._modalContainer?.destroy(); + } catch (_) { /* ignore */ } + this._modalContainer = null; + } + // ── End Turn keybind capture ───────────────────────── private beginEndTurnKeyCapture(): void { diff --git a/tests/main-street/meta-progression.test.ts b/tests/main-street/meta-progression.test.ts index 22f8f278..5c049c76 100644 --- a/tests/main-street/meta-progression.test.ts +++ b/tests/main-street/meta-progression.test.ts @@ -24,7 +24,6 @@ import { TIER_DEFINITIONS, ORDERED_TIER_DEFINITIONS, deriveUnlockedCardIds, - highestUnlockedTier, } from '../../example-games/main-street/MainStreetTiers'; import { createDefaultCampaignProgress, @@ -38,7 +37,6 @@ import { createBusinessDeck, createEventDeck, createUpgradeDeck, - CARD_TEMPLATE_NAMES, } from '../../example-games/main-street/MainStreetCards'; import { createSeededRng } from '../../src/core-engine'; import { CHALLENGE_TEMPLATES } from '../../example-games/main-street/MainStreetChallenges'; diff --git a/tests/main-street/smoke-scenario.test.ts b/tests/main-street/smoke-scenario.test.ts new file mode 100644 index 00000000..efdcbd73 --- /dev/null +++ b/tests/main-street/smoke-scenario.test.ts @@ -0,0 +1,259 @@ +/** + * Main Street: Smoke-Scenario Integration Test + * + * Runs a deterministic headless playthrough with the canonical smoke seed + * ("smoke-1") and validates that the run completes without error and that + * the final transcript contains all expected summary fields. + * + * This mirrors the CLI demo script (scripts/demo-main-street.ts) but runs + * inside Vitest so it is part of the standard `npm test` quality gate. + * + * Usage: + * npx vitest run --project unit tests/main-street/smoke-scenario.test.ts + * + * @module + */ + +import { describe, it, expect } from 'vitest'; +import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState'; +import { + executeDayStart, + executeAction, + processEndOfTurn, + computeScore, + type PlayerAction, + type TurnResult, +} from '../../example-games/main-street/MainStreetEngine'; +import { + getAffordableBusinessCards, + getAffordableUpgradeCards, + getEmptySlots, + canPurchaseEvent, +} from '../../example-games/main-street/MainStreetMarket'; + +// ── Canonical smoke seeds ───────────────────────────────────── + +/** Canonical seed used by the Tutorial scenario smoke test. */ +const SMOKE_SEED = 'smoke-1'; + +/** + * Additional seeds exercised as regression targets. + * These are the same seeds used in demo-main-street.ts and playtest-scenarios.ts. + */ +const REGRESSION_SEEDS = ['DemoSeed42', 'sweep-63', 'sweep-14', 'Bridge-Master-7']; + +// ── Greedy strategy ──────────────────────────────────────────── + +interface TurnRecord { + turn: number; + actions: { type: string; detail: string }[]; + income: number | null; + incident: string | null; + coinsAfter: number; + reputationAfter: number; + score: number; + gridOccupied: number; +} + +interface RunSummary { + game: 'main-street'; + version: '1.0.0'; + seed: string; + totalTurns: number; + result: 'win' | 'loss'; + endReason: string | null; + finalScore: number; + turns: TurnRecord[]; +} + +/** + * Runs a full greedy headless game from a given seed. + * Returns a RunSummary equivalent to the demo script output. + */ +function runGreedyGame(seed: string, maxTurns = 30): RunSummary { + const state = setupMainStreetGame({ seed }); + const turns: TurnRecord[] = []; + + while (state.gameResult === 'playing' && state.turn <= maxTurns) { + executeDayStart(state); + + const actions: PlayerAction[] = []; + const executed: { type: string; detail: string }[] = []; + + // Buy cheapest affordable business and place in first empty slot + const emptySlots = getEmptySlots(state); + const affordable = getAffordableBusinessCards(state); + affordable.sort((a, b) => a.cost - b.cost); + if (affordable.length > 0 && emptySlots.length > 0) { + const card = affordable[0]; + const slot = emptySlots[0]; + actions.push({ type: 'buy-business', cardId: card.id, slotIndex: slot }); + } + + // Play held event if any + if (state.heldEvent !== null) { + actions.push({ type: 'play-event' }); + } + + // Buy affordable investment event if none held + for (const card of state.market.investments) { + if (card.family !== 'event') continue; + const result = canPurchaseEvent(state, card.id); + if (result.legal) { + actions.push({ type: 'buy-event', cardId: card.id }); + break; + } + } + + // Buy upgrade if available + const upgrades = getAffordableUpgradeCards(state); + if (upgrades.length > 0) { + const upg = upgrades[0]; + const matchSlot = state.streetGrid.findIndex( + b => b !== null && b.upgradePath === upg.targetBusiness && b.level < b.maxLevel, + ); + if (matchSlot >= 0) { + actions.push({ type: 'buy-upgrade', cardId: upg.id, targetSlot: matchSlot }); + } + } + + actions.push({ type: 'end-turn' }); + + for (const action of actions) { + if (action.type === 'end-turn') break; + try { + executeAction(state, action); + switch (action.type) { + case 'buy-business': { + const a = action as { type: string; cardId: string; slotIndex: number }; + executed.push({ type: 'buy-business', detail: `${a.cardId} -> slot ${a.slotIndex}` }); + break; + } + case 'buy-upgrade': { + const a = action as { type: string; cardId: string; targetSlot?: number }; + executed.push({ type: 'buy-upgrade', detail: `${a.cardId} -> slot ${a.targetSlot}` }); + break; + } + case 'buy-event': { + const a = action as { type: string; cardId: string }; + executed.push({ type: 'buy-event', detail: a.cardId }); + break; + } + } + } catch { + // Illegal action — skip + } + } + + if (executed.length === 0) { + executed.push({ type: 'skip', detail: 'No affordable actions' }); + } + + const turnResult: TurnResult = processEndOfTurn(state); + + turns.push({ + turn: turns.length + 1, + actions: executed, + income: turnResult.income ? turnResult.income.total : null, + incident: turnResult.incident ? turnResult.incident.name : null, + coinsAfter: state.resourceBank.coins, + reputationAfter: state.resourceBank.reputation, + score: computeScore(state), + gridOccupied: state.streetGrid.filter(s => s !== null).length, + }); + + if (state.gameResult !== 'playing') break; + } + + return { + game: 'main-street', + version: '1.0.0', + seed, + totalTurns: turns.length, + result: state.gameResult === 'win' ? 'win' : 'loss', + endReason: state.endReason, + finalScore: state.finalScore, + turns, + }; +} + +// ── Tests ────────────────────────────────────────────────────── + +describe('Smoke: Main Street Scenario (seed "smoke-1")', () => { + it('completes without errors and emits all required summary fields', () => { + const summary = runGreedyGame(SMOKE_SEED); + + // ── Required summary fields must be present ─────────────── + expect(summary.game).toBe('main-street'); + expect(summary.version).toBe('1.0.0'); + expect(summary.seed).toBe(SMOKE_SEED); + expect(typeof summary.totalTurns).toBe('number'); + expect(summary.totalTurns).toBeGreaterThanOrEqual(1); + expect(['win', 'loss']).toContain(summary.result); + expect(summary.endReason).not.toBeUndefined(); + expect(typeof summary.finalScore).toBe('number'); + expect(Array.isArray(summary.turns)).toBe(true); + }); + + it('is deterministic: two runs with the same seed produce identical results', () => { + const run1 = runGreedyGame(SMOKE_SEED); + const run2 = runGreedyGame(SMOKE_SEED); + + expect(run1.result).toBe(run2.result); + expect(run1.finalScore).toBe(run2.finalScore); + expect(run1.totalTurns).toBe(run2.totalTurns); + expect(run1.endReason).toBe(run2.endReason); + }); + + it('each turn record has the expected fields', () => { + const summary = runGreedyGame(SMOKE_SEED); + for (const turn of summary.turns) { + expect(typeof turn.turn).toBe('number'); + expect(Array.isArray(turn.actions)).toBe(true); + expect(typeof turn.coinsAfter).toBe('number'); + expect(typeof turn.reputationAfter).toBe('number'); + expect(typeof turn.score).toBe('number'); + expect(typeof turn.gridOccupied).toBe('number'); + } + }); +}); + +describe('Smoke: Main Street Easy difficulty (Tutorial scenario baseline)', () => { + it('completes an Easy game with seed "smoke-1"', () => { + const state = setupMainStreetGame({ seed: SMOKE_SEED, difficulty: 'Easy' }); + let turns = 0; + const safetyLimit = state.config.maxTurns + 5; + + while (state.gameResult === 'playing' && turns < safetyLimit) { + executeDayStart(state); + const affordable = getAffordableBusinessCards(state); + const emptySlots = getEmptySlots(state); + if (affordable.length > 0 && emptySlots.length > 0) { + try { + executeAction(state, { type: 'buy-business', cardId: affordable[0].id, slotIndex: emptySlots[0] }); + } catch { /* ignore illegal moves */ } + } + processEndOfTurn(state); + turns++; + } + + expect(state.gameResult).not.toBe('playing'); + expect(['win', 'loss']).toContain(state.gameResult); + expect(state.endReason).not.toBeNull(); + expect(typeof state.finalScore).toBe('number'); + expect(turns).toBeGreaterThanOrEqual(1); + expect(turns).toBeLessThanOrEqual(safetyLimit); + }); +}); + +describe('Smoke: Regression seeds complete without errors', () => { + for (const seed of REGRESSION_SEEDS) { + it(`seed "${seed}" runs to completion`, () => { + const summary = runGreedyGame(seed); + expect(summary.totalTurns).toBeGreaterThanOrEqual(1); + expect(['win', 'loss']).toContain(summary.result); + expect(summary.endReason).not.toBeUndefined(); + expect(typeof summary.finalScore).toBe('number'); + }); + } +});