From 094fa50049e904bfc8a22f018c84fc78e309bf69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 09:37:51 +0000 Subject: [PATCH 01/30] Initial plan From 2ef0d81e98e9da9929a9be9a797f23800c3fd791 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 09:50:10 +0000 Subject: [PATCH 02/30] Add Main Street Tutorial scenario, tutorial overlays, smoke test, and fix build" Agent-Logs-Url: https://github.com/TheWizardsCode/Tableau-Card-Engine/sessions/ebb9c04b-ffc7-4382-9616-306bcbc35fc5 Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- README.md | 7 + docs/main-street/playtest-scenarios.md | 47 ++- .../main-street/scenes/MainStreetScene.ts | 4 +- .../MainStreetTutorialOverlayManager.ts | 330 ++++++++++++++++++ .../scenes/MainStreetTutorialScene.ts | 100 ++++++ main.ts | 10 +- tests/main-street/meta-progression.test.ts | 2 - tests/main-street/smoke-scenario.test.ts | 259 ++++++++++++++ 8 files changed, 753 insertions(+), 6 deletions(-) create mode 100644 example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts create mode 100644 example-games/main-street/scenes/MainStreetTutorialScene.ts create mode 100644 tests/main-street/smoke-scenario.test.ts 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/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 73eed3bd..6a357dea 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -114,8 +114,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..693c44d8 --- /dev/null +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -0,0 +1,330 @@ +/** + * 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'; + +// ── 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; + return { x: 0, y: l.marketTop - 6, w: l.gameW, h: l.marketRowH * 2 + l.marketRowGap + 16 }; + }, + }, + { + 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; + return { x: 0, y: l.queueTop - 6, w: l.gameW, h: l.queueCardH + 16 }; + }, + }, + { + 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; + 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_BG_COLOR = 0x1a2a1a; +const TOOLTIP_BORDER_COLOR = 0x44aa44; +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; + + constructor(private readonly scene: any) {} + + /** 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 { + this.clearObjects(); + this.visible = false; + } + + /** 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; + const layout = s.layout ?? {}; + const gameW: number = layout.gameW ?? 1280; + const gameH: number = layout.gameH ?? 720; + + // ── Optional highlight rectangle ────────────────────── + 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); + } + + // ── Tooltip box ─────────────────────────────────────── + // Position tooltip below the anchor (or centred if no anchor) + const tooltipX = gameW / 2 - TOOLTIP_W / 2; + let tooltipY: number; + if (anchor) { + // Try to place below the highlight; clamp to viewport + const belowY = anchor.y + anchor.h + 12; + const aboveY = anchor.y - TOOLTIP_H_BASE - 12; + tooltipY = belowY + TOOLTIP_H_BASE < gameH ? belowY : aboveY; + } else { + tooltipY = gameH / 2 - TOOLTIP_H_BASE / 2; + } + + const bg = s.add.graphics(); + bg.setDepth(TOOLTIP_DEPTH); + bg.fillStyle(TOOLTIP_BG_COLOR, 0.95); + bg.fillRoundedRect(tooltipX, tooltipY, TOOLTIP_W, TOOLTIP_H_BASE, 8); + bg.lineStyle(2, TOOLTIP_BORDER_COLOR, 0.9); + bg.strokeRoundedRect(tooltipX, tooltipY, TOOLTIP_W, TOOLTIP_H_BASE, 8); + this.objects.push(bg); + + // Step counter badge (e.g. "1 / 7") + const stepLabel = s.add.text( + tooltipX + 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); + + // Title + const titleText = s.add.text( + tooltipX + 16, + tooltipY + 12, + step.title, + { fontSize: '15px', fontStyle: 'bold', color: '#aaffaa', fontFamily: FONT_FAMILY }, + ).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + this.objects.push(titleText); + + // Body + const bodyText = s.add.text( + tooltipX + 16, + tooltipY + 36, + step.body, + { + fontSize: '13px', + color: '#ddccbb', + fontFamily: FONT_FAMILY, + wordWrap: { width: TOOLTIP_W - 32 }, + lineSpacing: 3, + }, + ).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + this.objects.push(bodyText); + + // ── Navigation buttons ──────────────────────────────── + const btnY = tooltipY + TOOLTIP_H_BASE - 24; + + // Dismiss button (always shown on the left) + const dismissBtn = s.add.text(tooltipX + 12, btnY, '[ Dismiss ]', { + fontSize: '12px', color: '#aa8866', fontFamily: FONT_FAMILY, + }).setOrigin(0, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + dismissBtn.on('pointerdown', () => this.dismiss()); + dismissBtn.on('pointerover', () => dismissBtn.setColor('#ddaa88')); + dismissBtn.on('pointerout', () => dismissBtn.setColor('#aa8866')); + this.objects.push(dismissBtn); + + // Prev button (disabled on step 0) + if (index > 0) { + const prevBtn = s.add.text(tooltipX + TOOLTIP_W / 2 - 50, btnY, '[ < Prev ]', { + fontSize: '12px', color: '#88bbff', fontFamily: FONT_FAMILY, + }).setOrigin(0.5, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + prevBtn.on('pointerdown', () => this.prevStep()); + prevBtn.on('pointerover', () => prevBtn.setColor('#aaddff')); + prevBtn.on('pointerout', () => prevBtn.setColor('#88bbff')); + this.objects.push(prevBtn); + } + + // Next / Finish button + const isLast = index === TUTORIAL_STEPS.length - 1; + const nextLabel = isLast ? '[ Finish ]' : '[ Next > ]'; + const nextColor = isLast ? '#44ff44' : '#88ff88'; + const nextBtn = s.add.text(tooltipX + TOOLTIP_W - 12, btnY, nextLabel, { + fontSize: '12px', color: nextColor, fontFamily: FONT_FAMILY, + }).setOrigin(1, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + nextBtn.on('pointerdown', () => this.nextStep()); + nextBtn.on('pointerover', () => nextBtn.setColor('#ffffff')); + nextBtn.on('pointerout', () => nextBtn.setColor(nextColor)); + this.objects.push(nextBtn); + } + + // ── Private helpers ─────────────────────────────────────── + + private clearObjects(): void { + for (const obj of this.objects) { + try { obj.destroy(); } catch (_) { /* ignore */ } + } + this.objects = []; + } +} diff --git a/example-games/main-street/scenes/MainStreetTutorialScene.ts b/example-games/main-street/scenes/MainStreetTutorialScene.ts new file mode 100644 index 00000000..d8449347 --- /dev/null +++ b/example-games/main-street/scenes/MainStreetTutorialScene.ts @@ -0,0 +1,100 @@ +/** + * MainStreetTutorialScene -- "Scenario: Tutorial" entry for Main Street. + * + * A thin subclass of MainStreetScene that: + * - Registers under a distinct Phaser scene key so it can co-exist with + * the standard Main Street scene in the scene registry. + * - Forces Easy difficulty (more turns, lower score target, generous coins) + * so new players experience a forgiving introduction. + * - Attaches a MainStreetTutorialOverlayManager and adds a toggleable + * "Tutorial" button to the action bar so players can revisit hints. + * + * Tutorial overlays are non-interactive: they highlight UI regions and + * display contextual text but never block gameplay. Players can dismiss + * individual hints or the whole overlay at any time. + * + * @module + */ + +import { MainStreetScene } from './MainStreetScene'; +import { MainStreetTutorialOverlayManager } from './MainStreetTutorialOverlayManager'; +import { FONT_FAMILY } from '../../../src/ui'; + +export const TUTORIAL_SCENE_KEY = 'MainStreetTutorialScene'; + +export class MainStreetTutorialScene extends MainStreetScene { + /** Tutorial overlay controller — initialised in create(). */ + public tutorialOverlay!: MainStreetTutorialOverlayManager; + + /** Whether to show tutorial hints automatically on first create. */ + private _autoShowTutorial = true; + + constructor() { + // Pass distinct scene key to parent so this scene co-exists with MainStreetScene. + super({ key: TUTORIAL_SCENE_KEY }); + // Force Easy difficulty for all tutorial runs. + this.selectedDifficulty = 'Easy'; + } + + // ── Phaser lifecycle ─────────────────────────────────────── + + override create(...args: any[]): any { + // selectedDifficulty is already 'Easy' from the constructor; loadCampaignAndSetup + // (called inside super.create) reads it when creating the game state. + const result = super.create(...args); + + // Attach tutorial overlay manager after parent has built the scene layout. + this.tutorialOverlay = new MainStreetTutorialOverlayManager(this); + + // Add the "[?] Tutorial" toggle button to the scene header area. + this._addTutorialButton(); + + // Auto-show tutorial on first load. + if (this._autoShowTutorial) { + this._autoShowTutorial = false; + // Small delay so the initial layout/render settles first. + this.time.delayedCall(300, () => { + if (this.tutorialOverlay) { + this.tutorialOverlay.start(); + } + }); + } + + return result; + } + + // ── Tutorial button ─────────────────────────────────────── + + /** + * Adds a small "[?] Tutorial" toggle button to the scene. + * The button is positioned at the top-right of the HUD area. + */ + private _addTutorialButton(): void { + const gameW = this.layout?.gameW ?? 1280; + const btnX = gameW - 120; + const btnY = 18; + + const btn = this.add.text(btnX, btnY, '[?] Tutorial', { + fontSize: '13px', + color: '#88ff88', + fontFamily: FONT_FAMILY, + backgroundColor: '#1a2a1a', + padding: { x: 6, y: 3 }, + }).setOrigin(0, 0.5).setDepth(150).setInteractive({ useHandCursor: true }); + + btn.on('pointerover', () => btn.setColor('#aaffaa')); + btn.on('pointerout', () => btn.setColor('#88ff88')); + btn.on('pointerdown', () => { + if (this.tutorialOverlay) { + this.tutorialOverlay.toggle(); + } + }); + + // Keep the button visible in the HUD container if one exists. + try { + if (this.hudContainer) { + this.hudContainer.add(btn); + } + } catch (_) { /* ignore */ } + } +} diff --git a/main.ts b/main.ts index 421f9d89..984f52b3 100644 --- a/main.ts +++ b/main.ts @@ -20,6 +20,7 @@ import { FeudalismScene } from './example-games/feudalism/scenes/FeudalismScene' import { LostCitiesScene } from './example-games/lost-cities/scenes/LostCitiesScene'; import { TheMindScene } from './example-games/the-mind/scenes/TheMindScene'; import { MainStreetScene } from './example-games/main-street/scenes/MainStreetScene'; +import { MainStreetTutorialScene } from './example-games/main-street/scenes/MainStreetTutorialScene'; // ── Game catalogue ───────────────────────────────────────── @@ -73,6 +74,13 @@ const GAMES: GameEntry[] = [ 'Single-player tableau builder. Purchase businesses, place them along a 10-slot street for synergy bonuses, manage coins and reputation, and build the highest-scoring Main Street in 20 turns.', thumbnail: 'games/main-street/thumbnail', }, + { + sceneKey: 'MainStreetTutorialScene', + title: 'Scenario: Tutorial', + description: + 'Guided introduction to Main Street (Easy, 25 turns). Non-interactive tutorial overlays walk you through the market, street placement, synergies, events, and scoring — perfect for new players.', + thumbnail: 'games/main-street/thumbnail', + }, ]; // ── Phaser boot ──────────────────────────────────────────── @@ -86,7 +94,7 @@ const isReplayMode = new URLSearchParams(window.location.search).get('mode') === createCardGame({ backgroundColor: '#1a2a1a', // Register all scenes; GameSelectorScene is first so it auto-starts. - scenes: [GameSelectorScene, GolfScene, BeleagueredCastleScene, SushiGoScene, FeudalismScene, LostCitiesScene, TheMindScene, MainStreetScene], + scenes: [GameSelectorScene, GolfScene, BeleagueredCastleScene, SushiGoScene, FeudalismScene, LostCitiesScene, TheMindScene, MainStreetScene, MainStreetTutorialScene], render: isReplayMode ? { preserveDrawingBuffer: true } : undefined, callbacks: { preBoot: (game: Phaser.Game) => { 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'); + }); + } +}); From c04af8106e87b9e28a109feafe7020ca87e6f615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 09:52:38 +0000 Subject: [PATCH 03/30] Address code review: add debug logging in catch blocks for tutorial overlay operations Agent-Logs-Url: https://github.com/TheWizardsCode/Tableau-Card-Engine/sessions/ebb9c04b-ffc7-4382-9616-306bcbc35fc5 Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- .../main-street/scenes/MainStreetTutorialOverlayManager.ts | 6 +++++- example-games/main-street/scenes/MainStreetTutorialScene.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 693c44d8..6a542936 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -323,7 +323,11 @@ export class MainStreetTutorialOverlayManager { private clearObjects(): void { for (const obj of this.objects) { - try { obj.destroy(); } catch (_) { /* ignore */ } + 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/example-games/main-street/scenes/MainStreetTutorialScene.ts b/example-games/main-street/scenes/MainStreetTutorialScene.ts index d8449347..4228038d 100644 --- a/example-games/main-street/scenes/MainStreetTutorialScene.ts +++ b/example-games/main-street/scenes/MainStreetTutorialScene.ts @@ -95,6 +95,10 @@ export class MainStreetTutorialScene extends MainStreetScene { if (this.hudContainer) { this.hudContainer.add(btn); } - } catch (_) { /* ignore */ } + } catch (e) { + // Non-fatal: hudContainer.add may fail in headless test environments. + // eslint-disable-next-line no-console + console.debug('[Tutorial] _addTutorialButton: hudContainer.add failed', e); + } } } From 3d1ba8123ba52fa755506f933cc1f218d54ab472 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 11:32:07 +0000 Subject: [PATCH 04/30] fix(renderer): preserve persistent HUD overlays through refreshHud() using transient-tagged removal Replace hudContainer.removeAll(true) in refreshHud() with a targeted loop that only destroys children tagged with _hudTransient. The four transient HUD elements (strip, coinText, repText, scoreText) are tagged via the new markHudTransient() helper. Persistent overlay objects (HelpPanel, SettingsPanel, buttons) are never tagged and therefore survive every refresh cycle. Root cause of the pre-existing SidebarOverlay.browser.test.ts failure: removeAll(true) was destroying helpPanel.container and settingsPanel.container on every refresh, setting parentContainer to undefined. The assertion expect(helpParent).toBe(scene.hudContainer) then failed, and Vitest crashed with RangeError: Invalid string length when trying to serialize the large Phaser Container object in the failure message. Agent-Logs-Url: https://github.com/TheWizardsCode/Tableau-Card-Engine/sessions/0c9ef8cd-e9a7-4a48-98de-f8772edae356 Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- .../main-street/scenes/MainStreetRenderer.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) 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({ From 58f7258dc3c2f18af4be9ccbe403714e3341134d Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:40:34 +0100 Subject: [PATCH 05/30] CG-0MP7HYWLS004HG3Y: Add user-facing 404 page and client-side deep-link guard to prevent deep-link asset resolution issues --- index.html | 33 +++++++++++++++++++++++++++++++++ public/404.html | 26 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 public/404.html 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).

+
+ + From 557e83bbf025baea9cdc7dd1f95075f7e4af673b Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:43:51 +0100 Subject: [PATCH 06/30] MainStreet tutorial: dynamic tooltip sizing, avoid DOM card occlusion by hiding svgDom while overlays visible; improve button layout --- .../MainStreetTutorialOverlayManager.ts | 107 ++++++++++-------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 6a542936..53893816 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -166,6 +166,9 @@ export class MainStreetTutorialOverlayManager { public start(): void { this.currentStep = 0; this.visible = true; + // Hide DOM-rendered SVG cards while tutorial overlays are visible so + // the canvas-based tooltip is not occluded by DOM elements. + try { this.scene.svgDom?.setVisible(false); } catch { /* ignore */ } this.showStep(this.currentStep); } @@ -182,6 +185,8 @@ export class MainStreetTutorialOverlayManager { public dismiss(): void { this.clearObjects(); this.visible = false; + // Restore DOM-rendered SVG cards visibility when tutorial is dismissed. + try { this.scene.svgDom?.setVisible(true); } catch { /* ignore */ } } /** Advance to the next tutorial step (or dismiss if at end). */ @@ -215,6 +220,10 @@ export class MainStreetTutorialOverlayManager { const gameW: number = layout.gameW ?? 1280; const gameH: number = layout.gameH ?? 720; + // Ensure DOM-rendered SVG cards are hidden while overlay is visible so + // canvas-based overlays are visually on top. + try { s.svgDom?.setVisible(false); } catch { /* ignore */ } + // ── Optional highlight rectangle ────────────────────── const anchor = step.anchor(s); if (anchor) { @@ -229,67 +238,79 @@ export class MainStreetTutorialOverlayManager { this.objects.push(highlight); } - // ── Tooltip box ─────────────────────────────────────── - // Position tooltip below the anchor (or centred if no anchor) + // Precompute layout for tooltip contents (title + body + buttons) const tooltipX = gameW / 2 - TOOLTIP_W / 2; + + // Create title and body first to measure heights, but keep them off-screen + const titleText = s.add.text(0, 0, step.title, { + fontSize: '15px', fontStyle: 'bold', color: '#aaffaa', fontFamily: FONT_FAMILY, + wordWrap: { width: TOOLTIP_W - 32 }, + }).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + + const bodyText = s.add.text(0, 0, step.body, { + fontSize: '13px', color: '#ddccbb', fontFamily: FONT_FAMILY, wordWrap: { width: TOOLTIP_W - 32 }, lineSpacing: 3, + }).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + + // Step label height (small) + const stepLabelHeight = 18; + + // Buttons area height + const btnAreaH = 36; // comfortable area for dismiss/prev/next + + // Padding and spacing + const padTop = 12; + const padSides = 16; + const padBetweenTitleAndBody = 8; + const padBottom = 12; + + // Compute desired tooltip height + let contentH = padTop + titleText.height + padBetweenTitleAndBody + bodyText.height + btnAreaH + padBottom; + const maxH = gameH - 40; // leave safety margin + let tooltipH = Math.min(Math.max(contentH, 80), maxH); + + // If content would overflow, clamp body and recompute heights + if (contentH > tooltipH) { + const availableForBody = tooltipH - (padTop + titleText.height + padBetweenTitleAndBody + btnAreaH + padBottom); + if (availableForBody > 20) { + bodyText.setCrop(0, 0, bodyText.width, availableForBody); + } + } + + // Decide vertical position relative to anchor let tooltipY: number; if (anchor) { - // Try to place below the highlight; clamp to viewport const belowY = anchor.y + anchor.h + 12; - const aboveY = anchor.y - TOOLTIP_H_BASE - 12; - tooltipY = belowY + TOOLTIP_H_BASE < gameH ? belowY : aboveY; + const aboveY = anchor.y - tooltipH - 12; + tooltipY = belowY + tooltipH < gameH ? belowY : Math.max(12, aboveY); } else { - tooltipY = gameH / 2 - TOOLTIP_H_BASE / 2; + tooltipY = Math.max(12, Math.floor(gameH / 2 - tooltipH / 2)); } + // Draw background using computed height const bg = s.add.graphics(); bg.setDepth(TOOLTIP_DEPTH); bg.fillStyle(TOOLTIP_BG_COLOR, 0.95); - bg.fillRoundedRect(tooltipX, tooltipY, TOOLTIP_W, TOOLTIP_H_BASE, 8); + bg.fillRoundedRect(tooltipX, tooltipY, TOOLTIP_W, tooltipH, 8); bg.lineStyle(2, TOOLTIP_BORDER_COLOR, 0.9); - bg.strokeRoundedRect(tooltipX, tooltipY, TOOLTIP_W, TOOLTIP_H_BASE, 8); + bg.strokeRoundedRect(tooltipX, tooltipY, TOOLTIP_W, tooltipH, 8); this.objects.push(bg); // Step counter badge (e.g. "1 / 7") - const stepLabel = s.add.text( - tooltipX + TOOLTIP_W - 12, - tooltipY + 10, - `${index + 1} / ${TUTORIAL_STEPS.length}`, - { fontSize: '11px', color: '#669966', fontFamily: FONT_FAMILY }, - ).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1); + const stepLabel = s.add.text(tooltipX + 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); - // Title - const titleText = s.add.text( - tooltipX + 16, - tooltipY + 12, - step.title, - { fontSize: '15px', fontStyle: 'bold', color: '#aaffaa', fontFamily: FONT_FAMILY }, - ).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + // Position title and body inside box + titleText.setPosition(tooltipX + padSides, tooltipY + padTop); this.objects.push(titleText); - // Body - const bodyText = s.add.text( - tooltipX + 16, - tooltipY + 36, - step.body, - { - fontSize: '13px', - color: '#ddccbb', - fontFamily: FONT_FAMILY, - wordWrap: { width: TOOLTIP_W - 32 }, - lineSpacing: 3, - }, - ).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); + bodyText.setPosition(tooltipX + padSides, tooltipY + padTop + titleText.height + padBetweenTitleAndBody); this.objects.push(bodyText); - // ── Navigation buttons ──────────────────────────────── - const btnY = tooltipY + TOOLTIP_H_BASE - 24; + // Navigation buttons positioned at bottom area + const btnY = tooltipY + tooltipH - padBottom - btnAreaH / 2; // Dismiss button (always shown on the left) - const dismissBtn = s.add.text(tooltipX + 12, btnY, '[ Dismiss ]', { - fontSize: '12px', color: '#aa8866', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + const dismissBtn = s.add.text(tooltipX + 12, btnY, '[ Dismiss ]', { fontSize: '12px', color: '#aa8866', fontFamily: FONT_FAMILY }).setOrigin(0, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); dismissBtn.on('pointerdown', () => this.dismiss()); dismissBtn.on('pointerover', () => dismissBtn.setColor('#ddaa88')); dismissBtn.on('pointerout', () => dismissBtn.setColor('#aa8866')); @@ -297,9 +318,7 @@ export class MainStreetTutorialOverlayManager { // Prev button (disabled on step 0) if (index > 0) { - const prevBtn = s.add.text(tooltipX + TOOLTIP_W / 2 - 50, btnY, '[ < Prev ]', { - fontSize: '12px', color: '#88bbff', fontFamily: FONT_FAMILY, - }).setOrigin(0.5, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + const prevBtn = s.add.text(tooltipX + TOOLTIP_W / 2 - 50, btnY, '[ < Prev ]', { fontSize: '12px', color: '#88bbff', fontFamily: FONT_FAMILY }).setOrigin(0.5, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); prevBtn.on('pointerdown', () => this.prevStep()); prevBtn.on('pointerover', () => prevBtn.setColor('#aaddff')); prevBtn.on('pointerout', () => prevBtn.setColor('#88bbff')); @@ -310,9 +329,7 @@ export class MainStreetTutorialOverlayManager { const isLast = index === TUTORIAL_STEPS.length - 1; const nextLabel = isLast ? '[ Finish ]' : '[ Next > ]'; const nextColor = isLast ? '#44ff44' : '#88ff88'; - const nextBtn = s.add.text(tooltipX + TOOLTIP_W - 12, btnY, nextLabel, { - fontSize: '12px', color: nextColor, fontFamily: FONT_FAMILY, - }).setOrigin(1, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); + const nextBtn = s.add.text(tooltipX + TOOLTIP_W - 12, btnY, nextLabel, { fontSize: '12px', color: nextColor, fontFamily: FONT_FAMILY }).setOrigin(1, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); nextBtn.on('pointerdown', () => this.nextStep()); nextBtn.on('pointerover', () => nextBtn.setColor('#ffffff')); nextBtn.on('pointerout', () => nextBtn.setColor(nextColor)); From 1ce096df4023b84f0bacd1d7cbeeadac2ac6ee51 Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:46:07 +0100 Subject: [PATCH 07/30] MainStreet tutorial: render tooltip as DOM overlay so it appears above DOM-rendered cards; keep highlight on canvas --- .../MainStreetTutorialOverlayManager.ts | 180 +++++++++--------- 1 file changed, 85 insertions(+), 95 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 53893816..1e1a932e 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -166,9 +166,6 @@ export class MainStreetTutorialOverlayManager { public start(): void { this.currentStep = 0; this.visible = true; - // Hide DOM-rendered SVG cards while tutorial overlays are visible so - // the canvas-based tooltip is not occluded by DOM elements. - try { this.scene.svgDom?.setVisible(false); } catch { /* ignore */ } this.showStep(this.currentStep); } @@ -185,8 +182,6 @@ export class MainStreetTutorialOverlayManager { public dismiss(): void { this.clearObjects(); this.visible = false; - // Restore DOM-rendered SVG cards visibility when tutorial is dismissed. - try { this.scene.svgDom?.setVisible(true); } catch { /* ignore */ } } /** Advance to the next tutorial step (or dismiss if at end). */ @@ -220,11 +215,7 @@ export class MainStreetTutorialOverlayManager { const gameW: number = layout.gameW ?? 1280; const gameH: number = layout.gameH ?? 720; - // Ensure DOM-rendered SVG cards are hidden while overlay is visible so - // canvas-based overlays are visually on top. - try { s.svgDom?.setVisible(false); } catch { /* ignore */ } - - // ── Optional highlight rectangle ────────────────────── + // ── Optional highlight rectangle (canvas) ────────────── const anchor = step.anchor(s); if (anchor) { const highlight = s.add.graphics(); @@ -238,102 +229,101 @@ export class MainStreetTutorialOverlayManager { this.objects.push(highlight); } - // Precompute layout for tooltip contents (title + body + buttons) + // Build a DOM tooltip so it can render above DOM-based card elements. const tooltipX = gameW / 2 - TOOLTIP_W / 2; - // Create title and body first to measure heights, but keep them off-screen - const titleText = s.add.text(0, 0, step.title, { - fontSize: '15px', fontStyle: 'bold', color: '#aaffaa', fontFamily: FONT_FAMILY, - wordWrap: { width: TOOLTIP_W - 32 }, - }).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); - - const bodyText = s.add.text(0, 0, step.body, { - fontSize: '13px', color: '#ddccbb', fontFamily: FONT_FAMILY, wordWrap: { width: TOOLTIP_W - 32 }, lineSpacing: 3, - }).setOrigin(0, 0).setDepth(TOOLTIP_DEPTH + 1); - - // Step label height (small) - const stepLabelHeight = 18; - - // Buttons area height - const btnAreaH = 36; // comfortable area for dismiss/prev/next - - // Padding and spacing + // Buttons and padding const padTop = 12; const padSides = 16; const padBetweenTitleAndBody = 8; const padBottom = 12; - // Compute desired tooltip height - let contentH = padTop + titleText.height + padBetweenTitleAndBody + bodyText.height + btnAreaH + padBottom; - const maxH = gameH - 40; // leave safety margin - let tooltipH = Math.min(Math.max(contentH, 80), maxH); - - // If content would overflow, clamp body and recompute heights - if (contentH > tooltipH) { - const availableForBody = tooltipH - (padTop + titleText.height + padBetweenTitleAndBody + btnAreaH + padBottom); - if (availableForBody > 20) { - bodyText.setCrop(0, 0, bodyText.width, availableForBody); - } - } - - // Decide vertical position relative to anchor - let tooltipY: number; - if (anchor) { - const belowY = anchor.y + anchor.h + 12; - const aboveY = anchor.y - tooltipH - 12; - tooltipY = belowY + tooltipH < gameH ? belowY : Math.max(12, aboveY); - } else { - tooltipY = Math.max(12, Math.floor(gameH / 2 - tooltipH / 2)); - } - - // Draw background using computed height - const bg = s.add.graphics(); - bg.setDepth(TOOLTIP_DEPTH); - bg.fillStyle(TOOLTIP_BG_COLOR, 0.95); - bg.fillRoundedRect(tooltipX, tooltipY, TOOLTIP_W, tooltipH, 8); - bg.lineStyle(2, TOOLTIP_BORDER_COLOR, 0.9); - bg.strokeRoundedRect(tooltipX, tooltipY, TOOLTIP_W, tooltipH, 8); - this.objects.push(bg); - - // Step counter badge (e.g. "1 / 7") - const stepLabel = s.add.text(tooltipX + 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); - - // Position title and body inside box - titleText.setPosition(tooltipX + padSides, tooltipY + padTop); - this.objects.push(titleText); - - bodyText.setPosition(tooltipX + padSides, tooltipY + padTop + titleText.height + padBetweenTitleAndBody); - this.objects.push(bodyText); - - // Navigation buttons positioned at bottom area - const btnY = tooltipY + tooltipH - padBottom - btnAreaH / 2; - - // Dismiss button (always shown on the left) - const dismissBtn = s.add.text(tooltipX + 12, btnY, '[ Dismiss ]', { fontSize: '12px', color: '#aa8866', fontFamily: FONT_FAMILY }).setOrigin(0, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); - dismissBtn.on('pointerdown', () => this.dismiss()); - dismissBtn.on('pointerover', () => dismissBtn.setColor('#ddaa88')); - dismissBtn.on('pointerout', () => dismissBtn.setColor('#aa8866')); - this.objects.push(dismissBtn); - - // Prev button (disabled on step 0) + 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 = s.add.text(tooltipX + TOOLTIP_W / 2 - 50, btnY, '[ < Prev ]', { fontSize: '12px', color: '#88bbff', fontFamily: FONT_FAMILY }).setOrigin(0.5, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); - prevBtn.on('pointerdown', () => this.prevStep()); - prevBtn.on('pointerover', () => prevBtn.setColor('#aaddff')); - prevBtn.on('pointerout', () => prevBtn.setColor('#88bbff')); - this.objects.push(prevBtn); + 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); - // Next / Finish button + const rightGroup = document.createElement('div'); + const nextBtn = document.createElement('button'); const isLast = index === TUTORIAL_STEPS.length - 1; - const nextLabel = isLast ? '[ Finish ]' : '[ Next > ]'; - const nextColor = isLast ? '#44ff44' : '#88ff88'; - const nextBtn = s.add.text(tooltipX + TOOLTIP_W - 12, btnY, nextLabel, { fontSize: '12px', color: nextColor, fontFamily: FONT_FAMILY }).setOrigin(1, 0.5).setDepth(TOOLTIP_DEPTH + 1).setInteractive({ useHandCursor: true }); - nextBtn.on('pointerdown', () => this.nextStep()); - nextBtn.on('pointerover', () => nextBtn.setColor('#ffffff')); - nextBtn.on('pointerout', () => nextBtn.setColor(nextColor)); - this.objects.push(nextBtn); + 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); + + // Add as a Phaser DOMElement positioned at top-left + const dom = s.add.dom(tooltipX, Math.max(12, Math.floor(gameH / 2 - TOOLTIP_H_BASE / 2)), 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 on top-left of tooltip + const stepLabel = s.add.text(tooltipX + TOOLTIP_W - 12, (dom as any).y + 10, `${index + 1} / ${TUTORIAL_STEPS.length}`, { fontSize: '11px', color: '#669966', fontFamily: FONT_FAMILY }).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1); + this.objects.push(stepLabel); } // ── Private helpers ─────────────────────────────────────── From 943d3ed44e17fec4d2cfabfe1c45d92ec3f155e5 Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:50:44 +0100 Subject: [PATCH 08/30] MainStreet tutorial: position tooltip to the right/left of anchor when possible (prevents obscuring Challenges panel); measure tooltip height for accurate placement --- .../MainStreetTutorialOverlayManager.ts | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 1e1a932e..8287acf9 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -315,14 +315,48 @@ export class MainStreetTutorialOverlayManager { container.appendChild(btnRow); - // Add as a Phaser DOMElement positioned at top-left - const dom = s.add.dom(tooltipX, Math.max(12, Math.floor(gameH / 2 - TOOLTIP_H_BASE / 2)), container) as Phaser.GameObjects.DOMElement; + // 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 on top-left of tooltip - const stepLabel = s.add.text(tooltipX + TOOLTIP_W - 12, (dom as any).y + 10, `${index + 1} / ${TUTORIAL_STEPS.length}`, { fontSize: '11px', color: '#669966', fontFamily: FONT_FAMILY }).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1); + // 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); } From 61d97202fc1e829a2b8c30a0c5a38dd41bfab5e2 Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:52:43 +0100 Subject: [PATCH 09/30] Tutorial: tighten Market highlight to the market box (20px inset, gameW-40 width) instead of full-width --- .../scenes/MainStreetTutorialOverlayManager.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 8287acf9..5a8d4bf0 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -58,8 +58,14 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ anchor: (scene: any) => { const l = scene.layout; if (!l) return null; - return { x: 0, y: l.marketTop - 6, w: l.gameW, h: l.marketRowH * 2 + l.marketRowGap + 16 }; + // Market visual area uses a left padding of 20 and width of gameW - 40 + const x = 20; + const w = Math.max(100, l.gameW - 40); + const y = l.marketTop - 6; + const h = l.marketRowH * 2 + l.marketRowGap + 16; + return { x, y, w, h }; }, + }, { title: 'Upcoming Incidents', From 8b8e3c7560911c627d1c2f7ba511ab4b87e6b0ef Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:54:19 +0100 Subject: [PATCH 10/30] tutorial: remove unused tooltip color constants (DOM tooltip uses CSS colors) --- .../main-street/scenes/MainStreetTutorialOverlayManager.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 5a8d4bf0..34bfbbbd 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -146,8 +146,6 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const TOOLTIP_W = 360; const TOOLTIP_H_BASE = 170; -const TOOLTIP_BG_COLOR = 0x1a2a1a; -const TOOLTIP_BORDER_COLOR = 0x44aa44; const TOOLTIP_DEPTH = 200; const HIGHLIGHT_COLOR = 0x44ff44; const HIGHLIGHT_ALPHA = 0.18; From 58b2cb4256d8b1d5467b0ae43d244bbba935435e Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:56:44 +0100 Subject: [PATCH 11/30] Tutorial: clamp Market and Incident highlight widths to avoid overlapping right-hand log/challenge area --- .../MainStreetTutorialOverlayManager.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 34bfbbbd..80eb0915 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -18,6 +18,7 @@ */ import { FONT_FAMILY } from '../../../src/ui'; +import { MARKET_BUSINESS_SLOTS } from '../MainStreetCards'; // ── Tutorial step definitions ──────────────────────────────── @@ -58,9 +59,17 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ anchor: (scene: any) => { const l = scene.layout; if (!l) return null; - // Market visual area uses a left padding of 20 and width of gameW - 40 - const x = 20; - const w = Math.max(100, l.gameW - 40); + // Market visual area: compute card row start and width so the highlight + // matches the visible card columns and doesn't extend into the log sidebar. + 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 + const x = Math.max(20, startX - padding); + // Prevent highlight from extending into the right-hand log/challenge area + const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); + const desiredW = Math.max(80, 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 }; @@ -78,7 +87,14 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ anchor: (scene: any) => { const l = scene.layout; if (!l) return null; - return { x: 0, y: l.queueTop - 6, w: l.gameW, h: l.queueCardH + 16 }; + // Compute queue area width using label width + card widths for the visible queue + const x = 20; + 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 }; }, }, { From 35eba92b7c529179eb0bf02bc5de6b7b0dc42363 Mon Sep 17 00:00:00 2001 From: Map Date: Fri, 15 May 2026 23:59:09 +0100 Subject: [PATCH 12/30] Tutorial: include left-side labels in Market/Incidents highlights; import INCIDENT_QUEUE_SIZE; clamp widths to avoid log overlap --- .../scenes/MainStreetTutorialOverlayManager.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 80eb0915..8e770360 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -18,7 +18,7 @@ */ import { FONT_FAMILY } from '../../../src/ui'; -import { MARKET_BUSINESS_SLOTS } from '../MainStreetCards'; +import { MARKET_BUSINESS_SLOTS, INCIDENT_QUEUE_SIZE } from '../MainStreetCards'; // ── Tutorial step definitions ──────────────────────────────── @@ -65,10 +65,12 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const slots = MARKET_BUSINESS_SLOTS; const totalCardsW = slots * l.marketCardW + (slots - 1) * l.marketCardGap; const padding = 8; // small padding around the highlight - const x = Math.max(20, startX - padding); + const leftLabelX = 40; // title/label X used in renderer + const x = Math.max(20, leftLabelX); // Prevent highlight from extending into the right-hand log/challenge area const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); - const desiredW = Math.max(80, totalCardsW + padding * 2); + // Desired width includes the space from label left to card area plus cards + const desiredW = Math.max(80, (startX - leftLabelX) + 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; @@ -88,7 +90,8 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const l = scene.layout; if (!l) return null; // Compute queue area width using label width + card widths for the visible queue - const x = 20; + const leftLabelX = 40; + const x = Math.max(20, leftLabelX); 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))); From 82e44ad9d84f26bc1984e864fd7c6a5949330175 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 00:01:01 +0100 Subject: [PATCH 13/30] Tutorial: tighten left alignment for Market and Incidents highlights; compute Challenges highlight height from active challenges --- .../MainStreetTutorialOverlayManager.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 8e770360..cb47b2a5 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -65,8 +65,9 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const slots = MARKET_BUSINESS_SLOTS; const totalCardsW = slots * l.marketCardW + (slots - 1) * l.marketCardGap; const padding = 8; // small padding around the highlight - const leftLabelX = 40; // title/label X used in renderer - const x = Math.max(20, leftLabelX); + // Use a small left padding so highlight hugs the market box closely + const leftLabelX = 20; // match renderer background left padding + const x = Math.max(12, leftLabelX); // Prevent highlight from extending into the right-hand log/challenge area const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); // Desired width includes the space from label left to card area plus cards @@ -90,8 +91,9 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const l = scene.layout; if (!l) return null; // Compute queue area width using label width + card widths for the visible queue - const leftLabelX = 40; - const x = Math.max(20, leftLabelX); + // Align queue highlight with left content padding + const leftLabelX = 20; + const x = Math.max(12, leftLabelX); 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))); @@ -156,7 +158,21 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const l = scene.layout; if (!l) return null; if (!l.challengeX || l.challengeX < 0) return null; - return { x: l.challengeX - 8, y: l.challengeY - 8, w: l.challengeW + 16, h: 140 }; + // Compute challenge panel height from constants and current active challenges if available + try { + 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 }; + } }, }, ]; From 4306323e2b2ced3b1546be9c9d6a26063f63ca44 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 14:25:21 +0100 Subject: [PATCH 14/30] Tutorial: align Market/Incidents highlights to label start (include titles); compute Challenges highlight from challengeContainer bounds when available --- .../MainStreetTutorialOverlayManager.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index cb47b2a5..8e832b5b 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -66,12 +66,13 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const totalCardsW = slots * l.marketCardW + (slots - 1) * l.marketCardGap; const padding = 8; // small padding around the highlight // Use a small left padding so highlight hugs the market box closely - const leftLabelX = 20; // match renderer background left padding - const x = Math.max(12, leftLabelX); + // Start at the text label X (40) so the highlight includes the title. + const labelX = 40; + const x = Math.max(12, labelX - 8); // small left padding to avoid tight edge // Prevent highlight from extending into the right-hand log/challenge area const rightLimit = (typeof l.logX === 'number' && l.logX > 0) ? l.logX - 20 : Math.max(20, l.gameW - 40); - // Desired width includes the space from label left to card area plus cards - const desiredW = Math.max(80, (startX - leftLabelX) + totalCardsW + padding * 2); + // Desired width: distance from label to card start + total cards width + padding + 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; @@ -92,8 +93,8 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ if (!l) return null; // Compute queue area width using label width + card widths for the visible queue // Align queue highlight with left content padding - const leftLabelX = 20; - const x = Math.max(12, leftLabelX); + 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))); @@ -160,6 +161,17 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ 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; From 4579c7018b923c9f95cc6a809fc2b6f41f8b16a5 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 14:30:47 +0100 Subject: [PATCH 15/30] Tutorial highlights: use rendered container bounds for Market and Queue when available to match actual UI; fallback to layout-derived sizes --- .../MainStreetTutorialOverlayManager.ts | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 8e832b5b..7245c400 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -59,19 +59,33 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ anchor: (scene: any) => { const l = scene.layout; if (!l) return null; - // Market visual area: compute card row start and width so the highlight - // matches the visible card columns and doesn't extend into the log sidebar. + // 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 - // Use a small left padding so highlight hugs the market box closely - // Start at the text label X (40) so the highlight includes the title. + // Start at the content label X so the highlight includes the title area const labelX = 40; - const x = Math.max(12, labelX - 8); // small left padding to avoid tight edge - // Prevent highlight from extending into the right-hand log/challenge area + 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); - // Desired width: distance from label to card start + total cards width + padding 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; @@ -91,8 +105,21 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ anchor: (scene: any) => { const l = scene.layout; if (!l) return null; - // Compute queue area width using label width + card widths for the visible queue - // Align queue highlight with left content padding + // 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); @@ -102,7 +129,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const h = l.queueCardH + 16; return { x, y, w, h }; }, - }, + } { title: 'Your Street', body: From 8e325daf6fb6cac3986221c7afda9bdc28a7204a Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 14:42:29 +0100 Subject: [PATCH 16/30] Fix syntax: remove stray brace in tutorial steps array --- .../main-street/scenes/MainStreetTutorialOverlayManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 7245c400..e0c04000 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -129,7 +129,6 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const h = l.queueCardH + 16; return { x, y, w, h }; }, - } { title: 'Your Street', body: From 16e235b2778eede218029095c69aeb0602e63867 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 15:10:13 +0100 Subject: [PATCH 17/30] tutorial: fix object literal/array punctuation (close Market step object) --- .../main-street/scenes/MainStreetTutorialOverlayManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index e0c04000..87ef2b2b 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -129,6 +129,7 @@ export const TUTORIAL_STEPS: TutorialStep[] = [ const h = l.queueCardH + 16; return { x, y, w, h }; }, + }, { title: 'Your Street', body: From 1f20384d677735d058d4269950f14b66f4225443 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 15:26:17 +0100 Subject: [PATCH 18/30] CG-0MM5ZGB8U02S0BFO: integrate tutorial into Main Street scene and Settings panel; persist tutorialSeen flag in campaign progress; auto-show on first run; allow replay from Settings --- .../main-street/MainStreetSaveLoad.ts | 9 ++- example-games/main-street/MainStreetState.ts | 2 + .../scenes/MainStreetLifecycleManager.ts | 55 ++++++++++++++++++- .../main-street/scenes/MainStreetScene.ts | 2 + .../MainStreetTutorialOverlayManager.ts | 9 ++- src/ui/SettingsPanel.ts | 17 ++++++ 6 files changed, 89 insertions(+), 5 deletions(-) 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..cab2f263 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'; @@ -342,6 +342,34 @@ 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 + try { + if (typeof window !== 'undefined' && (window as any).addEventListener) { + (window as any).addEventListener('tce:play-tutorial', () => { + try { (s as any).tutorialOverlay?.start(); } catch (_) { /* ignore */ } + }); + } + } catch (_) { /* ignore */ } + // Global keyboard handler for End Turn (configurable via Settings) const endTurnKeyHandler = (ev: KeyboardEvent) => { try { @@ -416,7 +444,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 +458,31 @@ 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 && (s as any).tutorialOverlay) { + try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ } + } + } catch (_) { /* ignore */ } + return saved; }).catch(() => { // If load fails, continue with defaults (already set up above) + try { + if (s.campaign && !(s.campaign as any).tutorialSeen && (s as any).tutorialOverlay) { + try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ } + } + } catch (_) { /* ignore */ } + 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/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 6a357dea..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'; diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 87ef2b2b..42016d80 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -232,8 +232,11 @@ export class MainStreetTutorialOverlayManager { private objects: Phaser.GameObjects.GameObject[] = []; private currentStep = 0; private visible = false; + private readonly onComplete: (() => void) | null; - constructor(private readonly scene: any) {} + constructor(private readonly scene: any, onComplete?: () => void) { + this.onComplete = onComplete ?? null; + } /** True if the tutorial overlay is currently visible. */ get isVisible(): boolean { @@ -258,8 +261,12 @@ export class MainStreetTutorialOverlayManager { /** 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). */ diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index f1080048..f7c5543c 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -535,6 +535,23 @@ export class SettingsPanel { }); tip.setDepth(DEPTH_PANEL_CONTENT); this.container.add(tip); + + // Play Tutorial button (dispatches a DOM event consumed by scenes) + const playTutorialY = difficultyY + 56; + const playTutorial = scene.add.text(PADDING, playTutorialY, 'Play Tutorial', { + fontSize: '14px', color: (HEADING_STYLE.color as string) ?? '#f0c040', fontFamily: 'Arial, sans-serif', + }); + playTutorial.setDepth(DEPTH_PANEL_CONTENT + 1); + playTutorial.setInteractive({ useHandCursor: true }); + playTutorial.on('pointerdown', () => { + try { + if (typeof window !== 'undefined' && (window as any).dispatchEvent) { + const ev = new CustomEvent('tce:play-tutorial'); + (window as any).dispatchEvent(ev); + } + } catch { /* ignore */ } + }); + this.container.add(playTutorial); } // Scene-level pointer events for slider dragging From 6a661ae6bc66f9661954eca5306a11f6250f36ea Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:10:12 +0100 Subject: [PATCH 19/30] Fix: ensure tutorialOverlay created before campaign load; add debug logs for Play Tutorial event --- .../scenes/MainStreetLifecycleManager.ts | 56 ++++++++++++++----- src/ui/SettingsPanel.ts | 5 +- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index cab2f263..516a9740 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -175,6 +175,34 @@ 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 (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) + } + // Game setup -- load campaign for tier-filtered deck building s.saveStore = new SaveLoadStore(); this.loadCampaignAndSetup(); @@ -192,15 +220,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 @@ -361,14 +380,25 @@ export class MainStreetLifecycleManager { // Ignore if DOM environment is unavailable (tests) } - // Listen for Settings 'Play Tutorial' request + // 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 (_) { /* ignore */ } + (window as any).addEventListener('tce:play-tutorial', (ev: any) => { + try { + // Debug log to help trace events from Settings + // eslint-disable-next-line no-console + console.debug('[MainStreet] Received tce:play-tutorial event', ev); + (s as any).tutorialOverlay?.start(); + } catch (e) { + // eslint-disable-next-line no-console + console.error('[MainStreet] play-tutorial handler failed', e); + } }); } - } catch (_) { /* ignore */ } + } catch (e) { + // eslint-disable-next-line no-console + console.debug('[MainStreet] failed to register play-tutorial listener', e); + } // Global keyboard handler for End Turn (configurable via Settings) const endTurnKeyHandler = (ev: KeyboardEvent) => { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index f7c5543c..bb0221c2 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -548,8 +548,11 @@ export class SettingsPanel { if (typeof window !== 'undefined' && (window as any).dispatchEvent) { const ev = new CustomEvent('tce:play-tutorial'); (window as any).dispatchEvent(ev); + // Debug log to help trace event firing + // eslint-disable-next-line no-console + console.debug('[SettingsPanel] dispatched tce:play-tutorial'); } - } catch { /* ignore */ } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:play-tutorial', e); } }); this.container.add(playTutorial); } From cb825cd2ea985c546f74ea8b3a84b03a3103f7ab Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:13:42 +0100 Subject: [PATCH 20/30] Improve visibility of Play Tutorial logs and add auto-show diagnostics; warn if tutorialOverlay unavailable --- .../scenes/MainStreetLifecycleManager.ts | 34 ++++++++++++++----- src/ui/SettingsPanel.ts | 4 +-- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 516a9740..9775d115 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -199,8 +199,12 @@ export class MainStreetLifecycleManager { } } catch (_) { /* ignore */ } }); - } catch (_) { + // eslint-disable-next-line no-console + console.info('[MainStreet] tutorialOverlay created'); + } catch (e) { // Ignore if DOM environment is unavailable (tests) + // eslint-disable-next-line no-console + console.warn('[MainStreet] tutorialOverlay creation failed', e); } // Game setup -- load campaign for tier-filtered deck building @@ -492,18 +496,32 @@ export class MainStreetLifecycleManager { } // After attempting to load (saved or not) auto-show tutorial if not seen try { - if (s.campaign && !(s.campaign as any).tutorialSeen && (s as any).tutorialOverlay) { - try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ } + if (s.campaign && !(s.campaign as any).tutorialSeen) { + if ((s as any).tutorialOverlay) { + // eslint-disable-next-line no-console + console.info('[MainStreet] auto-showing tutorial (first run)'); + try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } + } else { + // eslint-disable-next-line no-console + console.warn('[MainStreet] tutorialOverlay not available to auto-show'); + } } - } catch (_) { /* ignore */ } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial check failed', e); } return saved; - }).catch(() => { + }).catch((err) => { // If load fails, continue with defaults (already set up above) try { - if (s.campaign && !(s.campaign as any).tutorialSeen && (s as any).tutorialOverlay) { - try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ } + if (s.campaign && !(s.campaign as any).tutorialSeen) { + if ((s as any).tutorialOverlay) { + // eslint-disable-next-line no-console + console.info('[MainStreet] auto-showing tutorial (no saved campaign)'); + try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } + } else { + // eslint-disable-next-line no-console + console.warn('[MainStreet] tutorialOverlay not available to auto-show'); + } } - } catch (_) { /* ignore */ } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial fallback failed', e); } return null; }); } else { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index bb0221c2..40b72f27 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -548,9 +548,9 @@ export class SettingsPanel { if (typeof window !== 'undefined' && (window as any).dispatchEvent) { const ev = new CustomEvent('tce:play-tutorial'); (window as any).dispatchEvent(ev); - // Debug log to help trace event firing + // Log to help trace event firing (use info so it's visible by default) // eslint-disable-next-line no-console - console.debug('[SettingsPanel] dispatched tce:play-tutorial'); + console.info('[SettingsPanel] dispatched tce:play-tutorial'); } } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:play-tutorial', e); } }); From ef7d3d03b81a32a2149bcce0d0bdb21d1b92f186 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:14:33 +0100 Subject: [PATCH 21/30] Fix tutorial overlay creation: use static import instead of require (browser-safe) --- example-games/main-street/scenes/MainStreetLifecycleManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 9775d115..6a043904 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -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 { @@ -188,7 +189,7 @@ export class MainStreetLifecycleManager { // 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 (require('./MainStreetTutorialOverlayManager').MainStreetTutorialOverlayManager)(s, () => { + (s as any).tutorialOverlay = new MainStreetTutorialOverlayManager(s, () => { try { if (s.campaign) { s.campaign.tutorialSeen = true; From 9629d2f907f96e50d34779329053b34fa03e3215 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:18:47 +0100 Subject: [PATCH 22/30] Fallback: if DOM tooltip creation fails, render tutorial as in-canvas Phaser overlay --- .../MainStreetTutorialOverlayManager.ts | 263 ++++++++++-------- 1 file changed, 147 insertions(+), 116 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 42016d80..7140ed43 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -323,126 +323,157 @@ export class MainStreetTutorialOverlayManager { const padBetweenTitleAndBody = 8; const padBottom = 12; - 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)); + 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 { - const belowY = anchor.y + anchor.h + 12; - const aboveY = anchor.y - tooltipH - 12; - tooltipY = belowY + tooltipH < gameH ? belowY : Math.max(12, aboveY); + domX = Math.max(12, Math.floor(gameW / 2 - TOOLTIP_W / 2)); + tooltipY = Math.max(12, Math.floor(gameH / 2 - tooltipH / 2)); } - } 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); + // 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 + console.warn('[Tutorial] DOM tooltip creation failed, falling back to canvas tooltip', e); + + 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); + } - // 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); + this.objects.push(bg, border, titleTxt, bodyTxt, dismissBtn, nextBtn); + } } // ── Private helpers ─────────────────────────────────────── From b6b0974d147a9556ce802fa2a509793f8634ecbf Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:21:26 +0100 Subject: [PATCH 23/30] Make tutorial overlay resilient: retry showStep if scene not ready --- .../scenes/MainStreetTutorialOverlayManager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 7140ed43..5dc4e559 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -296,6 +296,15 @@ export class MainStreetTutorialOverlayManager { 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) { + // eslint-disable-next-line no-console + console.debug('[Tutorial] scene not ready for overlays, retrying shortly'); + 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; From 5a6c447f9ae5b26f9fdcbbf10e674b2113f1373a Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:24:14 +0100 Subject: [PATCH 24/30] Cleanup logs and remove tutorial entry from main menu; keep tutorial accessible from Settings and in-scene --- .../scenes/MainStreetLifecycleManager.ts | 27 ++++--------------- .../MainStreetTutorialOverlayManager.ts | 4 +-- main.ts | 10 +------ src/ui/SettingsPanel.ts | 3 --- 4 files changed, 7 insertions(+), 37 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 6a043904..d22b4216 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -200,12 +200,9 @@ export class MainStreetLifecycleManager { } } catch (_) { /* ignore */ } }); - // eslint-disable-next-line no-console - console.info('[MainStreet] tutorialOverlay created'); } catch (e) { // Ignore if DOM environment is unavailable (tests) - // eslint-disable-next-line no-console - console.warn('[MainStreet] tutorialOverlay creation failed', e); + /* keep silent on creation failure */ } // Game setup -- load campaign for tier-filtered deck building @@ -388,11 +385,8 @@ export class MainStreetLifecycleManager { // 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', (ev: any) => { + (window as any).addEventListener('tce:play-tutorial', () => { try { - // Debug log to help trace events from Settings - // eslint-disable-next-line no-console - console.debug('[MainStreet] Received tce:play-tutorial event', ev); (s as any).tutorialOverlay?.start(); } catch (e) { // eslint-disable-next-line no-console @@ -400,9 +394,8 @@ export class MainStreetLifecycleManager { } }); } - } catch (e) { - // eslint-disable-next-line no-console - console.debug('[MainStreet] failed to register play-tutorial listener', e); + } catch (_e) { + // ignore } // Global keyboard handler for End Turn (configurable via Settings) @@ -499,27 +492,17 @@ export class MainStreetLifecycleManager { try { if (s.campaign && !(s.campaign as any).tutorialSeen) { if ((s as any).tutorialOverlay) { - // eslint-disable-next-line no-console - console.info('[MainStreet] auto-showing tutorial (first run)'); try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } - } else { - // eslint-disable-next-line no-console - console.warn('[MainStreet] tutorialOverlay not available to auto-show'); } } } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial check failed', e); } return saved; - }).catch((err) => { + }).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) { - // eslint-disable-next-line no-console - console.info('[MainStreet] auto-showing tutorial (no saved campaign)'); try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } - } else { - // eslint-disable-next-line no-console - console.warn('[MainStreet] tutorialOverlay not available to auto-show'); } } } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial fallback failed', e); } diff --git a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts index 5dc4e559..db1b944b 100644 --- a/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts +++ b/example-games/main-street/scenes/MainStreetTutorialOverlayManager.ts @@ -298,8 +298,6 @@ export class MainStreetTutorialOverlayManager { const s = this.scene; // If the scene is not fully ready (no add/sys), retry shortly. if (!s || !s.add) { - // eslint-disable-next-line no-console - console.debug('[Tutorial] scene not ready for overlays, retrying shortly'); setTimeout(() => { try { this.showStep(index); } catch (_) { /* ignore */ } }, 60); @@ -456,7 +454,7 @@ export class MainStreetTutorialOverlayManager { } catch (e) { // Fallback to in-canvas tooltip if DOM is not available or fails // eslint-disable-next-line no-console - console.warn('[Tutorial] DOM tooltip creation failed, falling back to canvas tooltip', e); + // 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)); diff --git a/main.ts b/main.ts index 984f52b3..421f9d89 100644 --- a/main.ts +++ b/main.ts @@ -20,7 +20,6 @@ import { FeudalismScene } from './example-games/feudalism/scenes/FeudalismScene' import { LostCitiesScene } from './example-games/lost-cities/scenes/LostCitiesScene'; import { TheMindScene } from './example-games/the-mind/scenes/TheMindScene'; import { MainStreetScene } from './example-games/main-street/scenes/MainStreetScene'; -import { MainStreetTutorialScene } from './example-games/main-street/scenes/MainStreetTutorialScene'; // ── Game catalogue ───────────────────────────────────────── @@ -74,13 +73,6 @@ const GAMES: GameEntry[] = [ 'Single-player tableau builder. Purchase businesses, place them along a 10-slot street for synergy bonuses, manage coins and reputation, and build the highest-scoring Main Street in 20 turns.', thumbnail: 'games/main-street/thumbnail', }, - { - sceneKey: 'MainStreetTutorialScene', - title: 'Scenario: Tutorial', - description: - 'Guided introduction to Main Street (Easy, 25 turns). Non-interactive tutorial overlays walk you through the market, street placement, synergies, events, and scoring — perfect for new players.', - thumbnail: 'games/main-street/thumbnail', - }, ]; // ── Phaser boot ──────────────────────────────────────────── @@ -94,7 +86,7 @@ const isReplayMode = new URLSearchParams(window.location.search).get('mode') === createCardGame({ backgroundColor: '#1a2a1a', // Register all scenes; GameSelectorScene is first so it auto-starts. - scenes: [GameSelectorScene, GolfScene, BeleagueredCastleScene, SushiGoScene, FeudalismScene, LostCitiesScene, TheMindScene, MainStreetScene, MainStreetTutorialScene], + scenes: [GameSelectorScene, GolfScene, BeleagueredCastleScene, SushiGoScene, FeudalismScene, LostCitiesScene, TheMindScene, MainStreetScene], render: isReplayMode ? { preserveDrawingBuffer: true } : undefined, callbacks: { preBoot: (game: Phaser.Game) => { diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 40b72f27..b305a445 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -548,9 +548,6 @@ export class SettingsPanel { if (typeof window !== 'undefined' && (window as any).dispatchEvent) { const ev = new CustomEvent('tce:play-tutorial'); (window as any).dispatchEvent(ev); - // Log to help trace event firing (use info so it's visible by default) - // eslint-disable-next-line no-console - console.info('[SettingsPanel] dispatched tce:play-tutorial'); } } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:play-tutorial', e); } }); From adb5aae9a8cb7b6cc51e4758e19dde9fc55a0687 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:27:32 +0100 Subject: [PATCH 25/30] Remove debug/info logs, silence noisy tutorial-start failures; remove tutorial from game selector --- .../main-street/scenes/MainStreetLifecycleManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index d22b4216..d6ed3354 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -492,7 +492,7 @@ export class MainStreetLifecycleManager { try { if (s.campaign && !(s.campaign as any).tutorialSeen) { if ((s as any).tutorialOverlay) { - try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } + 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); } @@ -502,7 +502,7 @@ export class MainStreetLifecycleManager { try { if (s.campaign && !(s.campaign as any).tutorialSeen) { if ((s as any).tutorialOverlay) { - try { (s as any).tutorialOverlay.start(); } catch (e) { console.error('[MainStreet] tutorial start failed', e); } + 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); } From e8adad61ce291dfb3e1cb39197b9dd0b2dda2d58 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 18:31:30 +0100 Subject: [PATCH 26/30] Remove separate MainStreetTutorialScene - tutorial now overlay-only --- .../scenes/MainStreetTutorialScene.ts | 104 ------------------ 1 file changed, 104 deletions(-) delete mode 100644 example-games/main-street/scenes/MainStreetTutorialScene.ts diff --git a/example-games/main-street/scenes/MainStreetTutorialScene.ts b/example-games/main-street/scenes/MainStreetTutorialScene.ts deleted file mode 100644 index 4228038d..00000000 --- a/example-games/main-street/scenes/MainStreetTutorialScene.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * MainStreetTutorialScene -- "Scenario: Tutorial" entry for Main Street. - * - * A thin subclass of MainStreetScene that: - * - Registers under a distinct Phaser scene key so it can co-exist with - * the standard Main Street scene in the scene registry. - * - Forces Easy difficulty (more turns, lower score target, generous coins) - * so new players experience a forgiving introduction. - * - Attaches a MainStreetTutorialOverlayManager and adds a toggleable - * "Tutorial" button to the action bar so players can revisit hints. - * - * Tutorial overlays are non-interactive: they highlight UI regions and - * display contextual text but never block gameplay. Players can dismiss - * individual hints or the whole overlay at any time. - * - * @module - */ - -import { MainStreetScene } from './MainStreetScene'; -import { MainStreetTutorialOverlayManager } from './MainStreetTutorialOverlayManager'; -import { FONT_FAMILY } from '../../../src/ui'; - -export const TUTORIAL_SCENE_KEY = 'MainStreetTutorialScene'; - -export class MainStreetTutorialScene extends MainStreetScene { - /** Tutorial overlay controller — initialised in create(). */ - public tutorialOverlay!: MainStreetTutorialOverlayManager; - - /** Whether to show tutorial hints automatically on first create. */ - private _autoShowTutorial = true; - - constructor() { - // Pass distinct scene key to parent so this scene co-exists with MainStreetScene. - super({ key: TUTORIAL_SCENE_KEY }); - // Force Easy difficulty for all tutorial runs. - this.selectedDifficulty = 'Easy'; - } - - // ── Phaser lifecycle ─────────────────────────────────────── - - override create(...args: any[]): any { - // selectedDifficulty is already 'Easy' from the constructor; loadCampaignAndSetup - // (called inside super.create) reads it when creating the game state. - const result = super.create(...args); - - // Attach tutorial overlay manager after parent has built the scene layout. - this.tutorialOverlay = new MainStreetTutorialOverlayManager(this); - - // Add the "[?] Tutorial" toggle button to the scene header area. - this._addTutorialButton(); - - // Auto-show tutorial on first load. - if (this._autoShowTutorial) { - this._autoShowTutorial = false; - // Small delay so the initial layout/render settles first. - this.time.delayedCall(300, () => { - if (this.tutorialOverlay) { - this.tutorialOverlay.start(); - } - }); - } - - return result; - } - - // ── Tutorial button ─────────────────────────────────────── - - /** - * Adds a small "[?] Tutorial" toggle button to the scene. - * The button is positioned at the top-right of the HUD area. - */ - private _addTutorialButton(): void { - const gameW = this.layout?.gameW ?? 1280; - const btnX = gameW - 120; - const btnY = 18; - - const btn = this.add.text(btnX, btnY, '[?] Tutorial', { - fontSize: '13px', - color: '#88ff88', - fontFamily: FONT_FAMILY, - backgroundColor: '#1a2a1a', - padding: { x: 6, y: 3 }, - }).setOrigin(0, 0.5).setDepth(150).setInteractive({ useHandCursor: true }); - - btn.on('pointerover', () => btn.setColor('#aaffaa')); - btn.on('pointerout', () => btn.setColor('#88ff88')); - btn.on('pointerdown', () => { - if (this.tutorialOverlay) { - this.tutorialOverlay.toggle(); - } - }); - - // Keep the button visible in the HUD container if one exists. - try { - if (this.hudContainer) { - this.hudContainer.add(btn); - } - } catch (e) { - // Non-fatal: hudContainer.add may fail in headless test environments. - // eslint-disable-next-line no-console - console.debug('[Tutorial] _addTutorialButton: hudContainer.add failed', e); - } - } -} From 43073a9ba47675cc52cda52e08f640cdda70aed8 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 23:46:15 +0100 Subject: [PATCH 27/30] Settings: replace 'Play Tutorial' with 'Reset Tutorial'; add reset handler to MainStreetLifecycleManager --- .../scenes/MainStreetLifecycleManager.ts | 14 ++++++++++++++ src/ui/SettingsPanel.ts | 18 +++++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index d6ed3354..20ca70ba 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -393,6 +393,20 @@ export class MainStreetLifecycleManager { console.error('[MainStreet] play-tutorial handler failed', e); } }); + (window as any).addEventListener('tce:reset-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(() => {}); + } + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('[MainStreet] reset-tutorial handler failed', e); + } + }); } } catch (_e) { // ignore diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index b305a445..9ada8910 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -536,22 +536,22 @@ export class SettingsPanel { tip.setDepth(DEPTH_PANEL_CONTENT); this.container.add(tip); - // Play Tutorial button (dispatches a DOM event consumed by scenes) - const playTutorialY = difficultyY + 56; - const playTutorial = scene.add.text(PADDING, playTutorialY, 'Play Tutorial', { + // Reset Tutorial button (dispatches a DOM event consumed by scenes) + const resetTutorialY = difficultyY + 56; + const resetTutorial = scene.add.text(PADDING, resetTutorialY, 'Reset Tutorial', { fontSize: '14px', color: (HEADING_STYLE.color as string) ?? '#f0c040', fontFamily: 'Arial, sans-serif', }); - playTutorial.setDepth(DEPTH_PANEL_CONTENT + 1); - playTutorial.setInteractive({ useHandCursor: true }); - playTutorial.on('pointerdown', () => { + resetTutorial.setDepth(DEPTH_PANEL_CONTENT + 1); + resetTutorial.setInteractive({ useHandCursor: true }); + resetTutorial.on('pointerdown', () => { try { if (typeof window !== 'undefined' && (window as any).dispatchEvent) { - const ev = new CustomEvent('tce:play-tutorial'); + const ev = new CustomEvent('tce:reset-tutorial'); (window as any).dispatchEvent(ev); } - } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:play-tutorial', e); } + } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:reset-tutorial', e); } }); - this.container.add(playTutorial); + this.container.add(resetTutorial); } // Scene-level pointer events for slider dragging From 9b66969cdc47da9cd3be43b7b2f5fbca2563afb4 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 23:50:28 +0100 Subject: [PATCH 28/30] Settings: change to 'Replay Tutorial' with confirmation modal; scene listens for replay event and restarts tutorial run --- src/ui/SettingsPanel.ts | 73 +++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 9ada8910..2e1130dc 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; @@ -536,22 +539,15 @@ export class SettingsPanel { tip.setDepth(DEPTH_PANEL_CONTENT); this.container.add(tip); - // Reset Tutorial button (dispatches a DOM event consumed by scenes) - const resetTutorialY = difficultyY + 56; - const resetTutorial = scene.add.text(PADDING, resetTutorialY, 'Reset Tutorial', { + // 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', }); - resetTutorial.setDepth(DEPTH_PANEL_CONTENT + 1); - resetTutorial.setInteractive({ useHandCursor: true }); - resetTutorial.on('pointerdown', () => { - try { - if (typeof window !== 'undefined' && (window as any).dispatchEvent) { - const ev = new CustomEvent('tce:reset-tutorial'); - (window as any).dispatchEvent(ev); - } - } catch (e) { /* eslint-disable-next-line no-console */ console.error('[SettingsPanel] failed to dispatch tce:reset-tutorial', e); } - }); - this.container.add(resetTutorial); + 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 @@ -582,6 +578,55 @@ export class SettingsPanel { this.container.setVisible(false); } + // ── Replay Tutorial modal ───────────────────────────────── + + private _showReplayTutorialModal(): void { + if (this._modalOpen) return; + this._modalOpen = true; + + const w = Math.min(320, this.panelWidth - 40); + const h = 140; + const x = 20; // relative inside panel + const y = 120; + + const bg = this.scene.add.rectangle(x + w/2, y + h/2, w + 24, h + 24, 0x000000, 0.8).setDepth(DEPTH_PANEL_CONTENT + 50); + const box = this.scene.add.rectangle(x + w/2, y + h/2, w, h, 0x1a2a1a, 1).setDepth(DEPTH_PANEL_CONTENT + 51); + const title = this.scene.add.text(x + 12, y + 12, 'Replay Tutorial?', { fontSize: '16px', color: '#f0c040', fontFamily: 'Arial, sans-serif' }).setDepth(DEPTH_PANEL_CONTENT + 52).setOrigin(0,0); + const body = this.scene.add.text(x + 12, y + 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 }).setDepth(DEPTH_PANEL_CONTENT + 52).setOrigin(0,0); + + const cancel = this.scene.add.text(x + 12, y + h - 28, 'Cancel', { fontSize: '13px', color: '#aa8866', fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }).setDepth(DEPTH_PANEL_CONTENT + 53).setOrigin(0,0); + const confirm = this.scene.add.text(x + w - 12, y + h - 28, 'Continue', { fontSize: '13px', color: '#002200', backgroundColor: '#88ff88', padding: { left: 6, right: 6 } as any, fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }).setDepth(DEPTH_PANEL_CONTENT + 53).setOrigin(1,0); + + const container = this.scene.add.container(this.panelWidth - this.canvasWidth + this.container.x, 0); + // The container is positioned relative to the parent; we'll add children at panel-local coords + container.add([bg, box, title, body, cancel, confirm]); + container.setDepth(DEPTH_PANEL_CONTENT + 50); + + 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); } + this._closeReplayTutorialModal(); + // Close settings panel after confirmation + 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 { From f6ad5d76fafc4c9b0aa3abc37166be249382da70 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 23:53:06 +0100 Subject: [PATCH 29/30] Replay Tutorial flow: Settings modal dispatches tce:replay-tutorial; scene restarts run in Easy and auto-shows tutorial --- .../scenes/MainStreetLifecycleManager.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 20ca70ba..9e0cfc7f 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -393,7 +393,8 @@ export class MainStreetLifecycleManager { console.error('[MainStreet] play-tutorial handler failed', e); } }); - (window as any).addEventListener('tce:reset-tutorial', () => { + // 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; @@ -402,9 +403,21 @@ export class MainStreetLifecycleManager { 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] reset-tutorial handler failed', e); + console.error('[MainStreet] replay-tutorial handler failed', e); } }); } From e320ed4fdb8da598c08e6504a8d0cea361cda526 Mon Sep 17 00:00:00 2001 From: Map Date: Sat, 16 May 2026 23:57:08 +0100 Subject: [PATCH 30/30] SettingsPanel: center Replay Tutorial modal and ensure it is removed on Continue/Cancel --- src/ui/SettingsPanel.ts | 44 ++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 2e1130dc..3b5727d3 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -584,23 +584,38 @@ export class SettingsPanel { if (this._modalOpen) return; this._modalOpen = true; - const w = Math.min(320, this.panelWidth - 40); - const h = 140; - const x = 20; // relative inside panel - const y = 120; + // Modal dimensions + const w = Math.min(480, Math.max(320, Math.floor(this.canvasWidth * 0.5))); + const h = 160; - const bg = this.scene.add.rectangle(x + w/2, y + h/2, w + 24, h + 24, 0x000000, 0.8).setDepth(DEPTH_PANEL_CONTENT + 50); - const box = this.scene.add.rectangle(x + w/2, y + h/2, w, h, 0x1a2a1a, 1).setDepth(DEPTH_PANEL_CONTENT + 51); - const title = this.scene.add.text(x + 12, y + 12, 'Replay Tutorial?', { fontSize: '16px', color: '#f0c040', fontFamily: 'Arial, sans-serif' }).setDepth(DEPTH_PANEL_CONTENT + 52).setOrigin(0,0); - const body = this.scene.add.text(x + 12, y + 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 }).setDepth(DEPTH_PANEL_CONTENT + 52).setOrigin(0,0); + // 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); - const cancel = this.scene.add.text(x + 12, y + h - 28, 'Cancel', { fontSize: '13px', color: '#aa8866', fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }).setDepth(DEPTH_PANEL_CONTENT + 53).setOrigin(0,0); - const confirm = this.scene.add.text(x + w - 12, y + h - 28, 'Continue', { fontSize: '13px', color: '#002200', backgroundColor: '#88ff88', padding: { left: 6, right: 6 } as any, fontFamily: 'Arial, sans-serif' }).setInteractive({ useHandCursor: true }).setDepth(DEPTH_PANEL_CONTENT + 53).setOrigin(1,0); + // 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); - const container = this.scene.add.container(this.panelWidth - this.canvasWidth + this.container.x, 0); - // The container is positioned relative to the parent; we'll add children at panel-local coords container.add([bg, box, title, body, cancel, confirm]); - container.setDepth(DEPTH_PANEL_CONTENT + 50); cancel.on('pointerdown', () => this._closeReplayTutorialModal()); confirm.on('pointerdown', () => { @@ -610,8 +625,9 @@ export class SettingsPanel { (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(); - // Close settings panel after confirmation try { this.close(); } catch (_) { /* ignore */ } });