Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
094fa50
Initial plan
Copilot May 12, 2026
2ef0d81
Add Main Street Tutorial scenario, tutorial overlays, smoke test, and…
Copilot May 12, 2026
c04af81
Address code review: add debug logging in catch blocks for tutorial o…
Copilot May 12, 2026
3d1ba81
fix(renderer): preserve persistent HUD overlays through refreshHud() …
Copilot May 12, 2026
58f7258
CG-0MP7HYWLS004HG3Y: Add user-facing 404 page and client-side deep-li…
May 15, 2026
557e83b
MainStreet tutorial: dynamic tooltip sizing, avoid DOM card occlusion…
May 15, 2026
1ce096d
MainStreet tutorial: render tooltip as DOM overlay so it appears abov…
May 15, 2026
943d3ed
MainStreet tutorial: position tooltip to the right/left of anchor whe…
May 15, 2026
61d9720
Tutorial: tighten Market highlight to the market box (20px inset, gam…
May 15, 2026
8b8e3c7
tutorial: remove unused tooltip color constants (DOM tooltip uses CSS…
May 15, 2026
58b2cb4
Tutorial: clamp Market and Incident highlight widths to avoid overlap…
May 15, 2026
35eba92
Tutorial: include left-side labels in Market/Incidents highlights; im…
May 15, 2026
82e44ad
Tutorial: tighten left alignment for Market and Incidents highlights;…
May 15, 2026
4306323
Tutorial: align Market/Incidents highlights to label start (include t…
May 16, 2026
4579c70
Tutorial highlights: use rendered container bounds for Market and Que…
May 16, 2026
8e325da
Fix syntax: remove stray brace in tutorial steps array
May 16, 2026
16e235b
tutorial: fix object literal/array punctuation (close Market step obj…
May 16, 2026
1f20384
CG-0MM5ZGB8U02S0BFO: integrate tutorial into Main Street scene and Se…
May 16, 2026
6a661ae
Fix: ensure tutorialOverlay created before campaign load; add debug l…
May 16, 2026
cb825cd
Improve visibility of Play Tutorial logs and add auto-show diagnostic…
May 16, 2026
ef7d3d0
Fix tutorial overlay creation: use static import instead of require (…
May 16, 2026
9629d2f
Fallback: if DOM tooltip creation fails, render tutorial as in-canvas…
May 16, 2026
b6b0974
Make tutorial overlay resilient: retry showStep if scene not ready
May 16, 2026
5a6c447
Cleanup logs and remove tutorial entry from main menu; keep tutorial …
May 16, 2026
adb5aae
Remove debug/info logs, silence noisy tutorial-start failures; remove…
May 16, 2026
e8adad6
Remove separate MainStreetTutorialScene - tutorial now overlay-only
May 16, 2026
43073a9
Settings: replace 'Play Tutorial' with 'Reset Tutorial'; add reset ha…
May 16, 2026
9b66969
Settings: change to 'Replay Tutorial' with confirmation modal; scene …
May 16, 2026
f6ad5d7
Replay Tutorial flow: Settings modal dispatches tce:replay-tutorial; …
May 16, 2026
e320ed4
SettingsPanel: center Replay Tutorial modal and ensure it is removed …
May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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.

Expand Down
47 changes: 46 additions & 1 deletion docs/main-street/playtest-scenarios.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion example-games/main-street/MainStreetSaveLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
};

Expand All @@ -61,6 +67,7 @@ export function createDefaultCampaignProgress(): MainStreetCampaignProgress {
totalRuns: 0,
totalWins: 0,
lastUpdatedAt: new Date().toISOString(),
tutorialSeen: false,
};
}

Expand Down
2 changes: 2 additions & 0 deletions example-games/main-street/MainStreetState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────
Expand Down
132 changes: 120 additions & 12 deletions example-games/main-street/scenes/MainStreetLifecycleManager.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -175,6 +176,35 @@ export class MainStreetLifecycleManager {
);
});

// UI scaffolding
s.msRenderer = new MainStreetRenderer(s);
s.msAnimator = new MainStreetAnimator(s);
s.msTurnController = new MainStreetTurnController(s);
s.msOverlayManager = new MainStreetOverlayManager(s);
s.msInputManager = new MainStreetInputManager(s);
s.msSvgTextureManager = new MainStreetSvgTextureManager(s);
s.layout = s.computeLayout();
s.svgDebugEnabled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('msSvgDebug') === '1';

// Create tutorial overlay manager early so it's available to any async
// callbacks (campaign load) that may want to auto-show the tutorial.
try {
(s as any).tutorialOverlay = new MainStreetTutorialOverlayManager(s, () => {
try {
if (s.campaign) {
s.campaign.tutorialSeen = true;
if (s.saveStore) {
// Persist the updated campaign progress asynchronously
void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {});
}
}
} catch (_) { /* ignore */ }
});
} catch (e) {
// Ignore if DOM environment is unavailable (tests)
/* keep silent on creation failure */
}

// Game setup -- load campaign for tier-filtered deck building
s.saveStore = new SaveLoadStore();
this.loadCampaignAndSetup();
Expand All @@ -192,15 +222,6 @@ export class MainStreetLifecycleManager {
// ignore if recorder cannot be created
}

// UI scaffolding
s.msRenderer = new MainStreetRenderer(s);
s.msAnimator = new MainStreetAnimator(s);
s.msTurnController = new MainStreetTurnController(s);
s.msOverlayManager = new MainStreetOverlayManager(s);
s.msInputManager = new MainStreetInputManager(s);
s.msSvgTextureManager = new MainStreetSvgTextureManager(s);
s.layout = s.computeLayout();
s.svgDebugEnabled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('msSvgDebug') === '1';
// Prewarm SVG textures once all SVG sources are loaded.
// Until then the scene uses fallback cards; then we refresh with SVG textures.
void s.cardSvgLoadPromise
Expand Down Expand Up @@ -342,6 +363,68 @@ export class MainStreetLifecycleManager {
s.tooltipManager = new TooltipManager(s, s.settingsPanel);
}

// Create tutorial overlay manager (attached to main scene) so the tutorial
// can be shown from Settings or automatically on first run. The onComplete
// callback marks the campaign as having seen the tutorial and persists it.
try {
(s as any).tutorialOverlay = new (require('./MainStreetTutorialOverlayManager').MainStreetTutorialOverlayManager)(s, () => {
try {
if (s.campaign) {
s.campaign.tutorialSeen = true;
if (s.saveStore) {
// Persist the updated campaign progress asynchronously
void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {});
}
}
} catch (_) { /* ignore */ }
});
} catch (_) {
// Ignore if DOM environment is unavailable (tests)
}

// Listen for Settings 'Play Tutorial' request and log for debugging
try {
if (typeof window !== 'undefined' && (window as any).addEventListener) {
(window as any).addEventListener('tce:play-tutorial', () => {
try {
(s as any).tutorialOverlay?.start();
} catch (e) {
// eslint-disable-next-line no-console
console.error('[MainStreet] play-tutorial handler failed', e);
}
});
// Replay tutorial: warn in settings, then dispatch this event to restart current run into tutorial mode
(window as any).addEventListener('tce:replay-tutorial', () => {
try {
if (s.campaign) {
s.campaign.tutorialSeen = false;
if (s.saveStore) {
// Persist change but do not block
void saveCampaignProgress(s.saveStore, s.campaign).catch(() => {});
}
}

// Restart the current run as a tutorial run (force Easy difficulty)
try {
s.selectedDifficulty = 'Easy';
s.state = setupMainStreetGame({ difficulty: 'Easy', unlockedCardIds: s.campaign?.unlockedCardIds });
s.startDayPhase();
// show tutorial overlay if available
try { (s as any).tutorialOverlay?.start(); } catch (_) { /* ignore */ }
} catch (e) {
// eslint-disable-next-line no-console
console.error('[MainStreet] failed to restart into tutorial', e);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error('[MainStreet] replay-tutorial handler failed', e);
}
});
}
} catch (_e) {
// ignore
}

// Global keyboard handler for End Turn (configurable via Settings)
const endTurnKeyHandler = (ev: KeyboardEvent) => {
try {
Expand Down Expand Up @@ -416,7 +499,8 @@ export class MainStreetLifecycleManager {

// Async: attempt to load saved campaign and re-setup if found
if (s.saveStore) {
loadCampaignProgress(s.saveStore).then((saved: any) => {
// Store the load promise on the scene so other code can wait if needed
(s as any)._campaignLoadPromise = loadCampaignProgress(s.saveStore).then((saved: any) => {
if (saved) {
s.campaign = saved;
// Re-setup with the loaded campaign's unlocked cards
Expand All @@ -429,11 +513,35 @@ export class MainStreetLifecycleManager {
// phase is synchronised. Without this, the engine stays in
// DayStart while the UI shows market controls, blocking all
// player actions and causing End Turn to hang.
s.startDayPhase();
try { s.startDayPhase(); } catch (_) { /* ignore */ }
}
// After attempting to load (saved or not) auto-show tutorial if not seen
try {
if (s.campaign && !(s.campaign as any).tutorialSeen) {
if ((s as any).tutorialOverlay) {
try { (s as any).tutorialOverlay.start(); } catch (_e) { /* ignore */ }
}
}
} catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial check failed', e); }
return saved;
}).catch(() => {
// If load fails, continue with defaults (already set up above)
try {
if (s.campaign && !(s.campaign as any).tutorialSeen) {
if ((s as any).tutorialOverlay) {
try { (s as any).tutorialOverlay.start(); } catch (_e) { /* ignore */ }
}
}
} catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] auto-show tutorial fallback failed', e); }
return null;
});
} else {
// No saveStore: if tutorial hasn't been seen, show it now (best-effort)
try {
if (s.campaign && !(s.campaign as any).tutorialSeen && (s as any).tutorialOverlay) {
try { (s as any).tutorialOverlay.start(); } catch (_) { /* ignore */ }
}
} catch (_) { /* ignore */ }
}
}

Expand Down
33 changes: 25 additions & 8 deletions example-games/main-street/scenes/MainStreetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Phaser.GameObjects.GameObject>(obj: T): T & { _hudTransient: true } {
(obj as any)._hudTransient = true;
return obj as T & { _hudTransient: true };
}

export class MainStreetRenderer {
constructor(private readonly scene: any) {}

Expand Down Expand Up @@ -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<Phaser.GameObjects.GameObject & { _hudTransient?: boolean }>;
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({
Expand Down
6 changes: 4 additions & 2 deletions example-games/main-street/scenes/MainStreetScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -114,8 +116,8 @@ export class MainStreetScene extends CardGameScene {
// Undo/Redo manager for market actions (per-scene)
public undoManager!: UndoRedoManager;

constructor() {
super({ key: 'MainStreetScene' });
constructor(config?: Partial<Phaser.Types.Scenes.SettingsConfig>) {
super({ key: 'MainStreetScene', ...(config ?? {}) });
this.msLifecycleManager = new MainStreetLifecycleManager(this);
}

Expand Down
Loading
Loading