diff --git a/docs/core-engine/transcript-migration.md b/docs/core-engine/transcript-migration.md new file mode 100644 index 00000000..cd8443d7 --- /dev/null +++ b/docs/core-engine/transcript-migration.md @@ -0,0 +1,147 @@ +# Transcript & SaveLoad Consolidation — Migration Guide + +**Work Item:** CG-0MP12WI75001L9P4 +**Date:** 2026-05-24 + +## Summary + +All transcript-related functionality has been consolidated into a single +sub-module at `src/core-engine/transcript/`. The original top-level files +(`TranscriptRecorder.ts`, `TranscriptStore.ts`, `TranscriptTypes.ts`, +`autoSaveTranscript.ts`) remain as backward-compatible re-exports so that +existing imports continue to work without changes. + +## What Changed + +### New canonical location + +All transcript exports are now available from `src/core-engine/transcript/`: + +``` +src/core-engine/transcript/ +├── index.ts # Barrel file (canonical import target) +├── TranscriptRecorder.ts # TranscriptRecorderBase, BaseTranscript +├── TranscriptStore.ts # TranscriptStore, StoredTranscript, TranscriptStoreOptions +├── TranscriptTypes.ts # CardSnapshot, snapshotCard() +└── autoSaveTranscript.ts # autoSaveTranscript() +``` + +### Backward compatibility + +The legacy top-level files now re-export from the consolidated location: + +| Legacy path | Re-exports from | +|---|---| +| `src/core-engine/TranscriptRecorder.ts` | `./transcript/TranscriptRecorder.ts` | +| `src/core-engine/TranscriptStore.ts` | `./transcript/TranscriptStore.ts` | +| `src/core-engine/TranscriptTypes.ts` | `./transcript/TranscriptTypes.ts` | +| `src/core-engine/autoSaveTranscript.ts` | `./transcript/autoSaveTranscript.ts` | + +The core-engine barrel (`src/core-engine/index.ts`) also exports everything +from the consolidated transcript barrel, so `import { ... } from '@core-engine'` +continues to work. + +## Migration + +### No action required (recommended for now) + +Existing imports will continue to work without any changes. The re-export +shims ensure full backward compatibility. + +### Optional: update to the consolidated import (new code) + +When adding new code or refactoring, prefer the consolidated import: + +**Before (old per-file imports):** + +```ts +import { TranscriptRecorderBase, type BaseTranscript } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptStore, type StoredTranscript } from '../../src/core-engine/TranscriptStore'; +import { autoSaveTranscript } from '../../src/core-engine/autoSaveTranscript'; +import { CardSnapshot, snapshotCard } from '../../src/core-engine/TranscriptTypes'; +``` + +**After (consolidated import):** + +```ts +import { + TranscriptRecorderBase, + BaseTranscript, + TranscriptStore, + StoredTranscript, + TranscriptStoreOptions, + autoSaveTranscript, + CardSnapshot, + snapshotCard, +} from '@core-engine/transcript'; +``` + +Or via relative path: + +```ts +import { + TranscriptRecorderBase, + TranscriptStore, + autoSaveTranscript, + snapshotCard, +} from '../../../src/core-engine/transcript'; +``` + +### Via the core-engine barrel + +The consolidated exports are also available through the main barrel: + +```ts +import { + TranscriptRecorderBase, + TranscriptStore, + autoSaveTranscript, + snapshotCard, + BaseTranscript, + CardSnapshot, + StoredTranscript, + TranscriptStoreOptions, +} from '@core-engine'; +``` + +## Main Street Integration + +Main Street now uses `autoSaveTranscript` from the consolidated module. +When a game ends, the transcript is automatically finalized and persisted +to browser storage (IndexedDB with localStorage fallback). + +This was added in `MainStreetTurnController.ts`: + +```ts +import { finalizeMainStreetTranscript } from '../MainStreetTranscript'; +import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; + +// In the game-end branch: +const transcript = finalizeMainStreetTranscript({ + gameResult: result.gameResult, + finalScore: result.finalScore, +}); +if (transcript) { + const transcriptStore = new TranscriptStore(); + autoSaveTranscript(transcriptStore, 'main-street', transcript, '[MainStreet]'); +} +``` + +Two new helper functions were added to `MainStreetTranscript.ts`: + +- `finalizeMainStreetTranscript(result)` — finalizes the global recorder's + transcript and returns it (or null if no recorder is set). +- `getMainStreetTranscript()` — returns the current (possibly un-finalized) + transcript from the global recorder. + +## API Stability + +No public APIs were renamed or removed. All existing imports continue to work. +The only change is the addition of the consolidated `transcript/` sub-module. + +## Tests + +- All existing tests continue to pass (the re-export shims preserve behavior). +- A new integration test was added: + `tests/main-street/transcript-autosave.integration.test.ts` + This exercises autosave, save/load round-trips, and the consolidated barrel. diff --git a/example-games/beleaguered-castle/GameTranscript.ts b/example-games/beleaguered-castle/GameTranscript.ts index 3d528945..99f502e0 100644 --- a/example-games/beleaguered-castle/GameTranscript.ts +++ b/example-games/beleaguered-castle/GameTranscript.ts @@ -24,9 +24,11 @@ import type { BCMove, } from './BeleagueredCastleState'; import { FOUNDATION_COUNT, TABLEAU_COUNT } from './BeleagueredCastleState'; -import { snapshotCard } from '../../src/core-engine/TranscriptTypes'; -import type { CardSnapshot } from '../../src/core-engine/TranscriptTypes'; -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { + snapshotCard, + TranscriptRecorderBase, + type CardSnapshot, +} from '../../src/core-engine/transcript'; // Re-export so existing consumers that import from this module still work. export { snapshotCard }; diff --git a/example-games/feudalism/GameTranscript.ts b/example-games/feudalism/GameTranscript.ts index 04be00d1..76d7c0a6 100644 --- a/example-games/feudalism/GameTranscript.ts +++ b/example-games/feudalism/GameTranscript.ts @@ -19,7 +19,7 @@ * @module */ -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; import type { DevelopmentCard, PatronTile, ResourceTokens, Tier } from './FeudalismCards'; import type { FeudalismSession, diff --git a/example-games/feudalism/scenes/FeudalismOverlayManager.ts b/example-games/feudalism/scenes/FeudalismOverlayManager.ts index 030a6509..b25e5ad0 100644 --- a/example-games/feudalism/scenes/FeudalismOverlayManager.ts +++ b/example-games/feudalism/scenes/FeudalismOverlayManager.ts @@ -7,8 +7,7 @@ import { tierDisplayName, resourceDisplayName, formatCost } from '../FeudalismCa import type { FeudalismSession } from '../FeudalismGame'; import { getInfluence, getWinnerIndex } from '../FeudalismGame'; import { FeudalismTranscriptRecorder } from '../GameTranscript'; -import { autoSaveTranscript } from '../../../src/core-engine/autoSaveTranscript'; -import { TranscriptStore } from '../../../src/core-engine/TranscriptStore'; +import { autoSaveTranscript, TranscriptStore } from '../../../src/core-engine/transcript'; import { GAME_W, GAME_H, FONT_FAMILY, createOverlayBackground, createOverlayButton, createOverlayMenuButton, diff --git a/example-games/golf/GameTranscript.ts b/example-games/golf/GameTranscript.ts index 4e8fd445..222e5272 100644 --- a/example-games/golf/GameTranscript.ts +++ b/example-games/golf/GameTranscript.ts @@ -13,9 +13,11 @@ import type { GolfGrid } from './GolfGrid'; import type { DrawSource, GolfMove } from './GolfRules'; import type { GolfSession, TurnResult } from './GolfGame'; import { scoreGrid, scoreVisibleCards } from './GolfScoring'; -import { snapshotCard } from '../../src/core-engine/TranscriptTypes'; -import type { CardSnapshot } from '../../src/core-engine/TranscriptTypes'; -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { + snapshotCard, + TranscriptRecorderBase, + type CardSnapshot, +} from '../../src/core-engine/transcript'; // Re-export so existing consumers that import from this module still work. export { snapshotCard }; diff --git a/example-games/golf/scenes/GolfOverlayManager.ts b/example-games/golf/scenes/GolfOverlayManager.ts index 9d4d465e..1a0488b6 100644 --- a/example-games/golf/scenes/GolfOverlayManager.ts +++ b/example-games/golf/scenes/GolfOverlayManager.ts @@ -4,8 +4,7 @@ import type { TranscriptRecorder } from '../GameTranscript'; -import { TranscriptStore } from '../../../src/core-engine/TranscriptStore'; -import { autoSaveTranscript } from '../../../src/core-engine/autoSaveTranscript'; +import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import type { SoundManager, GameEventEmitter } from '../../../src/core-engine'; import { GAME_W, GAME_H, FONT_FAMILY, diff --git a/example-games/lost-cities/GameTranscript.ts b/example-games/lost-cities/GameTranscript.ts index 3ad96539..acbcabde 100644 --- a/example-games/lost-cities/GameTranscript.ts +++ b/example-games/lost-cities/GameTranscript.ts @@ -29,7 +29,7 @@ import type { import type { LostCitiesAction, } from './LostCitiesRules'; -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; // ── Card snapshot ────────────────────────────────────────── diff --git a/example-games/lost-cities/scenes/LostCitiesOverlayManager.ts b/example-games/lost-cities/scenes/LostCitiesOverlayManager.ts index 25c12a60..29e27601 100644 --- a/example-games/lost-cities/scenes/LostCitiesOverlayManager.ts +++ b/example-games/lost-cities/scenes/LostCitiesOverlayManager.ts @@ -5,8 +5,7 @@ import Phaser from 'phaser'; import { EXPEDITION_COLORS } from '../LostCitiesCards'; import type { LostCitiesSession, RoundScoreResult } from '../LostCitiesGame'; import { getMatchWinner } from '../LostCitiesGame'; -import { autoSaveTranscript } from '../../../src/core-engine/autoSaveTranscript'; -import { TranscriptStore } from '../../../src/core-engine/TranscriptStore'; +import { autoSaveTranscript, TranscriptStore } from '../../../src/core-engine/transcript'; import { GAME_W, GAME_H, diff --git a/example-games/main-street/MainStreetTranscript.ts b/example-games/main-street/MainStreetTranscript.ts index a0bdca67..131e6d5a 100644 --- a/example-games/main-street/MainStreetTranscript.ts +++ b/example-games/main-street/MainStreetTranscript.ts @@ -1,4 +1,4 @@ -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; // Minimal transcript event types for Main Street export type PlayerActionDescriptor = { type: string; [k: string]: any }; @@ -64,3 +64,28 @@ export function recordMainStreetEvent(e: MainStreetTranscriptEvent): void { // defensive: do not throw from recorder in non-critical paths } } + +/** + * Finalize the global transcript and return it. + * + * Returns null if no recorder has been set (e.g. in headless tests). + * The `result` parameter should be the game-end result object containing + * at least `gameResult` and `finalScore`. + */ +export function finalizeMainStreetTranscript(result: { + gameResult: string; + finalScore: number; + [k: string]: unknown; +}): MainStreetTranscript | null { + if (!globalRecorder) return null; + return globalRecorder.finalize(result); +} + +/** + * Get the current (possibly un-finalized) transcript from the global recorder. + * + * Returns null if no recorder has been set. + */ +export function getMainStreetTranscript(): MainStreetTranscript | null { + return globalRecorder?.getTranscript() ?? null; +} diff --git a/example-games/main-street/scenes/MainStreetTurnController.ts b/example-games/main-street/scenes/MainStreetTurnController.ts index 1b0628c5..d5ddb4e3 100644 --- a/example-games/main-street/scenes/MainStreetTurnController.ts +++ b/example-games/main-street/scenes/MainStreetTurnController.ts @@ -11,7 +11,8 @@ import { } from '../MainStreetMarket'; import type { BusinessCard, EventCard, UpgradeCard } from '../MainStreetCards'; import { BuyBusinessCommand, BuyUpgradeCommand, BuyEventCommand, PlayEventCommand, BuyRefreshInvestmentsCommand } from '../MainStreetCommands'; -import { recordMainStreetEvent } from '../MainStreetTranscript'; +import { recordMainStreetEvent, finalizeMainStreetTranscript } from '../MainStreetTranscript'; +import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import { FONT_FAMILY, createOverlayBackground, createOverlayButton, dismissOverlay } from '../../../src/ui'; export class MainStreetTurnController { @@ -80,6 +81,16 @@ export class MainStreetTurnController { // Update campaign progress (tier evaluation + persistence), // then compute newly unlocked tiers and show the overlay. + // Auto-save transcript to browser storage (fire-and-forget) + const transcript = finalizeMainStreetTranscript({ + gameResult: result.gameResult, + finalScore: result.finalScore, + }); + if (transcript) { + const transcriptStore = new TranscriptStore(); + autoSaveTranscript(transcriptStore, 'main-street', transcript, '[MainStreet]'); + } + s.updateCampaignProgress().then(() => { const tiersAfter = s.campaign ? s.campaign.unlockedTiers diff --git a/example-games/sushi-go/GameTranscript.ts b/example-games/sushi-go/GameTranscript.ts index d3412b40..7ce63a14 100644 --- a/example-games/sushi-go/GameTranscript.ts +++ b/example-games/sushi-go/GameTranscript.ts @@ -21,7 +21,7 @@ * @module */ -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; import type { SushiGoCard } from './SushiGoCards'; import type { SushiGoSession, PickAction, RoundResult } from './SushiGoGame'; diff --git a/example-games/sushi-go/scenes/SushiGoOverlayManager.ts b/example-games/sushi-go/scenes/SushiGoOverlayManager.ts index 042d1a14..e95ab1a4 100644 --- a/example-games/sushi-go/scenes/SushiGoOverlayManager.ts +++ b/example-games/sushi-go/scenes/SushiGoOverlayManager.ts @@ -15,8 +15,7 @@ import type { SushiGoSession, RoundResult } from '../SushiGoGame'; import { getWinnerIndex } from '../SushiGoGame'; import type { SoundManager, GameEventEmitter } from '../../../src/core-engine'; import { SushiGoTranscriptRecorder } from '../GameTranscript'; -import { TranscriptStore } from '../../../src/core-engine/TranscriptStore'; -import { autoSaveTranscript } from '../../../src/core-engine/autoSaveTranscript'; +import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import { SFX_KEYS } from './SushiGoConstants'; const transcriptStore = new TranscriptStore(); diff --git a/example-games/the-mind/GameTranscript.ts b/example-games/the-mind/GameTranscript.ts index 55340e80..f59d95a8 100644 --- a/example-games/the-mind/GameTranscript.ts +++ b/example-games/the-mind/GameTranscript.ts @@ -16,7 +16,7 @@ * @module */ -import { TranscriptRecorderBase } from '../../src/core-engine/TranscriptRecorder'; +import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; import type { PlayerId } from './TheMindGameState'; // --------------------------------------------------------------------------- diff --git a/src/core-engine/TranscriptRecorder.ts b/src/core-engine/TranscriptRecorder.ts index 2fc047e4..f5f69463 100644 --- a/src/core-engine/TranscriptRecorder.ts +++ b/src/core-engine/TranscriptRecorder.ts @@ -1,113 +1,10 @@ /** - * Shared transcript recorder base types for the Tableau Card Engine. + * Backward-compatibility re-export. * - * Provides a generic `BaseTranscript` interface (recommended shape for - * new games) and an abstract `TranscriptRecorderBase` class that all - * game-specific transcript recorders extend. + * The canonical location is now `src/core-engine/transcript/TranscriptRecorder`. + * New code should import from `@core-engine/transcript` or the barrel. * - * The base class eliminates the duplicated `getTranscript()` pattern - * and provides a protected `transcript` field for subclass access. - * - * Game-specific recorders provide their own: - * - `recordTurn()` / `recordAction()` / `recordMove()` methods - * - `finalize()` implementation (sets end timestamp and results) - * - Board snapshot and card snapshot types - * - * @example - * ```ts - * interface MyTranscript extends BaseTranscript { - * // game-specific extras - * } - * - * class MyRecorder extends TranscriptRecorderBase { - * constructor(session: MySession) { - * super({ - * version: 1, - * gameType: 'my-game', - * startedAt: new Date().toISOString(), - * endedAt: '', - * initialState: snapshotBoard(session), - * events: [], - * results: null, - * }); - * } - * - * finalize(): MyTranscript { - * this.transcript.endedAt = new Date().toISOString(); - * this.transcript.results = computeResults(); - * return this.getTranscript(); - * } - * } - * ``` - */ - -// ── Base transcript shape ────────────────────────────────── - -/** - * Recommended transcript shape for new games. - * - * Existing games may use slightly different field names or nesting - * (e.g. `turns` instead of `events`, or `metadata.startedAt` instead - * of flat `startedAt`). This interface captures the canonical shape - * that new games should follow. - * - * @typeParam TInitialState - Snapshot of the board before any moves - * @typeParam TEvent - A single recorded event (turn, move, action, etc.) - * @typeParam TResult - Final game/match results - */ -export interface BaseTranscript { - /** Format version for future compatibility. */ - version: number; - /** Game identifier string (e.g. 'golf', 'beleaguered-castle'). */ - gameType: string; - /** ISO 8601 timestamp when the game started. */ - startedAt: string; - /** ISO 8601 timestamp when the game ended (empty string until finalized). */ - endedAt: string; - /** Board state snapshot before any moves/actions. */ - initialState: TInitialState; - /** Ordered list of recorded events (turns, moves, actions). */ - events: TEvent[]; - /** Final results, or null while the game is in progress. */ - results: TResult | null; -} - -// ── Abstract recorder base ───────────────────────────────── - -/** - * Abstract base class for game transcript recorders. - * - * Provides the common `getTranscript()` accessor and a protected - * `transcript` field. Subclasses must call `super()` with a fully - * initialized transcript object. - * - * The class is deliberately minimal: it does not prescribe how - * events are recorded, how timestamps are managed, or how results - * are computed, since these vary across games. It extracts only - * the truly common pattern: holding a transcript object and - * exposing it via `getTranscript()`. - * - * @typeParam T - The concrete transcript type for this game + * @deprecated Use `import { TranscriptRecorderBase, BaseTranscript } from '@core-engine/transcript'` instead. */ -export abstract class TranscriptRecorderBase { - protected readonly transcript: T; - - /** - * @param transcript - A fully initialized transcript object. - * Typically has `endedAt` set to `''` and - * `results` set to `null`. - */ - constructor(transcript: T) { - this.transcript = transcript; - } - - /** - * Get the transcript in its current state (may not be finalized). - * - * This is identical across all game recorders and is the primary - * reason for the base class. - */ - getTranscript(): T { - return this.transcript; - } -} +export { TranscriptRecorderBase } from './transcript/TranscriptRecorder'; +export type { BaseTranscript } from './transcript/TranscriptRecorder'; diff --git a/src/core-engine/TranscriptStore.ts b/src/core-engine/TranscriptStore.ts index 3a510dae..0478d60d 100644 --- a/src/core-engine/TranscriptStore.ts +++ b/src/core-engine/TranscriptStore.ts @@ -1,476 +1,10 @@ /** - * TranscriptStore -- browser-based persistence for game transcripts. + * Backward-compatibility re-export. * - * Saves transcripts to IndexedDB (preferred) with a localStorage - * fallback. Maintains a rolling window of the most recent transcripts - * per game type to prevent unbounded storage growth. + * The canonical location is now `src/core-engine/transcript/TranscriptStore`. + * New code should import from `@core-engine/transcript` or the barrel. * - * This module is game-agnostic: it stores arbitrary JSON-serializable - * transcript objects keyed by game type. + * @deprecated Use `import { TranscriptStore, StoredTranscript, TranscriptStoreOptions } from '@core-engine/transcript'` instead. */ - -// ── Types ────────────────────────────────────────────────── - -/** Metadata wrapper stored alongside each transcript. */ -export interface StoredTranscript { - /** Unique ID for this stored transcript. */ - id: string; - /** Game type identifier (e.g. 'golf', 'beleaguered-castle'). */ - gameType: string; - /** ISO 8601 timestamp when the transcript was saved. */ - savedAt: string; - /** Monotonic sequence number for stable ordering (higher = newer). */ - seq: number; - /** The full transcript data. */ - transcript: T; -} - -/** Options for configuring the TranscriptStore. */ -export interface TranscriptStoreOptions { - /** Maximum number of transcripts to retain per game type. Defaults to 10. */ - maxPerGame?: number; - /** IndexedDB database name. Defaults to 'transcript-store'. */ - dbName?: string; - /** IndexedDB object store name. Defaults to 'transcripts'. */ - storeName?: string; - /** localStorage key prefix. Defaults to 'tce-transcripts'. */ - localStoragePrefix?: string; -} - -// ── Constants ────────────────────────────────────────────── - -const DEFAULT_MAX_PER_GAME = 10; -const DEFAULT_DB_NAME = 'transcript-store'; -const DEFAULT_STORE_NAME = 'transcripts'; -const DEFAULT_LS_PREFIX = 'tce-transcripts'; - -// ── Storage backend interface ────────────────────────────── - -interface StorageBackend { - save(entry: StoredTranscript): Promise; - list(gameType: string): Promise; - get(id: string): Promise; - remove(id: string): Promise; - clear(gameType?: string): Promise; - readonly name: string; -} - -// ── IndexedDB backend ────────────────────────────────────── - -class IndexedDBBackend implements StorageBackend { - readonly name = 'IndexedDB'; - private dbPromise: Promise | null = null; - - constructor( - private readonly dbName: string, - private readonly storeName: string, - ) {} - - private openDB(): Promise { - if (this.dbPromise) return this.dbPromise; - - this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1); - - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(this.storeName)) { - const store = db.createObjectStore(this.storeName, { keyPath: 'id' }); - store.createIndex('gameType', 'gameType', { unique: false }); - store.createIndex('savedAt', 'savedAt', { unique: false }); - store.createIndex('gameType_savedAt', ['gameType', 'savedAt'], { unique: false }); - } - }; - - request.onsuccess = () => resolve(request.result); - request.onerror = () => { - this.dbPromise = null; - reject(request.error); - }; - }); - - return this.dbPromise; - } - - async save(entry: StoredTranscript): Promise { - const db = await this.openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite'); - tx.objectStore(this.storeName).put(entry); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - async list(gameType: string): Promise { - const db = await this.openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readonly'); - const index = tx.objectStore(this.storeName).index('gameType'); - const request = index.getAll(gameType); - request.onsuccess = () => { - const results = request.result as StoredTranscript[]; - // Sort by seq descending (newest first); stable even when timestamps collide - results.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)); - resolve(results); - }; - request.onerror = () => reject(request.error); - }); - } - - async get(id: string): Promise { - const db = await this.openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readonly'); - const request = tx.objectStore(this.storeName).get(id); - request.onsuccess = () => resolve(request.result ?? null); - request.onerror = () => reject(request.error); - }); - } - - async remove(id: string): Promise { - const db = await this.openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite'); - tx.objectStore(this.storeName).delete(id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - async clear(gameType?: string): Promise { - const db = await this.openDB(); - if (!gameType) { - return new Promise((resolve, reject) => { - const tx = db.transaction(this.storeName, 'readwrite'); - tx.objectStore(this.storeName).clear(); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } - - // Clear only entries for a specific game type - const entries = await this.list(gameType); - const tx = db.transaction(this.storeName, 'readwrite'); - const store = tx.objectStore(this.storeName); - for (const entry of entries) { - store.delete(entry.id); - } - return new Promise((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - } -} - -// ── localStorage backend ─────────────────────────────────── - -class LocalStorageBackend implements StorageBackend { - readonly name = 'localStorage'; - - constructor(private readonly prefix: string) {} - - private indexKey(): string { - return `${this.prefix}:index`; - } - - private entryKey(id: string): string { - return `${this.prefix}:entry:${id}`; - } - - private getIndex(): string[] { - try { - const raw = localStorage.getItem(this.indexKey()); - return raw ? (JSON.parse(raw) as string[]) : []; - } catch { - return []; - } - } - - private setIndex(ids: string[]): void { - localStorage.setItem(this.indexKey(), JSON.stringify(ids)); - } - - async save(entry: StoredTranscript): Promise { - const data = JSON.stringify(entry); - const estimatedSize = data.length * 2; // rough UTF-16 size - if (estimatedSize > 1_000_000) { - console.warn( - `[TranscriptStore] Large transcript (${Math.round(estimatedSize / 1024)}KB) may exceed localStorage limits`, - ); - } - - try { - localStorage.setItem(this.entryKey(entry.id), data); - const index = this.getIndex(); - if (!index.includes(entry.id)) { - index.push(entry.id); - this.setIndex(index); - } - } catch (e) { - if (e instanceof DOMException && e.name === 'QuotaExceededError') { - console.error('[TranscriptStore] localStorage quota exceeded. Cannot save transcript.'); - } - throw e; - } - } - - async list(gameType: string): Promise { - const index = this.getIndex(); - const results: StoredTranscript[] = []; - - for (const id of index) { - const entry = await this.get(id); - if (entry && entry.gameType === gameType) { - results.push(entry); - } - } - - // Sort by seq descending (newest first); stable even when timestamps collide - results.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)); - return results; - } - - async get(id: string): Promise { - try { - const raw = localStorage.getItem(this.entryKey(id)); - return raw ? (JSON.parse(raw) as StoredTranscript) : null; - } catch { - return null; - } - } - - async remove(id: string): Promise { - localStorage.removeItem(this.entryKey(id)); - const index = this.getIndex().filter((i) => i !== id); - this.setIndex(index); - } - - async clear(gameType?: string): Promise { - if (!gameType) { - // Remove all entries - const index = this.getIndex(); - for (const id of index) { - localStorage.removeItem(this.entryKey(id)); - } - localStorage.removeItem(this.indexKey()); - return; - } - - // Remove entries for a specific game type - const entries = await this.list(gameType); - for (const entry of entries) { - await this.remove(entry.id); - } - } -} - -// ── TranscriptStore ──────────────────────────────────────── - -/** - * Browser-based transcript persistence with rolling window eviction. - * - * Uses IndexedDB when available, falls back to localStorage. - * Maintains at most `maxPerGame` transcripts per game type, - * evicting the oldest when the limit is exceeded. - * - * Usage: - * const store = new TranscriptStore(); - * await store.save('golf', transcript); - * const recent = await store.list('golf'); - */ -export class TranscriptStore { - private backend: StorageBackend | null = null; - private readonly maxPerGame: number; - private readonly dbName: string; - private readonly storeName: string; - private readonly localStoragePrefix: string; - private initPromise: Promise | null = null; - private seqCounter: number = 0; - - constructor(options: TranscriptStoreOptions = {}) { - this.maxPerGame = options.maxPerGame ?? DEFAULT_MAX_PER_GAME; - this.dbName = options.dbName ?? DEFAULT_DB_NAME; - this.storeName = options.storeName ?? DEFAULT_STORE_NAME; - this.localStoragePrefix = options.localStoragePrefix ?? DEFAULT_LS_PREFIX; - } - - /** - * Initialize the store backend. Called automatically on first operation. - * Safe to call multiple times (idempotent). - */ - private init(): Promise { - if (this.initPromise) return this.initPromise; - - this.initPromise = (async () => { - // Try IndexedDB first - if (typeof indexedDB !== 'undefined') { - try { - const backend = new IndexedDBBackend(this.dbName, this.storeName); - // Probe: try opening the database to verify it works - await backend.list('__probe__'); - this.backend = backend; - return; - } catch (e) { - console.warn( - '[TranscriptStore] IndexedDB unavailable, falling back to localStorage:', - e, - ); - } - } - - // Try localStorage - if (typeof localStorage !== 'undefined') { - try { - // Probe: try a write/read/delete cycle - const probeKey = `${this.localStoragePrefix}:__probe__`; - localStorage.setItem(probeKey, '1'); - localStorage.removeItem(probeKey); - this.backend = new LocalStorageBackend(this.localStoragePrefix); - console.warn( - '[TranscriptStore] Using localStorage fallback. Large transcripts may exceed storage limits.', - ); - return; - } catch (e) { - console.warn('[TranscriptStore] localStorage unavailable:', e); - } - } - - // Neither available - console.warn( - '[TranscriptStore] No storage backend available. Transcripts will not be persisted.', - ); - })(); - - return this.initPromise; - } - - /** Generate a unique ID for a stored transcript. */ - private generateId(gameType: string): string { - const timestamp = Date.now(); - const random = Math.random().toString(36).slice(2, 8); - return `${gameType}-${timestamp}-${random}`; - } - - /** - * Save a transcript, enforcing the rolling window limit. - * - * After persisting to the browser storage backend, fires a non-blocking - * POST to `/api/transcripts` so the Vite dev-server plugin can write - * the transcript to disk (when running in dev mode). - * - * @param gameType - Game identifier (e.g. 'golf') - * @param transcript - The transcript data to store - * @returns The stored transcript wrapper, or null if storage is unavailable - */ - async save(gameType: string, transcript: T): Promise | null> { - await this.init(); - if (!this.backend) return null; - - const entry: StoredTranscript = { - id: this.generateId(gameType), - gameType, - savedAt: new Date().toISOString(), - seq: this.seqCounter++, - transcript, - }; - - await this.backend.save(entry as StoredTranscript); - - // Enforce rolling window: evict oldest if over limit - await this.evict(gameType); - - // Fire-and-forget POST to dev server plugin for disk persistence. - // This is intentionally not awaited so it never blocks the return value. - this.postToDisk(gameType, transcript); - - return entry; - } - - /** - * List all stored transcripts for a game type, newest first. - */ - async list(gameType: string): Promise[]> { - await this.init(); - if (!this.backend) return []; - return (await this.backend.list(gameType)) as StoredTranscript[]; - } - - /** - * Retrieve a specific transcript by ID. - */ - async get(id: string): Promise | null> { - await this.init(); - if (!this.backend) return null; - return (await this.backend.get(id)) as StoredTranscript | null; - } - - /** - * Remove a specific transcript by ID. - */ - async remove(id: string): Promise { - await this.init(); - if (!this.backend) return; - await this.backend.remove(id); - } - - /** - * Clear all transcripts, optionally for a specific game type. - */ - async clear(gameType?: string): Promise { - await this.init(); - if (!this.backend) return; - await this.backend.clear(gameType); - } - - /** - * Get the name of the active storage backend. - * Returns null if no backend is available. - */ - async getBackendName(): Promise { - await this.init(); - return this.backend?.name ?? null; - } - - /** - * Fire-and-forget POST to the Vite dev-server transcript plugin. - * - * Sends { gameType, transcript } to /api/transcripts so the plugin - * can write the transcript to disk during development. Errors are - * logged as warnings but never propagate -- this must never break - * the save() return path. - */ - private postToDisk(gameType: string, transcript: T): void { - if (typeof fetch === 'undefined') return; - - fetch('/api/transcripts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ gameType, transcript }), - }) - .then((res) => { - if (!res.ok) { - console.warn( - `[TranscriptStore] Disk persistence POST returned HTTP ${res.status}`, - ); - } - }) - .catch((err) => { - console.warn('[TranscriptStore] Disk persistence POST failed:', err); - }); - } - - /** - * Evict oldest transcripts if the count exceeds maxPerGame. - */ - private async evict(gameType: string): Promise { - if (!this.backend) return; - - const entries = await this.backend.list(gameType); - // entries are sorted newest-first; remove from the end (oldest) - while (entries.length > this.maxPerGame) { - const oldest = entries.pop()!; - await this.backend.remove(oldest.id); - } - } -} +export { TranscriptStore } from './transcript/TranscriptStore'; +export type { StoredTranscript, TranscriptStoreOptions } from './transcript/TranscriptStore'; diff --git a/src/core-engine/TranscriptTypes.ts b/src/core-engine/TranscriptTypes.ts index 6ec1dfc0..a441ae99 100644 --- a/src/core-engine/TranscriptTypes.ts +++ b/src/core-engine/TranscriptTypes.ts @@ -1,41 +1,10 @@ /** - * Shared transcript snapshot types for the Tableau Card Engine. + * Backward-compatibility re-export. * - * Provides the canonical CardSnapshot interface and snapshotCard() - * helper used by all game transcript modules. Game-specific - * snapshot types (board layouts, scoring, etc.) remain in each - * example game's own GameTranscript module. - */ - -import type { Card, Rank, Suit } from '../card-system/Card'; - -// ── Snapshot types ────────────────────────────────────────── - -/** - * Serializable card snapshot (no methods). - * - * Captures rank, suit, and face-up state so that transcript - * consumers can reconstruct visual card state without needing - * the full Card object. - */ -export interface CardSnapshot { - rank: Rank; - suit: Suit; - faceUp: boolean; -} - -// ── Helpers ───────────────────────────────────────────────── - -/** - * Create a serializable snapshot of a card. + * The canonical location is now `src/core-engine/transcript/TranscriptTypes`. + * New code should import from `@core-engine/transcript` or the barrel. * - * Always includes `faceUp` so that replay tools and transcript - * consumers have complete visibility information. + * @deprecated Use `import { CardSnapshot, snapshotCard } from '@core-engine/transcript'` instead. */ -export function snapshotCard(card: Card): CardSnapshot { - return { - rank: card.rank, - suit: card.suit, - faceUp: card.faceUp, - }; -} +export { snapshotCard } from './transcript/TranscriptTypes'; +export type { CardSnapshot } from './transcript/TranscriptTypes'; diff --git a/src/core-engine/autoSaveTranscript.ts b/src/core-engine/autoSaveTranscript.ts index ba3c0034..6ecbf215 100644 --- a/src/core-engine/autoSaveTranscript.ts +++ b/src/core-engine/autoSaveTranscript.ts @@ -1,52 +1,9 @@ /** - * autoSaveTranscript – fire-and-forget transcript persistence helper. + * Backward-compatibility re-export. * - * Saves a finalized transcript to the {@link TranscriptStore} and logs - * the outcome. All four example games that persist transcripts used an - * identical copy of this pattern; this shared helper eliminates that - * duplication. + * The canonical location is now `src/core-engine/transcript/autoSaveTranscript`. + * New code should import from `@core-engine/transcript` or the barrel. * - * @module core-engine/autoSaveTranscript + * @deprecated Use `import { autoSaveTranscript } from '@core-engine/transcript'` instead. */ - -import { TranscriptStore } from './TranscriptStore'; - -/** - * Persist a transcript to browser storage via the given - * {@link TranscriptStore}, logging success or failure. - * - * The call is fire-and-forget: the returned promise resolves once - * the save completes (or fails), but callers typically ignore it. - * - * @typeParam T - The concrete transcript type for this game. - * @param store - A pre-existing TranscriptStore instance. - * @param gameType - Game identifier string (e.g. `'golf'`, `'sushi-go'`). - * @param transcript - The finalized transcript object to save. - * @param logPrefix - Optional prefix for console messages. - * Defaults to `[]`. - */ -export function autoSaveTranscript( - store: TranscriptStore, - gameType: string, - transcript: T, - logPrefix?: string, -): void { - const prefix = logPrefix ?? `[${gameType}]`; - - store.save(gameType, transcript).then( - (stored) => { - if (stored) { - console.info( - `${prefix} Transcript saved (${stored.id}) via ${stored.gameType}`, - ); - } else { - console.warn( - `${prefix} Transcript not saved -- no storage backend available`, - ); - } - }, - (err) => { - console.error(`${prefix} Failed to auto-save transcript:`, err); - }, - ); -} +export { autoSaveTranscript } from './transcript/autoSaveTranscript'; diff --git a/src/core-engine/index.ts b/src/core-engine/index.ts index b869cf80..cf7c1525 100644 --- a/src/core-engine/index.ts +++ b/src/core-engine/index.ts @@ -31,10 +31,18 @@ export { export type { Command } from './UndoRedoManager'; export { CompoundCommand, UndoRedoManager } from './UndoRedoManager'; -// Transcript persistence +// Transcript persistence (consolidated module — CG-0MP12WI75001L9P4) export type { StoredTranscript, TranscriptStoreOptions } from './TranscriptStore'; export { TranscriptStore } from './TranscriptStore'; +// Consolidated transcript sub-module barrel (canonical location) +export { + TranscriptRecorderBase, + autoSaveTranscript, + snapshotCard, +} from './transcript'; +export type { BaseTranscript, CardSnapshot } from './transcript'; + // Save/load persistence export type { SaveDomain, @@ -78,14 +86,6 @@ export type { } from './GameEventEmitter'; export { GameEventEmitter } from './GameEventEmitter'; -// Shared transcript snapshot types -export type { CardSnapshot } from './TranscriptTypes'; -export { snapshotCard } from './TranscriptTypes'; - -// Shared transcript recorder base -export type { BaseTranscript } from './TranscriptRecorder'; -export { TranscriptRecorderBase } from './TranscriptRecorder'; - // Phaser event bridge export type { PhaserLikeEventEmitter } from './PhaserEventBridge'; export { PhaserEventBridge } from './PhaserEventBridge'; @@ -108,8 +108,7 @@ export { createTfPlayer } from './tfAdapter'; // Seeded RNG factory export { createSeededRng } from './SeededRng'; -// Transcript auto-save helper -export { autoSaveTranscript } from './autoSaveTranscript'; +// Transcript auto-save helper — re-exported from consolidated transcript module // Challenge system generic API (CG-0MMJ8S9850MV4L0A) export type { diff --git a/src/core-engine/transcript/TranscriptRecorder.ts b/src/core-engine/transcript/TranscriptRecorder.ts new file mode 100644 index 00000000..2fc047e4 --- /dev/null +++ b/src/core-engine/transcript/TranscriptRecorder.ts @@ -0,0 +1,113 @@ +/** + * Shared transcript recorder base types for the Tableau Card Engine. + * + * Provides a generic `BaseTranscript` interface (recommended shape for + * new games) and an abstract `TranscriptRecorderBase` class that all + * game-specific transcript recorders extend. + * + * The base class eliminates the duplicated `getTranscript()` pattern + * and provides a protected `transcript` field for subclass access. + * + * Game-specific recorders provide their own: + * - `recordTurn()` / `recordAction()` / `recordMove()` methods + * - `finalize()` implementation (sets end timestamp and results) + * - Board snapshot and card snapshot types + * + * @example + * ```ts + * interface MyTranscript extends BaseTranscript { + * // game-specific extras + * } + * + * class MyRecorder extends TranscriptRecorderBase { + * constructor(session: MySession) { + * super({ + * version: 1, + * gameType: 'my-game', + * startedAt: new Date().toISOString(), + * endedAt: '', + * initialState: snapshotBoard(session), + * events: [], + * results: null, + * }); + * } + * + * finalize(): MyTranscript { + * this.transcript.endedAt = new Date().toISOString(); + * this.transcript.results = computeResults(); + * return this.getTranscript(); + * } + * } + * ``` + */ + +// ── Base transcript shape ────────────────────────────────── + +/** + * Recommended transcript shape for new games. + * + * Existing games may use slightly different field names or nesting + * (e.g. `turns` instead of `events`, or `metadata.startedAt` instead + * of flat `startedAt`). This interface captures the canonical shape + * that new games should follow. + * + * @typeParam TInitialState - Snapshot of the board before any moves + * @typeParam TEvent - A single recorded event (turn, move, action, etc.) + * @typeParam TResult - Final game/match results + */ +export interface BaseTranscript { + /** Format version for future compatibility. */ + version: number; + /** Game identifier string (e.g. 'golf', 'beleaguered-castle'). */ + gameType: string; + /** ISO 8601 timestamp when the game started. */ + startedAt: string; + /** ISO 8601 timestamp when the game ended (empty string until finalized). */ + endedAt: string; + /** Board state snapshot before any moves/actions. */ + initialState: TInitialState; + /** Ordered list of recorded events (turns, moves, actions). */ + events: TEvent[]; + /** Final results, or null while the game is in progress. */ + results: TResult | null; +} + +// ── Abstract recorder base ───────────────────────────────── + +/** + * Abstract base class for game transcript recorders. + * + * Provides the common `getTranscript()` accessor and a protected + * `transcript` field. Subclasses must call `super()` with a fully + * initialized transcript object. + * + * The class is deliberately minimal: it does not prescribe how + * events are recorded, how timestamps are managed, or how results + * are computed, since these vary across games. It extracts only + * the truly common pattern: holding a transcript object and + * exposing it via `getTranscript()`. + * + * @typeParam T - The concrete transcript type for this game + */ +export abstract class TranscriptRecorderBase { + protected readonly transcript: T; + + /** + * @param transcript - A fully initialized transcript object. + * Typically has `endedAt` set to `''` and + * `results` set to `null`. + */ + constructor(transcript: T) { + this.transcript = transcript; + } + + /** + * Get the transcript in its current state (may not be finalized). + * + * This is identical across all game recorders and is the primary + * reason for the base class. + */ + getTranscript(): T { + return this.transcript; + } +} diff --git a/src/core-engine/transcript/TranscriptStore.ts b/src/core-engine/transcript/TranscriptStore.ts new file mode 100644 index 00000000..3a510dae --- /dev/null +++ b/src/core-engine/transcript/TranscriptStore.ts @@ -0,0 +1,476 @@ +/** + * TranscriptStore -- browser-based persistence for game transcripts. + * + * Saves transcripts to IndexedDB (preferred) with a localStorage + * fallback. Maintains a rolling window of the most recent transcripts + * per game type to prevent unbounded storage growth. + * + * This module is game-agnostic: it stores arbitrary JSON-serializable + * transcript objects keyed by game type. + */ + +// ── Types ────────────────────────────────────────────────── + +/** Metadata wrapper stored alongside each transcript. */ +export interface StoredTranscript { + /** Unique ID for this stored transcript. */ + id: string; + /** Game type identifier (e.g. 'golf', 'beleaguered-castle'). */ + gameType: string; + /** ISO 8601 timestamp when the transcript was saved. */ + savedAt: string; + /** Monotonic sequence number for stable ordering (higher = newer). */ + seq: number; + /** The full transcript data. */ + transcript: T; +} + +/** Options for configuring the TranscriptStore. */ +export interface TranscriptStoreOptions { + /** Maximum number of transcripts to retain per game type. Defaults to 10. */ + maxPerGame?: number; + /** IndexedDB database name. Defaults to 'transcript-store'. */ + dbName?: string; + /** IndexedDB object store name. Defaults to 'transcripts'. */ + storeName?: string; + /** localStorage key prefix. Defaults to 'tce-transcripts'. */ + localStoragePrefix?: string; +} + +// ── Constants ────────────────────────────────────────────── + +const DEFAULT_MAX_PER_GAME = 10; +const DEFAULT_DB_NAME = 'transcript-store'; +const DEFAULT_STORE_NAME = 'transcripts'; +const DEFAULT_LS_PREFIX = 'tce-transcripts'; + +// ── Storage backend interface ────────────────────────────── + +interface StorageBackend { + save(entry: StoredTranscript): Promise; + list(gameType: string): Promise; + get(id: string): Promise; + remove(id: string): Promise; + clear(gameType?: string): Promise; + readonly name: string; +} + +// ── IndexedDB backend ────────────────────────────────────── + +class IndexedDBBackend implements StorageBackend { + readonly name = 'IndexedDB'; + private dbPromise: Promise | null = null; + + constructor( + private readonly dbName: string, + private readonly storeName: string, + ) {} + + private openDB(): Promise { + if (this.dbPromise) return this.dbPromise; + + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, 1); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.storeName)) { + const store = db.createObjectStore(this.storeName, { keyPath: 'id' }); + store.createIndex('gameType', 'gameType', { unique: false }); + store.createIndex('savedAt', 'savedAt', { unique: false }); + store.createIndex('gameType_savedAt', ['gameType', 'savedAt'], { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + this.dbPromise = null; + reject(request.error); + }; + }); + + return this.dbPromise; + } + + async save(entry: StoredTranscript): Promise { + const db = await this.openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + tx.objectStore(this.storeName).put(entry); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async list(gameType: string): Promise { + const db = await this.openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readonly'); + const index = tx.objectStore(this.storeName).index('gameType'); + const request = index.getAll(gameType); + request.onsuccess = () => { + const results = request.result as StoredTranscript[]; + // Sort by seq descending (newest first); stable even when timestamps collide + results.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)); + resolve(results); + }; + request.onerror = () => reject(request.error); + }); + } + + async get(id: string): Promise { + const db = await this.openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readonly'); + const request = tx.objectStore(this.storeName).get(id); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); + } + + async remove(id: string): Promise { + const db = await this.openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + tx.objectStore(this.storeName).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async clear(gameType?: string): Promise { + const db = await this.openDB(); + if (!gameType) { + return new Promise((resolve, reject) => { + const tx = db.transaction(this.storeName, 'readwrite'); + tx.objectStore(this.storeName).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + // Clear only entries for a specific game type + const entries = await this.list(gameType); + const tx = db.transaction(this.storeName, 'readwrite'); + const store = tx.objectStore(this.storeName); + for (const entry of entries) { + store.delete(entry.id); + } + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } +} + +// ── localStorage backend ─────────────────────────────────── + +class LocalStorageBackend implements StorageBackend { + readonly name = 'localStorage'; + + constructor(private readonly prefix: string) {} + + private indexKey(): string { + return `${this.prefix}:index`; + } + + private entryKey(id: string): string { + return `${this.prefix}:entry:${id}`; + } + + private getIndex(): string[] { + try { + const raw = localStorage.getItem(this.indexKey()); + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } + } + + private setIndex(ids: string[]): void { + localStorage.setItem(this.indexKey(), JSON.stringify(ids)); + } + + async save(entry: StoredTranscript): Promise { + const data = JSON.stringify(entry); + const estimatedSize = data.length * 2; // rough UTF-16 size + if (estimatedSize > 1_000_000) { + console.warn( + `[TranscriptStore] Large transcript (${Math.round(estimatedSize / 1024)}KB) may exceed localStorage limits`, + ); + } + + try { + localStorage.setItem(this.entryKey(entry.id), data); + const index = this.getIndex(); + if (!index.includes(entry.id)) { + index.push(entry.id); + this.setIndex(index); + } + } catch (e) { + if (e instanceof DOMException && e.name === 'QuotaExceededError') { + console.error('[TranscriptStore] localStorage quota exceeded. Cannot save transcript.'); + } + throw e; + } + } + + async list(gameType: string): Promise { + const index = this.getIndex(); + const results: StoredTranscript[] = []; + + for (const id of index) { + const entry = await this.get(id); + if (entry && entry.gameType === gameType) { + results.push(entry); + } + } + + // Sort by seq descending (newest first); stable even when timestamps collide + results.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)); + return results; + } + + async get(id: string): Promise { + try { + const raw = localStorage.getItem(this.entryKey(id)); + return raw ? (JSON.parse(raw) as StoredTranscript) : null; + } catch { + return null; + } + } + + async remove(id: string): Promise { + localStorage.removeItem(this.entryKey(id)); + const index = this.getIndex().filter((i) => i !== id); + this.setIndex(index); + } + + async clear(gameType?: string): Promise { + if (!gameType) { + // Remove all entries + const index = this.getIndex(); + for (const id of index) { + localStorage.removeItem(this.entryKey(id)); + } + localStorage.removeItem(this.indexKey()); + return; + } + + // Remove entries for a specific game type + const entries = await this.list(gameType); + for (const entry of entries) { + await this.remove(entry.id); + } + } +} + +// ── TranscriptStore ──────────────────────────────────────── + +/** + * Browser-based transcript persistence with rolling window eviction. + * + * Uses IndexedDB when available, falls back to localStorage. + * Maintains at most `maxPerGame` transcripts per game type, + * evicting the oldest when the limit is exceeded. + * + * Usage: + * const store = new TranscriptStore(); + * await store.save('golf', transcript); + * const recent = await store.list('golf'); + */ +export class TranscriptStore { + private backend: StorageBackend | null = null; + private readonly maxPerGame: number; + private readonly dbName: string; + private readonly storeName: string; + private readonly localStoragePrefix: string; + private initPromise: Promise | null = null; + private seqCounter: number = 0; + + constructor(options: TranscriptStoreOptions = {}) { + this.maxPerGame = options.maxPerGame ?? DEFAULT_MAX_PER_GAME; + this.dbName = options.dbName ?? DEFAULT_DB_NAME; + this.storeName = options.storeName ?? DEFAULT_STORE_NAME; + this.localStoragePrefix = options.localStoragePrefix ?? DEFAULT_LS_PREFIX; + } + + /** + * Initialize the store backend. Called automatically on first operation. + * Safe to call multiple times (idempotent). + */ + private init(): Promise { + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + // Try IndexedDB first + if (typeof indexedDB !== 'undefined') { + try { + const backend = new IndexedDBBackend(this.dbName, this.storeName); + // Probe: try opening the database to verify it works + await backend.list('__probe__'); + this.backend = backend; + return; + } catch (e) { + console.warn( + '[TranscriptStore] IndexedDB unavailable, falling back to localStorage:', + e, + ); + } + } + + // Try localStorage + if (typeof localStorage !== 'undefined') { + try { + // Probe: try a write/read/delete cycle + const probeKey = `${this.localStoragePrefix}:__probe__`; + localStorage.setItem(probeKey, '1'); + localStorage.removeItem(probeKey); + this.backend = new LocalStorageBackend(this.localStoragePrefix); + console.warn( + '[TranscriptStore] Using localStorage fallback. Large transcripts may exceed storage limits.', + ); + return; + } catch (e) { + console.warn('[TranscriptStore] localStorage unavailable:', e); + } + } + + // Neither available + console.warn( + '[TranscriptStore] No storage backend available. Transcripts will not be persisted.', + ); + })(); + + return this.initPromise; + } + + /** Generate a unique ID for a stored transcript. */ + private generateId(gameType: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).slice(2, 8); + return `${gameType}-${timestamp}-${random}`; + } + + /** + * Save a transcript, enforcing the rolling window limit. + * + * After persisting to the browser storage backend, fires a non-blocking + * POST to `/api/transcripts` so the Vite dev-server plugin can write + * the transcript to disk (when running in dev mode). + * + * @param gameType - Game identifier (e.g. 'golf') + * @param transcript - The transcript data to store + * @returns The stored transcript wrapper, or null if storage is unavailable + */ + async save(gameType: string, transcript: T): Promise | null> { + await this.init(); + if (!this.backend) return null; + + const entry: StoredTranscript = { + id: this.generateId(gameType), + gameType, + savedAt: new Date().toISOString(), + seq: this.seqCounter++, + transcript, + }; + + await this.backend.save(entry as StoredTranscript); + + // Enforce rolling window: evict oldest if over limit + await this.evict(gameType); + + // Fire-and-forget POST to dev server plugin for disk persistence. + // This is intentionally not awaited so it never blocks the return value. + this.postToDisk(gameType, transcript); + + return entry; + } + + /** + * List all stored transcripts for a game type, newest first. + */ + async list(gameType: string): Promise[]> { + await this.init(); + if (!this.backend) return []; + return (await this.backend.list(gameType)) as StoredTranscript[]; + } + + /** + * Retrieve a specific transcript by ID. + */ + async get(id: string): Promise | null> { + await this.init(); + if (!this.backend) return null; + return (await this.backend.get(id)) as StoredTranscript | null; + } + + /** + * Remove a specific transcript by ID. + */ + async remove(id: string): Promise { + await this.init(); + if (!this.backend) return; + await this.backend.remove(id); + } + + /** + * Clear all transcripts, optionally for a specific game type. + */ + async clear(gameType?: string): Promise { + await this.init(); + if (!this.backend) return; + await this.backend.clear(gameType); + } + + /** + * Get the name of the active storage backend. + * Returns null if no backend is available. + */ + async getBackendName(): Promise { + await this.init(); + return this.backend?.name ?? null; + } + + /** + * Fire-and-forget POST to the Vite dev-server transcript plugin. + * + * Sends { gameType, transcript } to /api/transcripts so the plugin + * can write the transcript to disk during development. Errors are + * logged as warnings but never propagate -- this must never break + * the save() return path. + */ + private postToDisk(gameType: string, transcript: T): void { + if (typeof fetch === 'undefined') return; + + fetch('/api/transcripts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gameType, transcript }), + }) + .then((res) => { + if (!res.ok) { + console.warn( + `[TranscriptStore] Disk persistence POST returned HTTP ${res.status}`, + ); + } + }) + .catch((err) => { + console.warn('[TranscriptStore] Disk persistence POST failed:', err); + }); + } + + /** + * Evict oldest transcripts if the count exceeds maxPerGame. + */ + private async evict(gameType: string): Promise { + if (!this.backend) return; + + const entries = await this.backend.list(gameType); + // entries are sorted newest-first; remove from the end (oldest) + while (entries.length > this.maxPerGame) { + const oldest = entries.pop()!; + await this.backend.remove(oldest.id); + } + } +} diff --git a/src/core-engine/transcript/TranscriptTypes.ts b/src/core-engine/transcript/TranscriptTypes.ts new file mode 100644 index 00000000..648ea7a1 --- /dev/null +++ b/src/core-engine/transcript/TranscriptTypes.ts @@ -0,0 +1,41 @@ +/** + * Shared transcript snapshot types for the Tableau Card Engine. + * + * Provides the canonical CardSnapshot interface and snapshotCard() + * helper used by all game transcript modules. Game-specific + * snapshot types (board layouts, scoring, etc.) remain in each + * example game's own GameTranscript module. + */ + +import type { Card, Rank, Suit } from '../../card-system/Card'; + +// ── Snapshot types ────────────────────────────────────────── + +/** + * Serializable card snapshot (no methods). + * + * Captures rank, suit, and face-up state so that transcript + * consumers can reconstruct visual card state without needing + * the full Card object. + */ +export interface CardSnapshot { + rank: Rank; + suit: Suit; + faceUp: boolean; +} + +// ── Helpers ───────────────────────────────────────────────── + +/** + * Create a serializable snapshot of a card. + * + * Always includes `faceUp` so that replay tools and transcript + * consumers have complete visibility information. + */ +export function snapshotCard(card: Card): CardSnapshot { + return { + rank: card.rank, + suit: card.suit, + faceUp: card.faceUp, + }; +} diff --git a/src/core-engine/transcript/autoSaveTranscript.ts b/src/core-engine/transcript/autoSaveTranscript.ts new file mode 100644 index 00000000..7a540c1f --- /dev/null +++ b/src/core-engine/transcript/autoSaveTranscript.ts @@ -0,0 +1,52 @@ +/** + * autoSaveTranscript – fire-and-forget transcript persistence helper. + * + * Saves a finalized transcript to the {@link TranscriptStore} and logs + * the outcome. All four example games that persist transcripts used an + * identical copy of this pattern; this shared helper eliminates that + * duplication. + * + * @module core-engine/transcript/autoSaveTranscript + */ + +import { TranscriptStore } from './TranscriptStore'; + +/** + * Persist a transcript to browser storage via the given + * {@link TranscriptStore}, logging success or failure. + * + * The call is fire-and-forget: the returned promise resolves once + * the save completes (or fails), but callers typically ignore it. + * + * @typeParam T - The concrete transcript type for this game. + * @param store - A pre-existing TranscriptStore instance. + * @param gameType - Game identifier string (e.g. `'golf'`, `'sushi-go'`). + * @param transcript - The finalized transcript object to save. + * @param logPrefix - Optional prefix for console messages. + * Defaults to `[]`. + */ +export function autoSaveTranscript( + store: TranscriptStore, + gameType: string, + transcript: T, + logPrefix?: string, +): void { + const prefix = logPrefix ?? `[${gameType}]`; + + store.save(gameType, transcript).then( + (stored) => { + if (stored) { + console.info( + `${prefix} Transcript saved (${stored.id}) via ${stored.gameType}`, + ); + } else { + console.warn( + `${prefix} Transcript not saved -- no storage backend available`, + ); + } + }, + (err) => { + console.error(`${prefix} Failed to auto-save transcript:`, err); + }, + ); +} diff --git a/src/core-engine/transcript/index.ts b/src/core-engine/transcript/index.ts new file mode 100644 index 00000000..90508adf --- /dev/null +++ b/src/core-engine/transcript/index.ts @@ -0,0 +1,70 @@ +/** + * Transcript module – consolidated transcript recording, storage, and + * autosave for the Tableau Card Engine. + * + * This module groups all transcript-related functionality in a single + * sub-package: + * + * - `TranscriptRecorderBase` / `BaseTranscript` – abstract base class + * and recommended transcript shape for game-specific recorders. + * - `TranscriptStore` – browser-based persistence (IndexedDB with + * localStorage fallback) with rolling window eviction. + * - `autoSaveTranscript()` – fire-and-forget helper to persist a + * finalized transcript. + * - `CardSnapshot` / `snapshotCard()` – serialisable card snapshot type + * and helper used by game transcript modules. + * + * ## Migration Guide (CG-0MP12WI75001L9P4) + * + * ### Before (old import paths) + * + * ```ts + * import { TranscriptRecorderBase, type BaseTranscript } from '../../src/core-engine/TranscriptRecorder'; + * import { TranscriptStore, type StoredTranscript } from '../../src/core-engine/TranscriptStore'; + * import { autoSaveTranscript } from '../../src/core-engine/autoSaveTranscript'; + * import { CardSnapshot, snapshotCard } from '../../src/core-engine/TranscriptTypes'; + * ``` + * + * ### After (new consolidated import) + * + * ```ts + * import { + * TranscriptRecorderBase, + * BaseTranscript, + * TranscriptStore, + * StoredTranscript, + * TranscriptStoreOptions, + * autoSaveTranscript, + * CardSnapshot, + * snapshotCard, + * } from '../../src/core-engine/transcript'; + * ``` + * + * ### Backward compatibility + * + * The legacy top-level files (`src/core-engine/TranscriptRecorder.ts`, + * `src/core-engine/TranscriptStore.ts`, `src/core-engine/autoSaveTranscript.ts`, + * `src/core-engine/TranscriptTypes.ts`) continue to re-export everything + * from `src/core-engine/transcript/`. Existing imports will continue to + * work without changes. + * + * New code should import from `src/core-engine/transcript` (or + * `@core-engine/transcript` via the path alias). + * + * @packageDocumentation + */ + +// Transcript recorder base +export type { BaseTranscript } from './TranscriptRecorder'; +export { TranscriptRecorderBase } from './TranscriptRecorder'; + +// Transcript storage +export type { StoredTranscript, TranscriptStoreOptions } from './TranscriptStore'; +export { TranscriptStore } from './TranscriptStore'; + +// Auto-save helper +export { autoSaveTranscript } from './autoSaveTranscript'; + +// Card snapshot types +export type { CardSnapshot } from './TranscriptTypes'; +export { snapshotCard } from './TranscriptTypes'; diff --git a/tests/main-street/transcript-autosave.integration.test.ts b/tests/main-street/transcript-autosave.integration.test.ts new file mode 100644 index 00000000..efb36e58 --- /dev/null +++ b/tests/main-street/transcript-autosave.integration.test.ts @@ -0,0 +1,241 @@ +/** + * Integration test for Main Street transcript autosave and save/load + * using the consolidated `src/core-engine/transcript` module. + * + * Exercises: + * - TranscriptRecorderBase via MainStreetTranscriptRecorder + * - autoSaveTranscript helper + * - TranscriptStore persistence + * - SaveLoadStore checkpoint save/load round-trip + * + * Satisfies: CG-0MP12WI75001L9P4 AC#2 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + TranscriptStore, + autoSaveTranscript, + TranscriptRecorderBase, +} from '../../src/core-engine/transcript'; +import { SaveLoadStore } from '../../src/core-engine'; +import { + setupMainStreetGame, + serializeMainStreetState, +} from '../../example-games/main-street/MainStreetState'; +import { + executeDayStart, + executeAction, + processEndOfTurn, +} from '../../example-games/main-street/MainStreetEngine'; +import { + MainStreetTranscriptRecorder, + finalizeMainStreetTranscript, + setMainStreetRecorder, + recordMainStreetEvent, +} from '../../example-games/main-street/MainStreetTranscript'; +import { + saveTurnStartCheckpoint, + loadTurnStartCheckpoint, +} from '../../example-games/main-street/MainStreetSaveLoad'; + +// ── Test helpers ──────────────────────────────────────────── + +function createLocalStorageMock(): Storage { + const data = new Map(); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value); }, + removeItem: (key: string) => { data.delete(key); }, + clear: () => data.clear(), + get length() { return data.size; }, + key: (index: number) => [...data.keys()][index] ?? null, + }; +} + +/** Run a few turns of Main Street with deterministic actions. */ +function playTurns(state: ReturnType, turns: number): void { + for (let t = 0; t < turns; t++) { + if (state.gameResult !== 'playing') break; + executeDayStart(state); + // Try to buy first affordable business + const affordable = state.market.business.filter( + (c) => c.cost <= state.resourceBank.coins, + ); + const emptyIdx = state.streetGrid.findIndex((b) => b === null); + if (affordable.length > 0 && emptyIdx >= 0) { + const card = affordable[0]; + try { + executeAction(state, { type: 'buy-business', cardId: card.id, slotIndex: emptyIdx }); + recordMainStreetEvent({ + type: 'action', + turn: state.turn, + action: { type: 'buy-business', cardId: card.id }, + description: `Bought ${card.id}`, + }); + } catch (_) { /* skip illegal */ } + } + processEndOfTurn(state); + recordMainStreetEvent({ type: 'turn-end', turn: state.turn }); + } +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('Main Street transcript autosave integration (CG-0MP12WI75001L9P4)', () => { + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', createLocalStorageMock()); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 })); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + // Reset global recorder + setMainStreetRecorder(null); + }); + + it('records events and finalizes transcript correctly', () => { + const state = setupMainStreetGame({ seed: 'transcript-test-1' }); + const initialSnapshot = serializeMainStreetState(state); + const recorder = new MainStreetTranscriptRecorder(initialSnapshot); + setMainStreetRecorder(recorder); + + playTurns(state, 2); + + const result = { gameResult: state.gameResult, finalScore: 0 }; + const transcript = finalizeMainStreetTranscript(result); + + expect(transcript).not.toBeNull(); + expect(transcript!.gameType).toBe('main-street'); + expect(transcript!.endedAt).not.toBe(''); + expect(transcript!.results).toEqual(result); + expect(transcript!.events.length).toBeGreaterThan(0); + }); + + it('autoSaveTranscript persists the finalized transcript', async () => { + const state = setupMainStreetGame({ seed: 'autosave-test-1' }); + const initialSnapshot = serializeMainStreetState(state); + const recorder = new MainStreetTranscriptRecorder(initialSnapshot); + setMainStreetRecorder(recorder); + + playTurns(state, 2); + + const result = { gameResult: state.gameResult, finalScore: 0 }; + const transcript = finalizeMainStreetTranscript(result); + expect(transcript).not.toBeNull(); + + const store = new TranscriptStore(); + autoSaveTranscript(store, 'main-street', transcript!); + + // Wait for fire-and-forget save to complete + await vi.waitFor(() => { + expect(console.info).toHaveBeenCalledWith( + expect.stringContaining('Transcript saved'), + ); + }); + + // Verify the transcript is in storage + const saved = await store.list('main-street'); + expect(saved.length).toBeGreaterThan(0); + const st = saved[0].transcript as { gameType: string }; + expect(st.gameType).toBe('main-street'); + }); + + it('full save/load + autosave round-trip: checkpoint and transcript survive storage restart', async () => { + const SEED = 'full-roundtrip-1'; + + // Phase 1: Set up recorder, play some turns + const state = setupMainStreetGame({ seed: SEED }); + const initialSnapshot = serializeMainStreetState(state); + const recorder = new MainStreetTranscriptRecorder(initialSnapshot); + setMainStreetRecorder(recorder); + + const saveStore = new SaveLoadStore(); + const transcriptStore = new TranscriptStore(); + + playTurns(state, 2); + + // Save checkpoint + await saveTurnStartCheckpoint(saveStore, state); + + // Auto-save transcript + const partialResult = { gameResult: 'playing', finalScore: 0 }; + const partialTranscript = finalizeMainStreetTranscript(partialResult); + expect(partialTranscript).not.toBeNull(); + autoSaveTranscript(transcriptStore, 'main-street', partialTranscript!); + + await vi.waitFor(() => { + expect(console.info).toHaveBeenCalledWith( + expect.stringContaining('Transcript saved'), + ); + }); + + // Phase 2: Load checkpoint and verify state matches + const restored = await loadTurnStartCheckpoint(saveStore); + expect(restored).not.toBeNull(); + expect(restored!.turn).toBe(state.turn); + expect(restored!.resourceBank.coins).toBe(state.resourceBank.coins); + expect(restored!.streetGrid.map((b) => b?.id ?? null)).toEqual( + state.streetGrid.map((b) => b?.id ?? null), + ); + + // Phase 3: Verify transcript was persisted + const savedTranscripts = await transcriptStore.list('main-street'); + expect(savedTranscripts.length).toBeGreaterThan(0); + const retrieved = await transcriptStore.get(savedTranscripts[0].id); + expect(retrieved).not.toBeNull(); + const rt = retrieved!.transcript as { gameType: string; events: unknown[] }; + expect(rt.gameType).toBe('main-street'); + expect(rt.events.length).toBeGreaterThan(0); + }); + + it('TranscriptRecorderBase is used correctly by MainStreetTranscriptRecorder', () => { + const state = setupMainStreetGame({ seed: 'base-class-test' }); + const initialSnapshot = serializeMainStreetState(state); + const recorder = new MainStreetTranscriptRecorder(initialSnapshot); + setMainStreetRecorder(recorder); + + // Verify it extends TranscriptRecorderBase + expect(recorder).toBeInstanceOf(TranscriptRecorderBase); + + // Verify getTranscript returns the same object + const t1 = recorder.getTranscript(); + const t2 = recorder.getTranscript(); + expect(t1).toBe(t2); + + // Verify events accumulate via global recorder + recordMainStreetEvent({ type: 'info', turn: 1, message: 'test' }); + const t3 = recorder.getTranscript(); + expect(t3.events.length).toBe(1); + expect(t3.events[0].type).toBe('info'); + }); + + it('consolidated module exports are accessible from @core-engine/transcript barrel', async () => { + const mod = await import('../../src/core-engine/transcript'); + expect(mod.TranscriptRecorderBase).toBeDefined(); + expect(mod.TranscriptStore).toBeDefined(); + expect(mod.autoSaveTranscript).toBeDefined(); + expect(mod.snapshotCard).toBeDefined(); + // CardSnapshot and BaseTranscript are type-only exports (not runtime values) + expect(Object.keys(mod)).not.toContain('CardSnapshot'); + expect(Object.keys(mod)).not.toContain('BaseTranscript'); + }); + + it('backward-compatible top-level exports still work', async () => { + const ts = await import('../../src/core-engine/TranscriptStore'); + expect(ts.TranscriptStore).toBeDefined(); + + const tr = await import('../../src/core-engine/TranscriptRecorder'); + expect(tr.TranscriptRecorderBase).toBeDefined(); + + const as = await import('../../src/core-engine/autoSaveTranscript'); + expect(as.autoSaveTranscript).toBeDefined(); + + const tt = await import('../../src/core-engine/TranscriptTypes'); + expect(tt.snapshotCard).toBeDefined(); + }); +});