diff --git a/.ralph.json b/.ralph.json new file mode 100644 index 00000000..3aa99ed1 --- /dev/null +++ b/.ralph.json @@ -0,0 +1,23 @@ +{ + "model_source": "remote", + "model": { + "remote": { + "intake": "opencode/claude-opus-4.7", + "planning": "opencode/gpt-5.5", + "implementation": "opencode-go/qwen3.6-plus", + "audit": "opencode-go/glm-5.1" + }, + "local": { + "intake": "qwen3", + "planning": "qwen3", + "implementation": "qwen3", + "audit": "qwen3" + } + }, + "timeout": { + "pi_stream": { + "remote": 900, + "local": 60 + } + } +} diff --git a/README.md b/README.md index ecf8a7bd..7529cb84 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ tableau-card-engine/ ├── tests/ Vitest test files ├── docs/ Developer documentation │ ├── DEVELOPER.md Detailed developer guide -│ └── core-engine/ Engine API notes (including spatial rules) +│ ├── core-engine/ Engine API notes (including spatial rules) +│ └── rule-engine/ Rule engine API docs (including economy ledger) ├── dist/ Production build output (gitignored) ├── AGENTS.md Project guidance and Worklog rules ├── package.json diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index c4ecbd3b..5179a70d 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -33,7 +33,7 @@ npx vitest run --project browser tests/gym/*.browser.test.ts | Overlay & UI Config | `GymOverlayUiScene` | `createOverlayBackground`, `dismissOverlay` | [`scenes/GymOverlayUiScene.ts`](../../example-games/gym/scenes/GymOverlayUiScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Undo / Redo | `GymUndoRedoScene` | `UndoRedoManager`, `CompoundCommand` | [`scenes/GymUndoRedoScene.ts`](../../example-games/gym/scenes/GymUndoRedoScene.ts) | [`GymUndoRedo.test.ts`](../../tests/gym/GymUndoRedo.test.ts) | | Transcript Recording | `GymTranscriptScene` | `TranscriptRecorderBase`, `createSeededRng` | [`scenes/GymTranscriptScene.ts`](../../example-games/gym/scenes/GymTranscriptScene.ts) | [`GymTranscript.test.ts`](../../tests/gym/GymTranscript.test.ts) | -| Save / Load State | `GymSaveLoadScene` | `SaveLoadStore`, `serializeWithVersion`, `deserializeWithVersion` | [`scenes/GymSaveLoadScene.ts`](../../example-games/gym/scenes/GymSaveLoadScene.ts) | [`GymSaveLoad.test.ts`](../../tests/gym/GymSaveLoad.test.ts) | +| Save / Load State | `GymSaveLoadScene` | `SaveLoadStore`, `serializeWithVersion`, `deserializeWithVersion`, `RenderTexture.saveTexture()`, `RenderTexture.snapshot()`, snapshot persistence via base64 data URL | [`scenes/GymSaveLoadScene.ts`](../../example-games/gym/scenes/GymSaveLoadScene.ts) | [`GymSaveLoad.test.ts`](../../tests/gym/GymSaveLoad.test.ts) | | Audio & Feedback Config | `GymAudioFeedbackScene` | `SoundManager`, `GameEventEmitter`, `EventSoundMapping` | [`scenes/GymAudioFeedbackScene.ts`](../../example-games/gym/scenes/GymAudioFeedbackScene.ts) | [`GymAudioFeedback.test.ts`](../../tests/gym/GymAudioFeedback.test.ts) | | Shader & Blend Spike | `GymGraphicsShaderSpikeScene` | Sprite tinting, blend modes, shader feasibility | [`scenes/GymGraphicsShaderSpikeScene.ts`](../../example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Lighting Spike | `GymGraphicsLightingSpikeScene` | Point light, shadow evaluation, WebGL fallback | [`scenes/GymGraphicsLightingSpikeScene.ts`](../../example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | diff --git a/docs/main-street/prd-milestone-6.md b/docs/main-street/prd-milestone-6.md index c66fff9b..696bdeac 100644 --- a/docs/main-street/prd-milestone-6.md +++ b/docs/main-street/prd-milestone-6.md @@ -257,6 +257,7 @@ Approach: - `tests/main-street/**` - `tests/e2e/replay-main-street.e2e.test.ts` - `tests/main-street/monte-carlo-balance.test.ts` + - `tests/main-street/market-extraction-parity.test.ts` — extraction parity oracle for MarketOfferEngine (57 tests, CG-0MPWZ5R1M001MZ3B) ### Manual checks @@ -298,6 +299,16 @@ Suggested implementation sequence: --- +## 9.1 Implementation progress + +| Component | Status | Work Item | Notes | +|---|---|---|---| +| MarketOfferEngine — extraction parity tests | ✅ Done | CG-0MPWZ5R1M001MZ3B | 57 tests in `tests/main-street/market-extraction-parity.test.ts` (positive + negative paths, reshuffle behavior, integration, refill) | +| MarketOfferEngine — shared module extraction | ⏳ Pending | — | Awaiting follow-up implementation work | +| Economy Ledger — extraction parity tests | ✅ Done | CG-0MPWZ5RFI001DJUA | 51 unit + integration tests in `tests/rule-engine/EconomyLedger.test.ts`; shared module at `src/rule-engine/EconomyLedger.ts` | +| Economy Ledger — shared module extraction | ✅ Done | CG-0MPWZ5RFI001DJUA | `EconomyLedger` interface and `createEconomyLedger` factory at `src/rule-engine/EconomyLedger.ts` with barrel export; 51 unit + integration tests; API documented in `docs/rule-engine/economy-ledger.md`; Main Street migration to consume it is a follow-up task | +| Action Commands | ⏳ Pending | — | — | + ## 10. Open questions 1. Should HintEngine live under `src/ai/` (strategy-centric) or `src/rule-engine/` (rule-eval-centric)? diff --git a/docs/rule-engine/economy-ledger.md b/docs/rule-engine/economy-ledger.md new file mode 100644 index 00000000..de294b44 --- /dev/null +++ b/docs/rule-engine/economy-ledger.md @@ -0,0 +1,164 @@ +# Rule Engine: Economy Ledger API + +Work item: CG-0MPWZ5RFI001DJUA + +The rule engine provides a generic resource-tracking component for managing +mutable game economy values (coins, reputation, score). Extracted from the +Main Street game to provide reusable economy mutation semantics for any +tableau card game. + +## Exports + +```ts +import { + createEconomyLedger, + type EconomyLedger, + type EconomyLedgerConfig, + type EconomyConstraints, + type ResourceDelta, + type ResourceSnapshot, +} from '@rule-engine'; +``` + +## Quick Start + +```ts +const ledger = createEconomyLedger({ coins: 10, reputation: 3 }); + +// Check if a purchase is affordable (with constraints) +const purchaseLedger = createEconomyLedger({ + coins: 10, + constraints: { minCoins: 0 }, +}); + +if (purchaseLedger.canApply({ coins: -8 })) { + purchaseLedger.apply({ coins: -8 }, 'buy-business'); +} + +// Earn income +ledger.apply({ coins: 5 }, 'income'); + +// Event resolution with multiple resources +ledger.apply({ coins: 3, reputation: 1 }, 'event-resolve'); + +// Set score directly (for games where score is independent) +ledger.setScore(150); + +// Snapshot for undo/redo or save/load +const snapshot = ledger.snapshot(); +// { coins: 15, reputation: 4, score: 150 } +``` + +## Design Principles + +### No Built-in Loss Conditions + +The EconomyLedger does **not** enforce bankruptcy, reputation collapse, or +other loss conditions. Coins and reputation are allowed to go negative. The +game engine is responsible for win/loss detection after mutations. This +matches the Main Street baseline where `checkImmediateLoss()` reads from +the resource bank after all mutations are applied. + +### Purely Additive Semantics + +All `apply` operations are purely additive — no multiplicative or clamping +behavior. Negative deltas subtract, positive deltas add. Unspecified fields +in a `ResourceDelta` are left unchanged. + +### Constraints are Optional + +By default, `canApply` always returns `true`. Constraints (`minCoins`, +`minReputation`) are opt-in and only affect `canApply` — they do **not** +prevent `apply` from executing. The caller is responsible for checking +`canApply` before calling `apply`. + +### Score is Independent + +Score is treated as an independent resource that can be set directly via +`setScore()`. Unlike coins and reputation (which use additive deltas via +`apply`), score can be set to any absolute value. Games that derive score +from other resources should compute it externally and call `setScore()`. + +## API Reference + +### `createEconomyLedger(config?)` + +Factory function that creates a new `EconomyLedger` instance. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `config.coins` | `number` | `0` | Initial coin balance | +| `config.reputation` | `number` | `0` | Initial reputation | +| `config.score` | `number` | `0` | Initial score | +| `config.constraints` | `EconomyConstraints` | `{}` | Optional `canApply` constraints | + +### `EconomyLedger` Interface + +| Method | Description | +|--------|-------------| +| `get(resource)` | Returns current value of `coins`, `reputation`, or `score` | +| `snapshot()` | Returns a `ResourceSnapshot` copy of all current values | +| `canApply(delta)` | Checks if delta can be applied given constraints (always `true` without constraints) | +| `apply(delta, reason?)` | Applies additive deltas to specified resources | +| `setScore(value)` | Sets score to an absolute value | + +### Types + +```ts +interface ResourceDelta { + coins?: number; + reputation?: number; + score?: number; +} + +interface ResourceSnapshot { + coins: number; + reputation: number; + score: number; +} + +interface EconomyConstraints { + minCoins?: number; + minReputation?: number; +} +``` + +## Integration Notes + +### Main Street Migration + +Main Street currently manages economy directly on `state.resourceBank`. +To migrate, create an `EconomyLedger` during game setup and delegate all +resource mutations through it: + +```ts +// Before (MainStreetEngine.ts): +state.resourceBank.coins -= card.cost; +state.resourceBank.coins += income; + +// After: +ledger.apply({ coins: -card.cost }, 'purchase'); +ledger.apply({ coins: income }, 'income'); +``` + +The ledger's `snapshot()` method can replace manual resource bank cloning +for undo/redo, and `get()` provides a clean read API for UI display. + +### Reputation Multiplier + +The EconomyLedger does **not** include reputation-based coin scaling. +Games should apply the reputation multiplier upstream (before calling +`apply`) using their own `applyReputationMultiplier` function. This keeps +the ledger generic and avoids coupling it to Main Street's specific +multiplier formula. + +## Tests + +51 unit and integration tests are available in +`tests/rule-engine/EconomyLedger.test.ts`, covering: + +- `get` / `snapshot` / `canApply` / `apply` / `setScore` semantics +- Invariant checks (no underflow guards, deterministic ordering, additive behavior) +- Integration parity with Main Street economy outcomes +- Direct `computeScore()` function equivalence +- Negative economy integration (bankruptcy, reputation collapse, combined) diff --git a/example-games/feudalism/scenes/FeudalismConstants.ts b/example-games/feudalism/scenes/FeudalismConstants.ts index 2dcd7e79..d3c5eacb 100644 --- a/example-games/feudalism/scenes/FeudalismConstants.ts +++ b/example-games/feudalism/scenes/FeudalismConstants.ts @@ -95,8 +95,8 @@ export const AI_AREA_Y = LOWER_TOP; export const DIVIDER_X = 640; // Action buttons -export const ACTION_Y = 660; -export const INSTRUCTION_Y = 696; +export const ACTION_Y = 652; +export const INSTRUCTION_Y = 708; // ── Audio asset keys ────────────────────────────────────── export const SFX_KEYS = { diff --git a/example-games/feudalism/scenes/FeudalismRenderer.ts b/example-games/feudalism/scenes/FeudalismRenderer.ts index 9ff9ebd4..7693bca1 100644 --- a/example-games/feudalism/scenes/FeudalismRenderer.ts +++ b/example-games/feudalism/scenes/FeudalismRenderer.ts @@ -29,12 +29,12 @@ import { ACTION_Y, INSTRUCTION_Y, RESOURCE_FILL, RESOURCE_TEXT_COLOR, RESOURCE_ICON_COLOR, RESOURCE_LABEL_COLOR, } from './FeudalismConstants'; +import { createFeudalismActionButton } from '../../../src/ui/Renderer/adapters/FeudalismAdapter'; import { buildTokenEntries, getBonusRenderOrder, getTokenRenderOrder, } from './FeudalismRenderHelpers'; -import { createFeudalismActionButton } from '../../../src/ui/Renderer/adapters/FeudalismAdapter'; export interface MarketCallbacks { onMarketCardClick: (card: DevelopmentCard) => void; diff --git a/example-games/gym/README.md b/example-games/gym/README.md index db58c471..619bdf5a 100644 --- a/example-games/gym/README.md +++ b/example-games/gym/README.md @@ -148,4 +148,106 @@ deckView.onClick(() => { /* draw logic */ deckView.update(); }); deckView.destroy(); ``` -**API**: `setPile(pile)`, `peek()`, `update()`, `onClick(cb)`, `getCountText()`, `getSprite()`, `getPile()`, `destroy()`. \ No newline at end of file +**API**: `setPile(pile)`, `peek()`, `update()`, `onClick(cb)`, `getCountText()`, `getSprite()`, `getPile()`, `destroy()`. + +## Shared Gym Utilities + +The Gym provides shared utility functions (in [`src/ui/GymSceneUtils.ts`](../../src/ui/GymSceneUtils.ts)) that extract common rendering patterns from demo scenes. These are used internally by Gym scenes but can also be imported directly for custom scenes or testing. + +### `createEventLog(scene, baseY, options?)` + +Renders a centered header and scrollable log line area. Used by 7 Gym scenes to display event logs. + +```ts +import { createEventLog } from '@ui/GymSceneUtils'; + +const eventLog = createEventLog(scene, 200, { + headerText: '── Event Log ──', // default + maxLines: 14, // default + lineHeight: 17, // default + textColor: '#aaddaa', // default + fontSize: '11px', // default + lineX: 40, // default +}); + +// Later, update the display: +eventLog.render(myLogLines); + +// Cleanup: +eventLog.destroy(); +``` + +**Options**: `headerText`, `lineHeight`, `textColor`, `maxLines`, `fontSize`, `headerX`, `lineX`, `headerFontSize`, `headerColor`. + +**Returns**: `{ header, lines, baseY, render(lines), destroy() }`. + +### `createDeckGrid(scene, deck, options?)` + +Renders a deck of cards as a compact face-up grid. Used by GymDeckRngScene. + +```ts +import { createDeckGrid } from '@ui/GymSceneUtils'; + +const grid = createDeckGrid(scene, myDeck, { + cols: 8, // default + gapX: 4, // default + gapY: 4, // default + centerX: 640, // default: GAME_W / 2 + centerY: 370, // default gym position +}); + +// Replace with a new shuffled deck: +grid.destroy(); +const newGrid = createDeckGrid(scene, shuffledDeck); +``` + +**Options**: `gapX`, `gapY`, `cols`, `centerX`, `centerY`, `cardScale`. + +**Returns**: `{ sprites[], destroy() }`. + +### `createSlider(scene, x, y, options?)` + +Creates a horizontal slider with track, fill bar, handle, and value text. Encapsulates drag logic. Used by GymHandPileScene's three live-control sliders. + +```ts +import { createSlider } from '@ui/GymSceneUtils'; + +const slider = createSlider(scene, 100, 680, { + initialValue: 0.5, + minValue: 0, + maxValue: 1, + label: 'Volume', + width: 150, + textColor: '#88ff88', +}); + +// Wire value changes: +slider.onValueChange = (value) => { + console.log('Slider value:', value); +}; + +// Wire scene input to slider drag: +scene.input.on('pointermove', (pointer) => { + slider.handlePointerMove(pointer.x); +}); +scene.input.on('pointerup', () => { + slider.handlePointerUp(); +}); + +// Programmatic set (does not fire onValueChange): +slider.setValue(0.75); +``` + +**Options**: `initialValue`, `minValue`, `maxValue`, `label`, `width`, `trackHeight`, `trackColor`, `fillColor`, `handleColor`, `fontSize`, `textColor`. + +**Returns**: `{ value, track, fill, handle, valueText, hitArea, onValueChange, setValue(v), destroy(), handlePointerMove(px), handlePointerUp() }`. + +### Migration Notes + +The following Gym scenes were migrated to use these shared utilities: + +- **Event log** (`createEventLog`): GymAudioFeedbackScene, GymGraphicsLightingSpikeScene, GymGraphicsShaderSpikeScene, GymOverlayUiScene, GymSaveLoadScene, GymTranscriptScene, GymUndoRedoScene +- **Deck grid** (`createDeckGrid`): GymDeckRngScene +- **Slider** (`createSlider`): GymHandPileScene (3 sliders: arc, spacing, rotation) + +Each migration preserved the original visual parameters (header text, colors, line spacing, slider ranges) via options, so player-facing behavior is unchanged. \ No newline at end of file diff --git a/example-games/gym/scenes/GymAudioFeedbackScene.ts b/example-games/gym/scenes/GymAudioFeedbackScene.ts index bf57b96b..3b9c1b51 100644 --- a/example-games/gym/scenes/GymAudioFeedbackScene.ts +++ b/example-games/gym/scenes/GymAudioFeedbackScene.ts @@ -24,6 +24,8 @@ import type { SoundPlayer, EventSoundMapping } from '../../../src/core-engine'; import { popTextOrIcon } from '../../../src/ui/popTextOrIcon'; import { GAME_W } from '../../../src/ui/constants'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; /** A stub SoundPlayer that records play calls instead of producing audio. */ class StubSoundPlayer implements SoundPlayer { @@ -57,8 +59,8 @@ export class GymAudioFeedbackScene extends GymSceneBase { private muted = false; private volume = 0.5; private statusText!: Phaser.GameObjects.Text; - private callLogTexts: Phaser.GameObjects.Text[] = []; private callLog: string[] = []; + private eventLogResult!: EventLogResult; // Track pop text targets for cleanup private popTargets: Phaser.GameObjects.Text[] = []; @@ -136,7 +138,16 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.statusText = createHudText(this, cx, y, this.statusString(), '#ffffff', { fontSize: '16px' }).setOrigin(0.5); y += 30; - createHudText(this, cx, y, '── Sound Call Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 40, { + headerText: '── Sound Call Log ──', + maxLines: 14, + lineHeight: 17, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); } private statusString(): string { @@ -289,13 +300,7 @@ export class GymAudioFeedbackScene extends GymSceneBase { private logCall(msg: string): void { this.callLog.push(msg); if (this.callLog.length > 14) this.callLog.shift(); - for (const t of this.callLogTexts) t.destroy(); - this.callLogTexts = []; - const baseY = 250; - for (let i = 0; i < this.callLog.length; i++) { - const txt = createHudText(this, 40, baseY + i * 17, this.callLog[i], '#aaddaa', { fontSize: '11px' }); - this.callLogTexts.push(txt); - } + this.eventLogResult.render(this.callLog); } protected cleanup(): void { diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index 6ada0442..5c88ed40 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -16,9 +16,11 @@ import { GYM_DECK_RNG_KEY } from '../GymRegistry'; import { createStandardDeck, shuffleArray } from '../../../src/card-system/Deck'; import type { Card } from '../../../src/card-system/Card'; import { createSeededRng } from '../../../src/core-engine/SeededRng'; -import { GAME_W, CARD_W, CARD_H } from '../../../src/ui/constants'; -import { preloadCardAssets, getCardTexture, ensureCardTextureFallbacks } from '../../../src/ui/CardTextureHelpers'; +import { GAME_W } from '../../../src/ui/constants'; +import { preloadCardAssets, ensureCardTextureFallbacks } from '../../../src/ui/CardTextureHelpers'; import { createHudText } from '../../../src/ui/Renderer'; +import { createDeckGrid } from '../../../src/ui/GymSceneUtils'; +import type { DeckGridResult } from '../../../src/ui/GymSceneUtils'; /** Default seed for deterministic demonstrations. */ const DEFAULT_SEED = 42; @@ -41,8 +43,8 @@ export class GymDeckRngScene extends GymSceneBase { private seedText!: Phaser.GameObjects.Text; private statusText!: Phaser.GameObjects.Text; - // Grid card sprites — tracked so they can be destroyed on shuffle - private cardSprites: Phaser.GameObjects.Image[] = []; + // Deck grid result — tracked so it can be destroyed on shuffle + private deckGridResult: DeckGridResult | null = null; constructor() { super({ key: GYM_DECK_RNG_KEY }); @@ -119,63 +121,25 @@ export class GymDeckRngScene extends GymSceneBase { this.rng = createSeededRng(this.seed); this.deck = createStandardDeck(); shuffleArray(this.deck, this.rng); - this.clearGridSprites(); - this.renderFullDeckGrid(); - } - // ── Grid rendering ─────────────────────────────────────── + // Destroy previous grid and render new one + if (this.deckGridResult) { + this.deckGridResult.destroy(); + this.deckGridResult = null; + } - /** - * Render all cards in the deck as a compact face-up grid within the - * cardDisplay SLL zone. Cards are scaled down to fit the available space. - */ - private renderFullDeckGrid(): void { const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); const centerX = cardDisplay?.x ?? GAME_W / 2; - // Shift the grid down within the cardDisplay zone to clear the header/controls const centerY = (cardDisplay?.y ?? 270) + 100; - // Full-scale cards (48×65px), 8 columns = ~412px wide - const cardScale = 1.0; - const scaledCardW = CARD_W * cardScale; - const scaledCardH = CARD_H * cardScale; - const stepX = scaledCardW + GRID_GAP_X; - const stepY = scaledCardH + GRID_GAP_Y; - - // Calculate the top-left origin of the grid so it's centered - const totalWidth = GRID_COLUMNS * stepX - GRID_GAP_X; - const totalRows = Math.ceil(this.deck.length / GRID_COLUMNS); - const totalHeight = totalRows * stepY - GRID_GAP_Y; - const gridStartX = centerX - totalWidth / 2 + scaledCardW / 2; - const gridStartY = centerY - totalHeight / 2 + scaledCardH / 2; - - for (let i = 0; i < this.deck.length; i++) { - const card = this.deck[i]; - card.faceUp = true; // Ensure all cards are face-up - - const col = i % GRID_COLUMNS; - const row = Math.floor(i / GRID_COLUMNS); - const x = gridStartX + col * stepX; - const y = gridStartY + row * stepY; - - const texture = getCardTexture(card); - const sprite = this.add.image(x, y, texture); - sprite.setScale(cardScale); - this.cardSprites.push(sprite); - - } + this.deckGridResult = createDeckGrid(this, this.deck, { + cols: GRID_COLUMNS, + gapX: GRID_GAP_X, + gapY: GRID_GAP_Y, + centerX, + centerY, + }); this.statusText.setText(`${this.deck.length} cards displayed · seed=${this.seed}`); } - - /** - * Destroy all card sprites and labels in the grid. - */ - private clearGridSprites(): void { - for (const sprite of this.cardSprites) { - try { sprite.destroy(); } catch (_) { /* ignore */ } - } - this.cardSprites = []; - - } } diff --git a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts index 28ba8e1d..37ef8ff6 100644 --- a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts @@ -17,12 +17,14 @@ import { GymSceneBase } from './GymSceneBase'; import { GAME_W } from '../../../src/ui/constants'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; export const GYM_GRAPHICS_LIGHTING_SPIKE_KEY = 'GymGraphicsLightingSpikeScene'; export class GymGraphicsLightingSpikeScene extends GymSceneBase { - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; + private eventLogResult!: EventLogResult; private lightingAvailable = false; private lightActive = true; @@ -109,7 +111,16 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { } y += 260; - createHudText(this, cx, y, '── Findings & Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Findings & Event Log ──', + maxLines: 14, + lineHeight: 16, + textColor: '#aaddaa', + fontSize: '10px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 20, + }); // Record findings this.logEvent('--- Lighting Spike Findings ---'); @@ -165,12 +176,13 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 14) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 370; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = createHudText(this, 20, baseY + i * 16, this.eventLog[i], '#aaddaa', { fontSize: '10px' }); - this.logTexts.push(txt); + // Guard against rendering before the eventLogResult has been created. + if (this.eventLogResult && typeof this.eventLogResult.render === 'function') { + try { + this.eventLogResult.render(this.eventLog); + } catch (_) { + // Ignore render errors in headless/test environments. + } } } } \ No newline at end of file diff --git a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts index 82309259..1783f882 100644 --- a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts @@ -16,6 +16,8 @@ import { GymSceneBase } from './GymSceneBase'; import { GAME_W } from '../../../src/ui/constants'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; /** The scene key must match the registration in GymRegistry. */ export const GYM_GRAPHICS_SHADER_SPIKE_KEY = 'GymGraphicsShaderSpikeScene'; @@ -42,8 +44,8 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { private sprites: Phaser.GameObjects.Image[] = []; private blendModeIndex = 0; private tintColorIndex = 0; - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; + private eventLogResult!: EventLogResult; private shaderAttempted = false; private shaderResult = ''; @@ -126,7 +128,16 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { } y += 200; - createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Event Log ──', + maxLines: 12, + lineHeight: 17, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); } private cycleTint(): void { @@ -195,12 +206,6 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 12) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 280; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = createHudText(this, 40, baseY + i * 17, this.eventLog[i], '#aaddaa', { fontSize: '11px' }); - this.logTexts.push(txt); - } + this.eventLogResult.render(this.eventLog); } } \ No newline at end of file diff --git a/example-games/gym/scenes/GymHandPileScene.ts b/example-games/gym/scenes/GymHandPileScene.ts index cb9c771f..20ccb8d3 100644 --- a/example-games/gym/scenes/GymHandPileScene.ts +++ b/example-games/gym/scenes/GymHandPileScene.ts @@ -33,6 +33,8 @@ import { shakeIllegalMove } from '../../../src/ui/shakeIllegalMove'; import { CARD_H, CARD_W, GAME_H, GAME_W } from '../../../src/ui/constants'; import { getCardTexture, ensureCardTextureFallbacks, preloadCardAssets } from '../../../src/ui/CardTextureHelpers'; import { createHudText } from '../../../src/ui/Renderer'; +import { createSlider } from '../../../src/ui/GymSceneUtils'; +import type { SliderResult } from '../../../src/ui/GymSceneUtils'; import type { Card } from '../../../src/card-system/Card'; const HAND_SIZE = 5; @@ -75,42 +77,13 @@ export class GymHandPileScene extends GymSceneBase { private readonly SLIDER_Y = GAME_H - 40; private readonly SLIDER_HORIZ_GAP = 40; private readonly SLIDER_START_X = 375; - - // Arc slider constants/state - private readonly ARC_RADIUS_MIN = 0; - private readonly ARC_RADIUS_MAX = 200; - private readonly ARC_RADIUS_DEFAULT = 150; private readonly ARC_SLIDER_WIDTH = 150; - private readonly ARC_SLIDER_HEIGHT = 6; - private readonly ARC_SLIDER_X = this.SLIDER_START_X; - private readonly SPACING_SLIDER_X = this.SLIDER_START_X + this.ARC_SLIDER_WIDTH + this.SLIDER_HORIZ_GAP; - private readonly ROTATION_SLIDER_X = this.SLIDER_START_X + 2 * (this.ARC_SLIDER_WIDTH + this.SLIDER_HORIZ_GAP); - private arcRadius = this.ARC_RADIUS_DEFAULT; - private arcSliderTrack?: Phaser.GameObjects.Rectangle; - private arcSliderFill?: Phaser.GameObjects.Rectangle; - private arcSliderHandle?: Phaser.GameObjects.Graphics; - private arcSliderHitArea?: Phaser.GameObjects.Zone; - private arcSliderValueText?: Phaser.GameObjects.Text; - private isArcSliderDragging = false; - - // Spacing slider state - private spacingSliderTrack?: Phaser.GameObjects.Rectangle; - private spacingSliderFill?: Phaser.GameObjects.Rectangle; - private spacingSliderHandle?: Phaser.GameObjects.Graphics; - private spacingSliderHitArea?: Phaser.GameObjects.Zone; - private spacingSliderValueText?: Phaser.GameObjects.Text; - private isSpacingSliderDragging = false; - - // Rotation slider constants/state - private readonly ROTATION_DEGREES_MIN = 0; - private readonly ROTATION_DEGREES_MAX = 45; + private readonly ARC_RADIUS_DEFAULT = 150; private readonly ROTATION_DEGREES_DEFAULT = 25; - private rotationSliderTrack?: Phaser.GameObjects.Rectangle; - private rotationSliderFill?: Phaser.GameObjects.Rectangle; - private rotationSliderHandle?: Phaser.GameObjects.Graphics; - private rotationSliderHitArea?: Phaser.GameObjects.Zone; - private rotationSliderValueText?: Phaser.GameObjects.Text; - private isRotationSliderDragging = false; + private arcRadius = this.ARC_RADIUS_DEFAULT; + private arcSlider!: SliderResult; + private spacingSlider!: SliderResult; + private rotationSlider!: SliderResult; constructor() { super({ key: GYM_HAND_PILE_KEY }); @@ -188,309 +161,74 @@ export class GymHandPileScene extends GymSceneBase { y += 35; createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); - this.createArcRadiusSlider(); - this.createSpacingSlider(); - this.createRotationSlider(); - - // Initialize - this.reset(); - } - - private createArcRadiusSlider(): void { + // Create sliders using the shared utility const sliderY = this.SLIDER_Y; - - this.arcSliderTrack = this.add.rectangle(0, sliderY, this.ARC_SLIDER_WIDTH, this.ARC_SLIDER_HEIGHT, 0x334433, 1) - .setOrigin(0, 0.5); - - this.arcSliderFill = this.add.rectangle(0, sliderY, 1, this.ARC_SLIDER_HEIGHT, 0x88ff88, 1) - .setOrigin(0, 0.5); - - this.arcSliderHandle = this.add.graphics(); - - this.arcSliderValueText = createHudText(this, 0, sliderY - 20, '', '#88ff88', { fontSize: '11px' }).setOrigin(0.5); - - this.arcSliderHitArea = this.add.zone(0, sliderY, this.ARC_SLIDER_WIDTH + 24, 28) - .setInteractive({ useHandCursor: true }); - - this.arcSliderHitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - this.isArcSliderDragging = true; - this.setArcRadiusFromPointer(pointer.x); + const sliderWidth = this.ARC_SLIDER_WIDTH; + const sliderHorizGap = this.SLIDER_HORIZ_GAP; + const startX = this.SLIDER_START_X; + + const arcSliderX = startX; + const spacingSliderX = startX + sliderWidth + sliderHorizGap; + const rotationSliderX = startX + 2 * (sliderWidth + sliderHorizGap); + + this.arcSlider = createSlider(this, arcSliderX, sliderY, { + initialValue: this.ARC_RADIUS_DEFAULT, + minValue: 0, + maxValue: 200, + label: 'Arc', + width: sliderWidth, + textColor: '#88ff88', }); + this.arcSlider.onValueChange = (value: number) => { + this.arcRadius = value; + this.handView.setArcRadius(value); + }; - this.input.on('pointermove', this.handleArcSliderPointerMove, this); - this.input.on('pointerup', this.handleArcSliderPointerUp, this); - - this.events.once('shutdown', () => { - this.input.off('pointermove', this.handleArcSliderPointerMove, this); - this.input.off('pointerup', this.handleArcSliderPointerUp, this); + const minSpacing = Math.round(CARD_W * (1 - 0.75)); + const maxSpacing = Math.round(CARD_W * (1 + 0.75)); + this.spacingSlider = createSlider(this, spacingSliderX, sliderY, { + initialValue: this.HAND_SPACING, + minValue: minSpacing, + maxValue: maxSpacing, + label: 'Spacing', + width: sliderWidth, + textColor: '#88ff88', }); - - this.updateArcSliderPosition(); - this.updateArcSliderVisuals(); - } - - private handleArcSliderPointerMove(pointer: Phaser.Input.Pointer): void { - if (!this.isArcSliderDragging) return; - this.setArcRadiusFromPointer(pointer.x); - } - - private handleArcSliderPointerUp(): void { - this.isArcSliderDragging = false; - } - - private setArcRadiusFromPointer(pointerX: number): void { - if (!this.arcSliderTrack) return; - - const minX = this.arcSliderTrack.x; - const maxX = minX + this.ARC_SLIDER_WIDTH; - const clampedX = Math.max(minX, Math.min(maxX, pointerX)); - const ratio = (clampedX - minX) / this.ARC_SLIDER_WIDTH; - const nextRadius = this.ARC_RADIUS_MIN + ratio * (this.ARC_RADIUS_MAX - this.ARC_RADIUS_MIN); - - this.arcRadius = nextRadius; - this.handView.setArcRadius(this.arcRadius); - this.updateArcSliderVisuals(); - } - - private updateArcSliderPosition(): void { - if (!this.arcSliderTrack || !this.arcSliderFill || !this.arcSliderHitArea || !this.arcSliderValueText) { - return; - } - - const trackX = this.ARC_SLIDER_X; - - this.arcSliderTrack.setPosition(trackX, this.SLIDER_Y); - this.arcSliderFill.setPosition(trackX, this.SLIDER_Y); - this.arcSliderHitArea.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y); - this.arcSliderValueText.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y - 20); - - this.updateArcSliderVisuals(); - // Also update spacing slider position so both sliders track the hand - try { this.updateSpacingSliderPosition(); } catch (_) { /* spacing slider may not be initialised */ } - } - - private updateArcSliderVisuals(): void { - if (!this.arcSliderTrack || !this.arcSliderFill || !this.arcSliderHandle || !this.arcSliderValueText) { - return; - } - - const ratio = (this.arcRadius - this.ARC_RADIUS_MIN) / (this.ARC_RADIUS_MAX - this.ARC_RADIUS_MIN); - const clampedRatio = Math.max(0, Math.min(1, ratio)); - const fillWidth = Math.max(1, this.ARC_SLIDER_WIDTH * clampedRatio); - const handleX = this.arcSliderTrack.x + fillWidth; - const handleY = this.arcSliderTrack.y; - - this.arcSliderFill.setSize(fillWidth, this.ARC_SLIDER_HEIGHT); - this.arcSliderFill.setPosition(this.arcSliderTrack.x, handleY); - - this.arcSliderHandle.clear(); - this.arcSliderHandle.fillStyle(0xffffff, 1); - this.arcSliderHandle.fillCircle(handleX, handleY, 8); - this.arcSliderHandle.lineStyle(2, 0x88ff88, 1); - this.arcSliderHandle.strokeCircle(handleX, handleY, 8); - - this.arcSliderValueText.setText(`Arc: ${Math.round(this.arcRadius)}`); - } - - // ── Spacing slider ────────────────────────────────────── - - private createSpacingSlider(): void { - const sliderY = this.SLIDER_Y; - - this.spacingSliderTrack = this.add.rectangle(0, sliderY, this.ARC_SLIDER_WIDTH, this.ARC_SLIDER_HEIGHT, 0x333344, 1) - .setOrigin(0, 0.5); - - this.spacingSliderFill = this.add.rectangle(0, sliderY, 1, this.ARC_SLIDER_HEIGHT, 0x88ff88, 1) - .setOrigin(0, 0.5); - - this.spacingSliderHandle = this.add.graphics(); - - this.spacingSliderValueText = createHudText(this, 0, sliderY - 20, '', '#88ff88', { fontSize: '11px' }).setOrigin(0.5); - - this.spacingSliderHitArea = this.add.zone(0, sliderY, this.ARC_SLIDER_WIDTH + 24, 28) - .setInteractive({ useHandCursor: true }); - - this.spacingSliderHitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - this.isSpacingSliderDragging = true; - this.setSpacingFromPointer(pointer.x); + this.spacingSlider.onValueChange = (value: number) => { + this.handView.setSpacing(Math.round(value)); + }; + + this.rotationSlider = createSlider(this, rotationSliderX, sliderY, { + initialValue: this.ROTATION_DEGREES_DEFAULT, + minValue: 0, + maxValue: 45, + label: 'Rotation', + width: sliderWidth, + textColor: '#88ff88', }); - - this.input.on('pointermove', this.handleSpacingSliderPointerMove, this); - this.input.on('pointerup', this.handleSpacingSliderPointerUp, this); - - this.events.once('shutdown', () => { - this.input.off('pointermove', this.handleSpacingSliderPointerMove, this); - this.input.off('pointerup', this.handleSpacingSliderPointerUp, this); + this.rotationSlider.onValueChange = (value: number) => { + this.handView.setMaxRotationDegrees(value); + }; + + // Wire global input events to forward drag events to all sliders + this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => { + this.arcSlider.handlePointerMove(pointer.x); + this.spacingSlider.handlePointerMove(pointer.x); + this.rotationSlider.handlePointerMove(pointer.x); }); - - this.updateSpacingSliderPosition(); - this.updateSpacingSliderVisuals(); - } - - private handleSpacingSliderPointerMove(pointer: Phaser.Input.Pointer): void { - if (!this.isSpacingSliderDragging) return; - this.setSpacingFromPointer(pointer.x); - } - - private handleSpacingSliderPointerUp(): void { - this.isSpacingSliderDragging = false; - } - - private setSpacingFromPointer(pointerX: number): void { - if (!this.spacingSliderTrack || !this.spacingSliderValueText) return; - - const minSpacing = Math.round(CARD_W * (1 - 0.75)); - const maxSpacing = Math.round(CARD_W * (1 + 0.75)); - - const minX = this.spacingSliderTrack.x; - const maxX = minX + this.ARC_SLIDER_WIDTH; - const clampedX = Math.max(minX, Math.min(maxX, pointerX)); - const ratio = (clampedX - minX) / this.ARC_SLIDER_WIDTH; - const nextSpacing = Math.round(minSpacing + ratio * (maxSpacing - minSpacing)); - - this.handView.setSpacing(nextSpacing); - this.updateSpacingSliderVisuals(); - } - - private updateSpacingSliderPosition(): void { - if (!this.spacingSliderTrack || !this.spacingSliderFill || !this.spacingSliderHitArea || !this.spacingSliderValueText) { - return; - } - - const trackX = this.SPACING_SLIDER_X; - - this.spacingSliderTrack.setPosition(trackX, this.SLIDER_Y); - this.spacingSliderFill.setPosition(trackX, this.SLIDER_Y); - this.spacingSliderHitArea.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y); - this.spacingSliderValueText.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y - 20); - - this.updateSpacingSliderVisuals(); - // Also update rotation slider position so all sliders track the hand - try { this.updateRotationSliderPosition(); } catch (_) { /* rotation slider may not be initialised */ } - } - - private updateSpacingSliderVisuals(): void { - if (!this.spacingSliderTrack || !this.spacingSliderFill || !this.spacingSliderHandle || !this.spacingSliderValueText) { - return; - } - - const minSpacing = Math.round(CARD_W * (1 - 0.75)); - const maxSpacing = Math.round(CARD_W * (1 + 0.75)); - const cur = this.handView.getSpacing ? this.handView.getSpacing() : this.HAND_SPACING; - - const ratio = (cur - minSpacing) / (maxSpacing - minSpacing); - const clampedRatio = Math.max(0, Math.min(1, ratio)); - const fillWidth = Math.max(1, this.ARC_SLIDER_WIDTH * clampedRatio); - const handleX = this.spacingSliderTrack.x + fillWidth; - const handleY = this.spacingSliderTrack.y; - - this.spacingSliderFill.setSize(fillWidth, this.ARC_SLIDER_HEIGHT); - this.spacingSliderFill.setPosition(this.spacingSliderTrack.x, handleY); - - this.spacingSliderHandle.clear(); - this.spacingSliderHandle.fillStyle(0xffffff, 1); - this.spacingSliderHandle.fillCircle(handleX, handleY, 8); - this.spacingSliderHandle.lineStyle(2, 0x88ff88, 1); - this.spacingSliderHandle.strokeCircle(handleX, handleY, 8); - - this.spacingSliderValueText.setText(`Spacing: ${Math.round(cur)}`); - } - - // ── Rotation slider ─────────────────────────────────── - - private createRotationSlider(): void { - const sliderY = this.SLIDER_Y; - - this.rotationSliderTrack = this.add.rectangle(0, sliderY, this.ARC_SLIDER_WIDTH, this.ARC_SLIDER_HEIGHT, 0x333344, 1) - .setOrigin(0, 0.5); - - this.rotationSliderFill = this.add.rectangle(0, sliderY, 1, this.ARC_SLIDER_HEIGHT, 0x88ff88, 1) - .setOrigin(0, 0.5); - - this.rotationSliderHandle = this.add.graphics(); - - this.rotationSliderValueText = createHudText(this, 0, sliderY - 20, '', '#88ff88', { fontSize: '11px' }).setOrigin(0.5); - - this.rotationSliderHitArea = this.add.zone(0, sliderY, this.ARC_SLIDER_WIDTH + 24, 28) - .setInteractive({ useHandCursor: true }); - - this.rotationSliderHitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { - this.isRotationSliderDragging = true; - this.setRotationFromPointer(pointer.x); + this.input.on('pointerup', () => { + this.arcSlider.handlePointerUp(); + this.spacingSlider.handlePointerUp(); + this.rotationSlider.handlePointerUp(); }); - this.input.on('pointermove', this.handleRotationSliderPointerMove, this); - this.input.on('pointerup', this.handleRotationSliderPointerUp, this); - this.events.once('shutdown', () => { - this.input.off('pointermove', this.handleRotationSliderPointerMove, this); - this.input.off('pointerup', this.handleRotationSliderPointerUp, this); + this.input.off('pointermove'); + this.input.off('pointerup'); }); - this.updateRotationSliderPosition(); - this.updateRotationSliderVisuals(); - } - - private handleRotationSliderPointerMove(pointer: Phaser.Input.Pointer): void { - if (!this.isRotationSliderDragging) return; - this.setRotationFromPointer(pointer.x); - } - - private handleRotationSliderPointerUp(): void { - this.isRotationSliderDragging = false; - } - - private setRotationFromPointer(pointerX: number): void { - if (!this.rotationSliderTrack || !this.rotationSliderValueText) return; - - const minX = this.rotationSliderTrack.x; - const maxX = minX + this.ARC_SLIDER_WIDTH; - const clampedX = Math.max(minX, Math.min(maxX, pointerX)); - const ratio = (clampedX - minX) / this.ARC_SLIDER_WIDTH; - const nextRotation = this.ROTATION_DEGREES_MIN + ratio * (this.ROTATION_DEGREES_MAX - this.ROTATION_DEGREES_MIN); - - this.handView.setMaxRotationDegrees(nextRotation); - this.updateRotationSliderVisuals(); - } - - private updateRotationSliderPosition(): void { - if (!this.rotationSliderTrack || !this.rotationSliderFill || !this.rotationSliderHitArea || !this.rotationSliderValueText) { - return; - } - - const trackX = this.ROTATION_SLIDER_X; - - this.rotationSliderTrack.setPosition(trackX, this.SLIDER_Y); - this.rotationSliderFill.setPosition(trackX, this.SLIDER_Y); - this.rotationSliderHitArea.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y); - this.rotationSliderValueText.setPosition(trackX + this.ARC_SLIDER_WIDTH / 2, this.SLIDER_Y - 20); - - this.updateRotationSliderVisuals(); - } - - private updateRotationSliderVisuals(): void { - if (!this.rotationSliderTrack || !this.rotationSliderFill || !this.rotationSliderHandle || !this.rotationSliderValueText) { - return; - } - - const cur = this.handView.getMaxRotationDegrees ? this.handView.getMaxRotationDegrees() : this.ROTATION_DEGREES_DEFAULT; - - const ratio = cur / this.ROTATION_DEGREES_MAX; - const clampedRatio = Math.max(0, Math.min(1, ratio)); - const fillWidth = Math.max(1, this.ARC_SLIDER_WIDTH * clampedRatio); - const handleX = this.rotationSliderTrack.x + fillWidth; - const handleY = this.rotationSliderTrack.y; - - this.rotationSliderFill.setSize(fillWidth, this.ARC_SLIDER_HEIGHT); - this.rotationSliderFill.setPosition(this.rotationSliderTrack.x, handleY); - - this.rotationSliderHandle.clear(); - this.rotationSliderHandle.fillStyle(0xffffff, 1); - this.rotationSliderHandle.fillCircle(handleX, handleY, 8); - this.rotationSliderHandle.lineStyle(2, 0x88ff88, 1); - this.rotationSliderHandle.strokeCircle(handleX, handleY, 8); - - this.rotationSliderValueText.setText(`Rotation: ${Math.round(cur)}°`); + // Initialize + this.reset(); } private getHandPositionForIndex(index: number, handCount: number): { x: number; y: number } { @@ -530,7 +268,6 @@ export class GymHandPileScene extends GymSceneBase { // Instant placement for reduced motion this.handView.setCards(this.hand); this.handView.setSelected(this.selectedIdx >= 0 ? this.selectedIdx : null); - this.updateArcSliderPosition(); this.deckView.update(); this.logEvent(`Drew ${card.rank}${card.suit} to hand (instant, reduced-motion)`); return; @@ -544,7 +281,6 @@ export class GymHandPileScene extends GymSceneBase { try { animSprite.destroy(); } catch (_) { /* ignore */ } this.handView.setCards(this.hand); this.handView.setSelected(this.selectedIdx >= 0 ? this.selectedIdx : null); - this.updateArcSliderPosition(); this.deckView.update(); gameEvents.removeAllListeners(); this.logEvent(`Drew ${card.rank}${card.suit} to hand (animated)`); @@ -588,7 +324,6 @@ export class GymHandPileScene extends GymSceneBase { this.clearHighlights(); this.handView.setCards(this.hand); this.handView.setSelected(null); - this.updateArcSliderPosition(); this.discardView.update(); (gameEvents as any).removeAllListeners(); this.logEvent(`Discarded ${card.rank}${card.suit} (animated)`); @@ -614,7 +349,6 @@ export class GymHandPileScene extends GymSceneBase { this.clearHighlights(); this.handView.setCards(this.hand); this.handView.setSelected(null); - this.updateArcSliderPosition(); this.discardView.update(); this.logEvent(`Discarded ${card.rank}${card.suit} (instant)`); } @@ -637,7 +371,6 @@ export class GymHandPileScene extends GymSceneBase { if (this.reducedMotion) { this.handView.setCards(this.hand); this.handView.setSelected(this.selectedIdx >= 0 ? this.selectedIdx : null); - this.updateArcSliderPosition(); this.discardView.update(); this.logEvent(`Recalled ${card.rank}${card.suit} from discard (instant)`); return; @@ -650,7 +383,6 @@ export class GymHandPileScene extends GymSceneBase { try { animSprite.destroy(); } catch (_) {} this.handView.setCards(this.hand); this.handView.setSelected(this.selectedIdx >= 0 ? this.selectedIdx : null); - this.updateArcSliderPosition(); this.discardView.update(); gameEvents.removeAllListeners(); this.logEvent(`Recalled ${card.rank}${card.suit} from discard (animated)`); @@ -842,19 +574,18 @@ export class GymHandPileScene extends GymSceneBase { this.clearHighlights(); this.cancelMove(); - // Reset arc slider to default on scene reset (no persistence). + // Reset sliders to defaults this.arcRadius = this.ARC_RADIUS_DEFAULT; + this.arcSlider.setValue(this.ARC_RADIUS_DEFAULT); this.handView.setArcRadius(this.arcRadius); - this.updateArcSliderVisuals(); - // Reset rotation slider to default (backwards-compatible: 0 = no tilt) + // Reset rotation slider to default + this.rotationSlider.setValue(this.ROTATION_DEGREES_DEFAULT); this.handView.setMaxRotationDegrees(this.ROTATION_DEGREES_DEFAULT); - try { this.updateRotationSliderVisuals(); } catch (_) { /* ignore */ } // Sync UI components this.handView.setCards(this.hand); this.handView.setSelected(null); - this.updateArcSliderPosition(); this.deckView.setPile(this.drawPile); this.discardView.setPile(this.discardPile); diff --git a/example-games/gym/scenes/GymOverlayUiScene.ts b/example-games/gym/scenes/GymOverlayUiScene.ts index e5fe5375..678afb99 100644 --- a/example-games/gym/scenes/GymOverlayUiScene.ts +++ b/example-games/gym/scenes/GymOverlayUiScene.ts @@ -18,14 +18,16 @@ import { GYM_OVERLAY_UI_KEY } from '../GymRegistry'; import { GAME_W } from '../../../src/ui/constants'; import { createOverlayBackground, dismissOverlay } from '../../../src/ui/Overlay'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; export class GymOverlayUiScene extends GymSceneBase { private overlayObjects: Phaser.GameObjects.GameObject[] | null = null; private overlayOpen = false; private feedbackIntensity = 1.0; private intensityText!: Phaser.GameObjects.Text; - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; + private eventLogResult!: EventLogResult; private overlayIntensityText: Phaser.GameObjects.Text | null = null; // Mask references for GeometryMask demo @@ -70,7 +72,16 @@ export class GymOverlayUiScene extends GymSceneBase { this.intensityText.setOrigin(0.5); y += 30; - createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Event Log ──', + maxLines: 14, + lineHeight: 17, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); } private openOverlay(): void { @@ -268,12 +279,6 @@ export class GymOverlayUiScene extends GymSceneBase { private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 14) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 150; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = createHudText(this, 40, baseY + i * 17, this.eventLog[i], '#aaddaa', { fontSize: '11px' }); - this.logTexts.push(txt); - } + this.eventLogResult.render(this.eventLog); } } \ No newline at end of file diff --git a/example-games/gym/scenes/GymSaveLoadScene.ts b/example-games/gym/scenes/GymSaveLoadScene.ts index a440a5bb..62e2f7b8 100644 --- a/example-games/gym/scenes/GymSaveLoadScene.ts +++ b/example-games/gym/scenes/GymSaveLoadScene.ts @@ -3,11 +3,12 @@ * the core-engine SaveLoadStore API. * * Features: - * - Save current scene state to persistent storage + * - Save current scene state (hand of cards + screenshot) to persistent storage * - Load and restore saved state * - Handle malformed save payloads safely - * - Verify state invariants after restore - * - RenderTexture snapshot on save (with headless fallback) + * - Full-screen RenderTexture screenshot captured and displayed as thumbnail + * - Hand display via reusable HandView component (arc layout, lower centre) + * - Add Card button deals a random card; score totals card values * * @module example-games/gym/scenes/GymSaveLoadScene */ @@ -18,50 +19,104 @@ import { SaveLoadStore, } from '../../../src/core-engine'; import type { SaveSerializer } from '../../../src/core-engine'; -import { GAME_W } from '../../../src/ui/constants'; +import { GAME_W, GAME_H, CARD_W, CARD_H } from '../../../src/ui/constants'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; +import { createCard, shuffleArray, createStandardDeck, rankValue } from '../../../src/card-system'; +import type { Card, Rank, Suit } from '../../../src/card-system'; +import { ensureCardTextureFallbacks, preloadCardAssets } from '../../../src/ui/CardTextureHelpers'; +import { HandView } from '../../../src/ui/HandView'; -/** Simple state for this demo. */ +// ── Card score: A=1, 2=2, ..., J=11, Q=12, K=13 ──────────── +function cardScore(rank: Rank): number { + return rankValue(rank) + 1; +} + +// ── State & serialisation types ──────────────────────────── + +/** State tracked by this demo scene. */ interface DemoState { - counter: number; - label: string; + hand: Card[]; + /** Base64 data URL of the screenshot thumbnail, or null. */ + screenshotDataUrl: string | null; } +/** Wire format for save/load persistence. */ interface DemoSerialized { - c: number; - l: string; + h: Array<{ r: string; s: string }>; + sd: string | null; } const DEMO_SERIALIZER: SaveSerializer = { schemaVersion: 1, serialize(state: DemoState): DemoSerialized { - return { c: state.counter, l: state.label }; + return { + h: state.hand.map((c) => ({ r: c.rank, s: c.suit })), + sd: state.screenshotDataUrl, + }; }, deserialize(data: DemoSerialized): DemoState { - return { counter: data.c, label: data.l }; + const hand = data.h.map((c) => createCard(c.r as Rank, c.s as Suit, true)); + return { hand, screenshotDataUrl: data.sd ?? null }; }, }; const GAME_TYPE = 'gym-save-load'; const SLOT_ID = 'demo-slot'; +/** Number of cards dealt to the starting hand. */ +const STARTING_HAND_SIZE = 5; + +/** Card spacing & arc for HandView (lower centre of screen). */ +const HAND_SPACING = 74; +const HAND_ARC_RADIUS = 350; + +/* + * HandView baseX is computed at runtime so the full hand is centred. + * baseY is the Y centre of the first card; we place it a card-height + * below the previous position. + */ +const HAND_BASE_Y = GAME_H * 0.65 + CARD_H; // ~598 + +/** Full-screen RenderTexture for the screenshot, displayed at this scale. */ +const SCREENSHOT_THUMB_SCALE = 0.25; + export class GymSaveLoadScene extends GymSceneBase { - private state: DemoState = { counter: 0, label: 'initial' }; + private state: DemoState = { hand: [], screenshotDataUrl: null }; private store!: SaveLoadStore; private stateText!: Phaser.GameObjects.Text; private backendText!: Phaser.GameObjects.Text; - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; - // RenderTexture thumbnail - private thumbnailImage: Phaser.GameObjects.Image | null = null; - private _snapshotAvailable = false; - /** Whether a snapshot is currently displayed. Read-only for external checks. */ - get snapshotAvailable(): boolean { return this._snapshotAvailable; } + private eventLogResult!: EventLogResult; + /** Reusable HandView component for the hand display. */ + private handView!: HandView; + /** Source deck used for dealing random cards. */ + private deck: Card[] = []; + private screenshotPlaceholder: Phaser.GameObjects.Text | null = null; + /** The RenderTexture used as the current screenshot display (if any). */ + private screenshotDisplay: Phaser.GameObjects.RenderTexture | null = null; + /** An Image created from a loaded screenshot data URL (used during load restore). */ + private screenshotImage: Phaser.GameObjects.Image | null = null; + private _screenshotAvailable = false; + /** Pending screenshot callback data URL (set async by snapshot()); null if none. */ + private _pendingScreenshotDataUrl: string | null = null; + + /** Whether a screenshot is currently displayed. Read-only for external checks. */ + get screenshotAvailable(): boolean { return this._screenshotAvailable; } constructor() { super({ key: GYM_SAVE_LOAD_KEY }); } + /** + * Preload the real card SVG assets from the shared card sprite set. + * Falls back to placeholders if the SVGs are unavailable. + */ + preload(): void { + preloadCardAssets(this, CARD_W, CARD_H); + } + async create(): Promise { this.cameras.main.setBackgroundColor('#1a2a1a'); this.initHeader('Save / Load State'); @@ -69,31 +124,60 @@ export class GymSaveLoadScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Demonstrates saving and loading scene state via the SaveLoadStore API. Includes handling malformed payloads, RenderTexture snapshots, and verifying invariants after restore.' }, - { heading: 'Controls', body: '[ Increment ]: Mutate demo state.\n[ Set Label ]: Update label to reflect counter.\n[ Save State ]: Persist current state (with optional snapshot).\n[ Load State ]: Restore last saved state.\n[ Load Malformed ]: Simulate a bad payload to verify error handling.\n[ Clear Save ]: Remove persisted save data.\n[ Snapshot ]: Attempt a RenderTexture snapshot of the scene.' } + { heading: 'Overview', body: 'Demonstrates saving and loading scene state via the SaveLoadStore API. Includes handling malformed payloads, full-screen RenderTexture screenshots, a hand of cards displayed via HandView, and verifying invariants after restore.' }, + { heading: 'Controls', body: '[ Add Card ]: Deal a random card to the hand.\n[ Save State ]: Persist current hand + screenshot.\n[ Load State ]: Restore last saved hand + screenshot.\n[ Load Malformed ]: Simulate a bad payload to verify error handling.\n[ Clear Save ]: Remove persisted save data.\n[ Take Screenshot ]: Capture a full-screen RenderTexture screenshot.\n[ Clear Screenshot ]: Remove the screenshot thumbnail.' }, ]); + // Generate fallback card textures if the real SVGs did not load + ensureCardTextureFallbacks(this); + + // Initialise the source deck and deal the starting hand, centred + this.deck = shuffleArray(createStandardDeck()); + const initialHand: Card[] = []; + for (let i = 0; i < STARTING_HAND_SIZE; i++) { + const card = this.deck.pop()!; + card.faceUp = true; + initialHand.push(card); + } + this.state = { hand: initialHand, screenshotDataUrl: null }; + this.store = new SaveLoadStore({ dbName: 'gym-save-load', localStoragePrefix: 'gym-sl' }); + // ── HandView (lower centre, arc, full-size cards) ───────── + const handWidth = (STARTING_HAND_SIZE - 1) * HAND_SPACING; + const handBaseX = GAME_W / 2 - handWidth / 2; + + this.handView = new HandView(this, { + baseX: handBaseX, + baseY: HAND_BASE_Y, + spacing: HAND_SPACING, + cardWidth: CARD_W, + arcRadius: HAND_ARC_RADIUS, + showLabels: false, + selectionEnabled: false, + clickEnabled: false, + }); + this.handView.setCards(this.state.hand); + + // ── Buttons ─────────────────────────────────────────────── const cx = GAME_W / 2; let y = 60; - this.addButton(cx - 400, y, '[ Increment ]', () => this.increment()); - this.addButton(cx - 260, y, '[ Set Label ]', () => this.setLabel()); - this.addButton(cx - 110, y, '[ Save State ]', () => this.saveState()); - this.addButton(cx + 50, y, '[ Load State ]', () => this.loadState()); - this.addButton(cx + 200, y, '[ Load Malformed ]', () => this.loadMalformed()); - this.addButton(cx + 400, y, '[ Clear Save ]', () => this.clearSave()); + this.addButton(cx - 400, y, '[ Add Card ]', () => this.addCard()); + this.addButton(cx - 240, y, '[ Save State ]', () => this.saveState()); + this.addButton(cx - 80, y, '[ Load State ]', () => this.loadState()); + this.addButton(cx + 80, y, '[ Load Malformed ]', () => this.loadMalformed()); + this.addButton(cx + 240, y, '[ Clear Save ]', () => this.clearSave()); y += 26; - this.addButton(cx - 300, y, '[ Take Snapshot ]', () => this.takeSnapshot()); - this.addButton(cx - 100, y, '[ Clear Snapshot ]', () => this.clearSnapshot()); + this.addButton(cx - 300, y, '[ Take Screenshot ]', () => this.takeScreenshot()); + this.addButton(cx - 100, y, '[ Clear Screenshot ]', () => this.clearScreenshot()); + // ── State text ──────────────────────────────────────────── y += 40; try { this.stateText = createHudText(this, cx, y, this.stateString(), '#ffffff', { fontSize: '18px' }).setOrigin(0.5); } catch (e) { - // Fallback to label if text texture creation fails in some headless environments this.stateText = this.addLabel(cx, y, this.stateString(), { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5); } @@ -101,7 +185,6 @@ export class GymSaveLoadScene extends GymSceneBase { this.backendText = createHudText(this, cx, y, 'Storage: checking...', '#888888', { fontSize: '12px' }); this.backendText.setOrigin(0.5); - // Check backend const backendName = await this.store.getBackendName(); try { this.backendText.setText(`Storage backend: ${backendName ?? 'none'}`); @@ -109,38 +192,66 @@ export class GymSaveLoadScene extends GymSceneBase { // Ignore text set errors in headless environments } + // ── Event log (reduced lines to leave room for screenshot) ─ y += 20; if (this.sys && this.sys.isActive && this.sys.isActive()) { - createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Event Log ──', + maxLines: 8, + lineHeight: 17, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); } } + // ── State helpers ─────────────────────────────────────────── + + private handSize(): number { + return this.state.hand.length; + } + + private score(): number { + return this.state.hand.reduce((sum, c) => sum + cardScore(c.rank), 0); + } + private stateString(): string { - return `Counter: ${this.state.counter} | Label: "${this.state.label}"`; + return `Hand size: ${this.handSize()} | Score: ${this.score()}`; } - private increment(): void { - this.state.counter++; + private updateStateDisplay(): void { try { this.stateText.setText(this.stateString()); } catch (e) { // Ignore text update errors during headless tests } - this.logEvent(`Counter incremented to ${this.state.counter}`); } - private setLabel(): void { - this.state.label = `label-${this.state.counter}`; - try { - this.stateText.setText(this.stateString()); - } catch (e) { - // Ignore text update errors during headless tests + // ── Card actions ───────────────────────────────────────────── + + private addCard(): void { + if (this.deck.length === 0) { + this.deck = shuffleArray(createStandardDeck()); } - this.logEvent(`Label set to "${this.state.label}"`); + const card = this.deck.pop()!; + card.faceUp = true; + this.state.hand.push(card); + this.handView.addCard(card); + this.updateStateDisplay(); + this.logEvent(`Added ${card.rank} of ${card.suit} (score +${cardScore(card.rank)})`); } + // ── Save / Load ────────────────────────────────────────────── + private async saveState(): Promise { try { + // Adopt any pending screenshot data from the async snapshot callback + if (this._pendingScreenshotDataUrl) { + this.state.screenshotDataUrl = this._pendingScreenshotDataUrl; + } const result = await this.store.saveSerialized( 'run-checkpoint', GAME_TYPE, @@ -168,12 +279,17 @@ export class GymSaveLoadScene extends GymSceneBase { ); if (loaded) { this.state = loaded; - try { - this.stateText.setText(this.stateString()); - } catch (e) { - // Ignore text update errors during headless tests + // Ensure all loaded cards are face-up + for (const c of this.state.hand) c.faceUp = true; + this.handView.setCards(this.state.hand); + this.updateStateDisplay(); + // Recreate screenshot thumbnail from persisted data + if (this.state.screenshotDataUrl) { + this.recreateScreenshot(this.state.screenshotDataUrl); + } else { + this.clearScreenshot(); } - this.logEvent(`Loaded: counter=${this.state.counter}, label="${this.state.label}"`); + this.logEvent(`Loaded: hand size=${this.handSize()}, score=${this.score()}`); } else { this.logEvent('No save data found'); } @@ -183,7 +299,6 @@ export class GymSaveLoadScene extends GymSceneBase { } private async loadMalformed(): Promise { - // Simulate a malformed payload by writing incompatible version then loading try { await this.store.save('run-checkpoint', GAME_TYPE, SLOT_ID, 99, { schemaVersion: 99, @@ -195,19 +310,15 @@ export class GymSaveLoadScene extends GymSceneBase { SLOT_ID, DEMO_SERIALIZER, ); - // Should have thrown this.logEvent('Unexpected: malformed load succeeded without error'); if (loaded) { this.state = loaded; - try { - this.stateText.setText(this.stateString()); - } catch (e) { - // Ignore text update errors during headless tests - } + for (const c of this.state.hand) c.faceUp = true; + this.handView.setCards(this.state.hand); + this.updateStateDisplay(); } } catch (e) { this.logEvent(`Malformed payload caught: ${(e as Error).message}`); - // Clean up the bad save await this.store.remove('run-checkpoint', GAME_TYPE, SLOT_ID); } } @@ -221,58 +332,117 @@ export class GymSaveLoadScene extends GymSceneBase { } } - // ── RenderTexture snapshot demo ───────────────────────────── + // ── RenderTexture screenshot (full-screen) ─────────────────── - private takeSnapshot(): void { - // Clear any existing thumbnail - this.clearSnapshot(); + private takeScreenshot(): void { + this.clearScreenshot(); try { - // Attempt to create a RenderTexture snapshot of a representative area - const rt = this.add.renderTexture(0, 0, 200, 150); - // Draw the current scene camera into the render texture - rt.draw(this.children.getAll(), 0, 0); - // Scale down the render texture for display as a thumbnail - rt.setScale(0.5); - rt.setPosition(GAME_W / 2 - 100, 340); - - this.thumbnailImage = this.add.image(GAME_W / 2 - 100, 340, ''); - this._snapshotAvailable = true; - this.logEvent('Snapshot taken (RenderTexture 200x150)'); + // Capture the entire game canvas into a full-size RenderTexture. + // By leaving the RT camera at default scroll (0,0) and making the + // RT the same size as the game, all scene children are captured at + // their world positions through the main scene camera. + const rt = this.add.renderTexture(0, 0, GAME_W, GAME_H); + + // Exclude rt itself from the draw + const drawables = this.children.getAll().filter((child) => child !== rt); + rt.draw(drawables, 0, 0); + rt.render(); + + rt.saveTexture('screenshot-thumb'); + + // Display as a thumbnail centred below the controls + rt.setPosition(GAME_W / 2, 360); + rt.setScale(SCREENSHOT_THUMB_SCALE); + + this.screenshotDisplay = rt; + this._screenshotAvailable = true; + + // Extract a base64 data URL for persistence. + rt.snapshot((snapshot: Phaser.Display.Color | HTMLImageElement) => { + if (!(snapshot instanceof HTMLImageElement)) return; + try { + const canvas = document.createElement('canvas'); + canvas.width = snapshot.naturalWidth || GAME_W; + canvas.height = snapshot.naturalHeight || GAME_H; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(snapshot, 0, 0); + this._pendingScreenshotDataUrl = canvas.toDataURL('image/png'); + } + } catch (_) { + console.warn('[GymSaveLoadScene] Failed to extract screenshot data URL'); + } + }, 'image/png'); + + this.logEvent('Screenshot taken (full-screen)'); } catch (e) { - // Headless/non-canvas environments: show textual placeholder - this.logEvent(`Snapshot fallback (headless): ${(e as Error).message?.substring(0, 50) ?? 'RenderTexture unavailable'}`); - // Show a textual placeholder instead - const placeholder = createHudText(this, GAME_W / 2, 340, '[ Snapshot: Text Placeholder ]', '#888888', { fontSize: '12px' }).setOrigin(0.5); - this.logTexts.push(placeholder); - this._snapshotAvailable = false; + this.logEvent(`Screenshot fallback (headless): ${(e as Error).message?.substring(0, 50) ?? 'RenderTexture unavailable'}`); + this.screenshotPlaceholder = createHudText(this, GAME_W / 2, 360, '[ Screenshot: Text Placeholder ]', '#888888', { fontSize: '12px' }).setOrigin(0.5); + this._screenshotAvailable = false; + } + } + + /** Remove the screenshot display objects without touching state data. */ + private removeScreenshotDisplay(): void { + if (this.screenshotDisplay) { + try { this.screenshotDisplay.destroy(); } catch (_) { /* ignore */ } + this.screenshotDisplay = null; + } + if (this.screenshotImage) { + try { this.screenshotImage.destroy(); } catch (_) { /* ignore */ } + this.screenshotImage = null; + } + if (this.screenshotPlaceholder) { + try { this.screenshotPlaceholder.destroy(); } catch (_) { /* ignore */ } + this.screenshotPlaceholder = null; } } - private clearSnapshot(): void { - // Remove any existing thumbnail - if (this.thumbnailImage) { - try { this.thumbnailImage.destroy(); } catch (_) { /* ignore */ } - this.thumbnailImage = null; + private clearScreenshot(): void { + this.removeScreenshotDisplay(); + this.state.screenshotDataUrl = null; + this._pendingScreenshotDataUrl = null; + this._screenshotAvailable = false; + } + + /** + * Recreate a screenshot thumbnail from a persisted base64 data URL. + * Called when loading a saved state that includes screenshot data. + */ + private recreateScreenshot(dataUrl: string): void { + this.removeScreenshotDisplay(); + try { + const img = new Image(); + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || GAME_W; + canvas.height = img.naturalHeight || GAME_H; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0); + this.textures.addCanvas('screenshot-loaded', canvas); + this.screenshotImage = this.add.image(GAME_W / 2, 360, 'screenshot-loaded'); + this.screenshotImage.setScale(SCREENSHOT_THUMB_SCALE); + this._screenshotAvailable = true; + } + } catch (e) { + console.warn('[GymSaveLoadScene] Failed to draw loaded screenshot onto canvas'); + } + }; + img.onerror = () => { + console.warn('[GymSaveLoadScene] Failed to decode screenshot image data'); + }; + img.src = dataUrl; + } catch (e) { + console.warn('[GymSaveLoadScene] Failed to recreate screenshot from loaded data'); } - this._snapshotAvailable = false; } private logEvent(msg: string): void { this.eventLog.push(msg); - if (this.eventLog.length > 14) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 170; - for (let i = 0; i < this.eventLog.length; i++) { - try { - const txt = createHudText(this, 40, baseY + i * 17, this.eventLog[i], '#aaddaa', { fontSize: '11px' }); - this.logTexts.push(txt); - } catch (e) { - // Fallback to label if text texture creation fails - const txt = this.addLabel(40, baseY + i * 17, this.eventLog[i], { fontSize: '11px', color: '#aaddaa' }); - this.logTexts.push(txt); - } - } + if (this.eventLog.length > 8) this.eventLog.shift(); + this.eventLogResult.render(this.eventLog); } -} \ No newline at end of file +} diff --git a/example-games/gym/scenes/GymTranscriptScene.ts b/example-games/gym/scenes/GymTranscriptScene.ts index 9bec3e16..439abd1b 100644 --- a/example-games/gym/scenes/GymTranscriptScene.ts +++ b/example-games/gym/scenes/GymTranscriptScene.ts @@ -19,7 +19,8 @@ import { import type { BaseTranscript } from '../../../src/core-engine'; import { popTextOrIcon } from '../../../src/ui/popTextOrIcon'; import { GAME_W } from '../../../src/ui/constants'; -import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; /** Simple event shape for this demo. */ interface DemoTranscriptEvent { @@ -68,8 +69,8 @@ export class GymTranscriptScene extends GymSceneBase { private rng: (() => number) | null = null; private seed = 42; private eventCount = 0; - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; + private eventLogResult!: EventLogResult; constructor() { super({ key: GYM_TRANSCRIPT_KEY }); @@ -96,7 +97,16 @@ export class GymTranscriptScene extends GymSceneBase { this.addButton(cx + 200, y, '[ Show Transcript ]', () => this.showTranscript()); y += 40; - createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Event Log ──', + maxLines: 16, + lineHeight: 16, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); this.newSession(); } @@ -165,13 +175,7 @@ export class GymTranscriptScene extends GymSceneBase { private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 16) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 140; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = createHudText(this, 40, baseY + i * 16, this.eventLog[i], '#aaddaa', { fontSize: '11px' }); - this.logTexts.push(txt); - } + this.eventLogResult.render(this.eventLog); } /** Show a pop text near the event log area. */ diff --git a/example-games/gym/scenes/GymUndoRedoScene.ts b/example-games/gym/scenes/GymUndoRedoScene.ts index 37afc095..0208ca4c 100644 --- a/example-games/gym/scenes/GymUndoRedoScene.ts +++ b/example-games/gym/scenes/GymUndoRedoScene.ts @@ -18,6 +18,8 @@ import type { Command } from '../../../src/core-engine/UndoRedoManager'; import { popTextOrIcon } from '../../../src/ui/popTextOrIcon'; import { GAME_W } from '../../../src/ui/constants'; import { createHudText } from '../../../src/ui/Renderer'; +import { createEventLog } from '../../../src/ui/GymSceneUtils'; +import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; /** A simple command that increments/decrements a counter. */ class IncrementCommand implements Command { @@ -45,8 +47,8 @@ export class GymUndoRedoScene extends GymSceneBase { private undoAvailText!: Phaser.GameObjects.Text; private redoAvailText!: Phaser.GameObjects.Text; private historyText!: Phaser.GameObjects.Text; - private logTexts: Phaser.GameObjects.Text[] = []; private eventLog: string[] = []; + private eventLogResult!: EventLogResult; constructor() { super({ key: GYM_UNDO_REDO_KEY }); @@ -90,7 +92,16 @@ export class GymUndoRedoScene extends GymSceneBase { this.historyText.setOrigin(0.5); y += 20; - createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); + this.eventLogResult = createEventLog(this, y + 20, { + headerText: '── Event Log ──', + maxLines: 12, + lineHeight: 17, + textColor: '#aaddaa', + fontSize: '11px', + headerFontSize: '12px', + headerColor: '#669966', + lineX: 40, + }); } private executeAction(delta: number): void { @@ -155,13 +166,7 @@ export class GymUndoRedoScene extends GymSceneBase { private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 12) this.eventLog.shift(); - for (const t of this.logTexts) t.destroy(); - this.logTexts = []; - const baseY = 250; - for (let i = 0; i < this.eventLog.length; i++) { - const txt = createHudText(this, 40, baseY + i * 17, this.eventLog[i], '#aaddaa', { fontSize: '11px' }); - this.logTexts.push(txt); - } + this.eventLogResult.render(this.eventLog); } /** Show a pop text/icon near the specified coordinates. */ diff --git a/example-games/main-street/MainStreetAdjacency.ts b/example-games/main-street/MainStreetAdjacency.ts index 38cafdd6..82dce771 100644 --- a/example-games/main-street/MainStreetAdjacency.ts +++ b/example-games/main-street/MainStreetAdjacency.ts @@ -12,7 +12,7 @@ import type { BusinessCard, SynergyType } from './MainStreetCards'; import { GRID_SIZE, SYNERGY_BONUS_PER_NEIGHBOR } from './MainStreetCards'; import type { MainStreetState } from './MainStreetState'; -import { addLog } from './MainStreetState'; +import { addLog, syncResourceBankToLedger } from './MainStreetState'; import { applyReputationMultiplier } from './MainStreetDifficulty'; // ── Adjacency Resolver ────────────────────────────────────── @@ -180,6 +180,7 @@ export function applyIncome(state: MainStreetState): IncomeResult { state.config, ); state.resourceBank.coins += multiplied; + syncResourceBankToLedger(state); if (multiplied > 0) { addLog(state, `Income: +${multiplied} coins`, 'gain'); } else { diff --git a/example-games/main-street/MainStreetCommands.ts b/example-games/main-street/MainStreetCommands.ts index 9df2d193..f0434dc5 100644 --- a/example-games/main-street/MainStreetCommands.ts +++ b/example-games/main-street/MainStreetCommands.ts @@ -1,4 +1,15 @@ -import type { Command } from '../../src/core-engine/UndoRedoManager'; +/** + * Main Street: Action Commands + * + * Reversible command wrappers for market actions, built using the shared + * ActionCommands adapter (`toCommand` / `ReversibleAction`) from the + * core engine. Each command captures a pre-snapshot on first execute + * and restores it on undo. + * + * @module + */ + +import { toCommand, type ReversibleAction } from '../../src/core-engine/ActionCommands'; import type { MainStreetState } from './MainStreetState'; import { purchaseBusiness, @@ -29,7 +40,7 @@ function safeClone(obj: T): T { } catch (_) { // fall back } - // JSON fallback (sufficient for our snapshotuses: arrays/objects of primitives) + // JSON fallback (sufficient for our snapshot uses: arrays/objects of primitives) return JSON.parse(JSON.stringify(obj)); } @@ -60,115 +71,104 @@ function restoreSnapshot(state: MainStreetState, snap: MarketActionSnapshot): vo state.activityLog = snap.activityLog as any; } -/** Command: Buy Business */ -export class BuyBusinessCommand implements Command { - readonly description?: string; - private pre: MarketActionSnapshot | null = null; - - constructor( - private readonly state: MainStreetState, - private readonly cardId: string, - private readonly slotIndex: number, - ) { - this.description = `BuyBusiness ${cardId} -> slot ${slotIndex}`; - } +/** + * Creates a snapshot-capturing action from a do function. + * Captures the snapshot on the first execute and restores it on undo. + */ +function snapshotAction( + doFn: (state: MainStreetState) => void, + description: string, +): ReversibleAction { + let pre: MarketActionSnapshot | null = null; + return { + description, + do(state: MainStreetState): void { + if (pre === null) pre = captureSnapshot(state); + doFn(state); + }, + undo(state: MainStreetState): void { + if (pre === null) return; + restoreSnapshot(state, pre); + }, + }; +} - execute(): void { - if (this.pre === null) { - this.pre = captureSnapshot(this.state); - } - purchaseBusiness(this.state, this.cardId, this.slotIndex); - } +// ── Commands ──────────────────────────────────────────────── - undo(): void { - if (this.pre === null) return; - restoreSnapshot(this.state, this.pre); - } +/** Command: Buy Business */ +export function buyBusinessCommand( + state: MainStreetState, + cardId: string, + slotIndex: number, +) { + return toCommand( + state, + snapshotAction( + (s) => purchaseBusiness(s, cardId, slotIndex), + `BuyBusiness ${cardId} -> slot ${slotIndex}`, + ), + ); } /** Command: Buy Upgrade */ -export class BuyUpgradeCommand implements Command { - readonly description?: string; - private pre: MarketActionSnapshot | null = null; - - constructor( - private readonly state: MainStreetState, - private readonly cardId: string, - private readonly targetSlot?: number, - ) { - this.description = `BuyUpgrade ${cardId} -> slot ${targetSlot ?? 'auto'}`; - } - - execute(): void { - if (this.pre === null) this.pre = captureSnapshot(this.state); - purchaseUpgrade(this.state, this.cardId, this.targetSlot); - } - - undo(): void { - if (this.pre === null) return; - restoreSnapshot(this.state, this.pre); - } +export function buyUpgradeCommand( + state: MainStreetState, + cardId: string, + targetSlot?: number, +) { + return toCommand( + state, + snapshotAction( + (s) => purchaseUpgrade(s, cardId, targetSlot), + `BuyUpgrade ${cardId} -> slot ${targetSlot ?? 'auto'}`, + ), + ); } /** Command: Buy Event (Investment) */ -export class BuyEventCommand implements Command { - readonly description?: string; - private pre: MarketActionSnapshot | null = null; - - constructor( - private readonly state: MainStreetState, - private readonly cardId: string, - ) { - this.description = `BuyEvent ${cardId}`; - } - - execute(): void { - if (this.pre === null) this.pre = captureSnapshot(this.state); - purchaseEvent(this.state, this.cardId); - } - - undo(): void { - if (this.pre === null) return; - restoreSnapshot(this.state, this.pre); - } +export function buyEventCommand( + state: MainStreetState, + cardId: string, +) { + return toCommand( + state, + snapshotAction( + (s) => purchaseEvent(s, cardId), + `BuyEvent ${cardId}`, + ), + ); } /** Command: Play Held Investment Event */ -export class PlayEventCommand implements Command { - readonly description?: string; - private pre: MarketActionSnapshot | null = null; - - constructor(private readonly state: MainStreetState) { - this.description = `PlayHeldEvent`; - } - - execute(): void { - if (this.pre === null) this.pre = captureSnapshot(this.state); - playHeldEvent(this.state); - } - - undo(): void { - if (this.pre === null) return; - restoreSnapshot(this.state, this.pre); - } +export function playEventCommand(state: MainStreetState) { + return toCommand( + state, + snapshotAction( + (s) => playHeldEvent(s), + 'PlayHeldEvent', + ), + ); } -/** Command: Refresh Investments Row (buy new opportunities) */ -export class BuyRefreshInvestmentsCommand implements Command { - readonly description?: string; - private pre: MarketActionSnapshot | null = null; - - constructor(private readonly state: MainStreetState) { - this.description = `RefreshInvestments`; - } - - execute(): void { - if (this.pre === null) this.pre = captureSnapshot(this.state); - refreshInvestments(this.state); - } - - undo(): void { - if (this.pre === null) return; - restoreSnapshot(this.state, this.pre); - } +/** Command: Refresh Investments Row */ +export function refreshInvestmentsCommand(state: MainStreetState) { + return toCommand( + state, + snapshotAction( + (s) => refreshInvestments(s), + 'RefreshInvestments', + ), + ); } + +// Re-export renamed symbols for backward compatibility +/** @deprecated Use buyBusinessCommand() instead. */ +export const BuyBusinessCommand = buyBusinessCommand; +/** @deprecated Use buyUpgradeCommand() instead. */ +export const BuyUpgradeCommand = buyUpgradeCommand; +/** @deprecated Use buyEventCommand() instead. */ +export const BuyEventCommand = buyEventCommand; +/** @deprecated Use playEventCommand() instead. */ +export const PlayEventCommand = playEventCommand; +/** @deprecated Use refreshInvestmentsCommand() instead. */ +export const BuyRefreshInvestmentsCommand = refreshInvestmentsCommand; diff --git a/example-games/main-street/MainStreetEngine.ts b/example-games/main-street/MainStreetEngine.ts index f5663b9a..30a75c17 100644 --- a/example-games/main-street/MainStreetEngine.ts +++ b/example-games/main-street/MainStreetEngine.ts @@ -16,7 +16,7 @@ */ import type { MainStreetState, DayPhase } from './MainStreetState'; -import { PHASE_ORDER, addLog } from './MainStreetState'; +import { PHASE_ORDER, addLog, syncResourceBankToLedger } from './MainStreetState'; import type { EventCard, SynergyType } from './MainStreetCards'; import { applyIncome, type IncomeResult } from './MainStreetAdjacency'; import { @@ -94,9 +94,13 @@ export interface TurnResult { * Formula: coins + (reputation * reputationScoreMultiplier) + (challengesCompleted * challengeBonusPoints) */ export function computeScore(state: MainStreetState): number { + // Sync the ledger from resourceBank before reading, to ensure it reflects + // any direct resourceBank mutations made by tests or external code. + syncResourceBankToLedger(state); + // Use shared EconomyLedger for resource values return ( - state.resourceBank.coins + - state.resourceBank.reputation * state.config.reputationScoreMultiplier + + state.ledger.get('coins') + + state.ledger.get('reputation') * state.config.reputationScoreMultiplier + state.challengesCompleted.length * state.config.challengeBonusPoints ); } @@ -243,6 +247,9 @@ export function resolveEvent(state: MainStreetState, event: EventCard): void { break; } } + + // Sync shared EconomyLedger after resourceBank mutations + syncResourceBankToLedger(state); } /** diff --git a/example-games/main-street/MainStreetMarket.ts b/example-games/main-street/MainStreetMarket.ts index e4527f51..989d7b1b 100644 --- a/example-games/main-street/MainStreetMarket.ts +++ b/example-games/main-street/MainStreetMarket.ts @@ -19,11 +19,17 @@ import type { BusinessCard, UpgradeCard, EventCard, AnyCard } from './MainStreet import { GRID_SIZE, INCIDENT_QUEUE_SIZE, + MARKET_BUSINESS_SLOTS, + MARKET_INVESTMENT_SLOTS, MARKET_INVESTMENT_UPGRADE_COUNT, MARKET_INVESTMENT_EVENT_COUNT, REFRESH_INVESTMENTS_COST, } from './MainStreetCards'; import { shuffleArray } from '../../src/card-system'; +import { + createMarketOfferEngine, + type MarketOfferEngine, +} from '../../src/card-system/MarketOfferEngine'; // Shared reshuffle helper: when a draw/refill needs cards and the deck is // empty but the matching discard pile is non-empty, shuffle the discard @@ -41,6 +47,45 @@ function reshuffleIfNeeded(state: MainStreetState, deck: T[], discard: T[], n } } +// ── Shared Market Engine Integration ────────────────────── + +/** + * Builds a MarketOfferEngine snapshot from the current Main Street state. + * The engine provides row-based access to business and investments markets. + */ +function buildMarketEngine(state: MainStreetState): MarketOfferEngine { + return createMarketOfferEngine([ + { + id: 'business', + slots: MARKET_BUSINESS_SLOTS, + cards: state.market.business, + }, + { + id: 'investments', + slots: MARKET_INVESTMENT_SLOTS, + cards: state.market.investments, + }, + ]); +} + +/** + * Syncs only the business row from the engine back to state.market.business. + */ +function syncBusinessFromEngine( + state: MainStreetState, + engine: MarketOfferEngine, +): void { + const bizRow = engine.getRow('business'); + if (bizRow) { + state.market.business = []; + for (const slot of bizRow.slots) { + if (slot.card !== null) { + state.market.business.push(slot.card as BusinessCard); + } + } + } +} + // ── Result Types ──────────────────────────────────────────── /** Result returned after a successful purchase action. */ @@ -194,14 +239,14 @@ export function canPurchaseEvent( * Called after initial setup or if the market is partially empty. */ export function refillBusinessMarket(state: MainStreetState): void { - const { market, decks } = state; + const { decks } = state; // If the business deck is exhausted but there are discarded business cards, // reshuffle them back into the deck immediately so refill can proceed. reshuffleIfNeeded(state, decks.business, state.discards.business, 'business'); - while (market.business.length < 4 && decks.business.length > 0) { - market.business.push(decks.business.pop()!); - } + const engine = buildMarketEngine(state); + engine.refillRow('business', decks.business); + syncBusinessFromEngine(state, engine); } /** diff --git a/example-games/main-street/MainStreetState.ts b/example-games/main-street/MainStreetState.ts index d251f482..0922dda6 100644 --- a/example-games/main-street/MainStreetState.ts +++ b/example-games/main-street/MainStreetState.ts @@ -10,6 +10,7 @@ import { shuffleArray } from '../../src/card-system'; import { createSeededRng } from '../../src/core-engine'; +import { createEconomyLedger, type EconomyLedger } from '../../src/rule-engine/EconomyLedger'; import { type BusinessCard, type EventCard, @@ -68,6 +69,20 @@ export function addLog( state.activityLog.push({ turn: state.turn, text, type }); } +/** + * Syncs the shared EconomyLedger from resourceBank values. + * Called after direct resourceBank mutations to keep the ledger consistent. + */ +export function syncResourceBankToLedger(state: MainStreetState): void { + const coins = state.resourceBank.coins; + const rep = state.resourceBank.reputation; + const coinDelta = coins - state.ledger.get('coins'); + const repDelta = rep - state.ledger.get('reputation'); + if (coinDelta !== 0 || repDelta !== 0) { + state.ledger.apply({ coins: coinDelta, reputation: repDelta }, 'sync-from-resourceBank'); + } +} + // ── Phase Types ───────────────────────────────────────────── /** @@ -154,6 +169,8 @@ export interface MainStreetState { market: MarketState; /** Player resources. */ resourceBank: ResourceBank; + /** Shared EconomyLedger for resource mutation (synced with resourceBank). */ + ledger: EconomyLedger; /** Remaining cards in each deck (draw from end = top). */ decks: { business: BusinessCard[]; @@ -397,6 +414,8 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS } // Build initial state -- use config values instead of hard-coded constants + const initCoins = config.startingCoins; + const initRep = config.startingReputation; state = { config, turn: 1, @@ -404,9 +423,14 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS streetGrid: new Array(GRID_SIZE).fill(null), market, resourceBank: { - coins: config.startingCoins, - reputation: config.startingReputation, + coins: initCoins, + reputation: initRep, }, + ledger: createEconomyLedger({ + coins: initCoins, + reputation: initRep, + score: 0, + }), decks: { business: businessDeck, event: eventDeck, @@ -496,6 +520,11 @@ export function deserializeMainStreetState(saved: MainStreetSerializedState): Ma streetGrid: structuredClone(saved.streetGrid), market: structuredClone(saved.market), resourceBank: structuredClone(saved.resourceBank), + ledger: createEconomyLedger({ + coins: saved.resourceBank.coins, + reputation: saved.resourceBank.reputation, + score: saved.finalScore, + }), decks: structuredClone(saved.decks), discards: structuredClone(saved.discards), challengesCompleted: [...saved.challengesCompleted], diff --git a/example-games/main-street/scenes/MainStreetTurnController.ts b/example-games/main-street/scenes/MainStreetTurnController.ts index 7bd040d8..ac5724ee 100644 --- a/example-games/main-street/scenes/MainStreetTurnController.ts +++ b/example-games/main-street/scenes/MainStreetTurnController.ts @@ -10,7 +10,7 @@ import { canRefreshInvestments, } from '../MainStreetMarket'; import type { BusinessCard, EventCard, UpgradeCard } from '../MainStreetCards'; -import { BuyBusinessCommand, BuyUpgradeCommand, BuyEventCommand, PlayEventCommand, BuyRefreshInvestmentsCommand } from '../MainStreetCommands'; +import { buyBusinessCommand, buyUpgradeCommand, buyEventCommand, playEventCommand, refreshInvestmentsCommand } from '../MainStreetCommands'; import { recordMainStreetEvent, finalizeMainStreetTranscript } from '../MainStreetTranscript'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import { FONT_FAMILY, createOverlayBackground, createOverlayButton, dismissOverlay } from '../../../src/ui'; @@ -133,7 +133,7 @@ export class MainStreetTurnController { console.debug('[MS] onPlayHeldEvent: attempting PlayEvent', { heldEventId: s.state.heldEvent?.id, coinsBefore: s.state.resourceBank.coins }); try { - const cmd = new PlayEventCommand(s.state); + const cmd = playEventCommand(s.state); s.undoManager.execute(cmd); // Record action event try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'play-event' }, description: cmd.description }); } catch (_) {} @@ -242,7 +242,7 @@ export class MainStreetTurnController { const afterTransfer = (): void => { console.debug('[MS] onSlotClick: attempting BuyBusiness', { cardId: pendingCardId, slotIndex, coinsBefore: s.state.resourceBank.coins, marketBefore: s.state.market.business.map((c: any)=>c.id) }); try { - const cmd = new BuyBusinessCommand(s.state, pendingCardId, slotIndex); + const cmd = buyBusinessCommand(s.state, pendingCardId, slotIndex); s.undoManager.execute(cmd); // Record action event try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-business', cardId: pendingCardId, slotIndex }, description: cmd.description }); } catch (_) {} @@ -305,7 +305,7 @@ export class MainStreetTurnController { const afterTransfer = (): void => { console.debug('[MS] onEventCardClick: attempting BuyEvent', { cardId: card.id, coinsBefore: s.state.resourceBank.coins, marketBefore: s.state.market.investments.map((c: any)=>c.id) }); try { - const cmd = new BuyEventCommand(s.state, card.id); + const cmd = buyEventCommand(s.state, card.id); s.undoManager.execute(cmd); try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-event', cardId: card.id }, description: cmd.description }); } catch (_) {} try { s.gameEvents?.emit('card:placed', { cardId: card.id }); } catch (_) {} @@ -348,7 +348,7 @@ export class MainStreetTurnController { s.refreshAll(); try { - const cmd = new BuyRefreshInvestmentsCommand(s.state); + const cmd = refreshInvestmentsCommand(s.state); s.undoManager.execute(cmd); try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'refresh-investments' }, description: cmd.description }); } catch (_) {} s.instructionText.setText('Refreshed investments'); @@ -404,7 +404,7 @@ export class MainStreetTurnController { const afterTransfer = (): void => { console.debug('[MS] onUpgradeCardClick: attempting BuyUpgrade', { cardId: card.id, targetSlot, coinsBefore: s.state.resourceBank.coins, marketBefore: s.state.market.investments.map((c: any)=>c.id), streetBefore: s.state.streetGrid.map((slot: any)=>slot?.id ?? null) }); try { - const cmd = new BuyUpgradeCommand(s.state, card.id, targetSlot); + const cmd = buyUpgradeCommand(s.state, card.id, targetSlot); s.undoManager.execute(cmd); try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-upgrade', cardId: card.id, targetSlot }, description: cmd.description }); } catch (_) {} try { s.gameEvents?.emit('card:placed', { cardId: card.id, targetSlot }); } catch (_) {} @@ -505,7 +505,7 @@ export class MainStreetTurnController { const afterTransfer = (): void => { try { - s.undoManager.execute(new BuyUpgradeCommand(s.state, branch.id, targetSlot)); + s.undoManager.execute(buyUpgradeCommand(s.state, branch.id, targetSlot)); s.instructionText.setText(`Applied upgrade: "${branch.name}"`); } catch (e) { s.instructionText.setText(`Error: ${(e as Error).message}`); diff --git a/src/card-system/MarketOfferEngine.ts b/src/card-system/MarketOfferEngine.ts new file mode 100644 index 00000000..e996cca2 --- /dev/null +++ b/src/card-system/MarketOfferEngine.ts @@ -0,0 +1,287 @@ +/** + * Market Offer Engine + * + * A generic engine for managing a market of card offers organized in rows. + * Each row has a configurable number of slots, each slot may hold a card. + * Supports refill from external decks, slot locking, and card lookup. + * + * Designed to be reusable across any tableau card game that uses a row-based + * market (e.g., Main Street, Feudalism). Game-specific business rules + * (affordability checks, placement validation) remain in the game layer. + * + * @module + */ + +// ── Types ─────────────────────────────────────────────────── + +/** + * A single slot within a market row. + * Can be empty (card === null) or occupied. + */ +export interface MarketSlot { + card: TCard | null; + locked: boolean; +} + +/** + * A named row of market slots. + * Each row has a unique id and a fixed-size array of slots. + */ +export interface MarketRow { + id: string; + slots: MarketSlot[]; +} + +/** + * Configuration for creating a market row. + */ +export interface MarketRowConfig { + id: string; + slots: number; + cards?: TCard[]; +} + +/** + * Result of a successful purchase. + */ +export interface PurchaseResult { + card: TCard; + slotIndex: number; + rowId: string; +} + +// ── MarketOfferEngine interface ───────────────────────────── + +/** + * Generic market offer engine for managing rows of card offers. + * + * @typeParam TCard - The card type stored in market slots. + * + * @example + * ```ts + * const market = createMarketOfferEngine([ + * { id: 'business', slots: 4, cards: [card1, card2, card3, card4] }, + * { id: 'investments', slots: 5 }, + * ]); + * + * // Find a card + * const found = market.findCard('business', someCardId); + * + * // Purchase (remove from market) + * const purchased = market.removeCard('business', 2); + * + * // Refill empty slots from deck + * market.refillRow('business', businessDeck); + * ``` + */ +export interface MarketOfferEngine { + /** Returns all rows in the market. */ + getRows(): readonly MarketRow[]; + + /** Returns a specific row by id, or undefined if not found. */ + getRow(rowId: string): MarketRow | undefined; + + /** Returns the card at a specific slot, or null if empty. */ + getCard(rowId: string, slotIndex: number): TCard | null; + + /** Sets the card at a specific slot (null to clear). */ + setCard(rowId: string, slotIndex: number, card: TCard | null): void; + + /** Finds a card by ID within a specific row. */ + findCard(rowId: string, cardId: string): { slotIndex: number; card: TCard } | undefined; + + /** Finds a card by ID across all rows. */ + findCardAnywhere(cardId: string): { rowId: string; slotIndex: number; card: TCard } | undefined; + + /** Removes and returns the card at a specific slot (throws if empty). */ + removeCard(rowId: string, slotIndex: number): TCard; + + /** Returns indices of all empty (card === null) slots in a row. */ + getEmptySlots(rowId: string): number[]; + + /** Locks a slot, preventing it from being refilled. */ + lockSlot(rowId: string, slotIndex: number): void; + + /** Unlocks a slot, allowing it to be refilled. */ + unlockSlot(rowId: string, slotIndex: number): void; + + /** Returns whether a slot is locked. */ + isSlotLocked(rowId: string, slotIndex: number): boolean; + + /** Refills empty (and unlocked) slots in a row from a deck (pops from end). */ + refillRow(rowId: string, deck: TCard[]): number; + + /** Returns whether all slots in a row are empty. */ + isEmpty(rowId: string): boolean; + + /** Returns the number of occupied slots in a row. */ + countCards(rowId: string): number; + + /** Iterates over each card in a row, calling fn(card, slotIndex). */ + forEachCard(rowId: string, fn: (card: TCard, slotIndex: number) => void): void; +} + +// ── Implementation ────────────────────────────────────────── + +/** + * Validates that a rowId and slotIndex reference a valid slot. + * @throws Error if the row or slot does not exist. + */ +function validateSlot( + rows: Map>, + rowId: string, + slotIndex: number, +): MarketSlot { + const row = rows.get(rowId); + if (!row) { + throw new Error(`Market row '${rowId}' not found.`); + } + if (slotIndex < 0 || slotIndex >= row.slots.length) { + throw new Error( + `Slot index ${slotIndex} out of range for row '${rowId}' (0-${row.slots.length - 1}).`, + ); + } + return row.slots[slotIndex]; +} + +function createRow(config: MarketRowConfig): MarketRow { + const slots: MarketSlot[] = []; + const cardCount = config.cards?.length ?? 0; + for (let i = 0; i < config.slots; i++) { + slots.push({ + card: i < cardCount ? (config.cards![i] ?? null) : null, + locked: false, + }); + } + return { id: config.id, slots }; +} + +/** + * Creates a MarketOfferEngine with the given row configurations. + * + * @param rowsConfig Configuration for each market row. + * @returns A new MarketOfferEngine instance. + */ +export function createMarketOfferEngine( + rowsConfig: MarketRowConfig[], +): MarketOfferEngine { + const rows = new Map>(); + + for (const config of rowsConfig) { + rows.set(config.id, createRow(config)); + } + + return { + getRows(): readonly MarketRow[] { + return Array.from(rows.values()); + }, + + getRow(rowId: string): MarketRow | undefined { + return rows.get(rowId); + }, + + getCard(rowId: string, slotIndex: number): TCard | null { + return validateSlot(rows, rowId, slotIndex).card; + }, + + setCard(rowId: string, slotIndex: number, card: TCard | null): void { + validateSlot(rows, rowId, slotIndex).card = card; + }, + + findCard(rowId: string, cardId: string): { slotIndex: number; card: TCard } | undefined { + const row = rows.get(rowId); + if (!row) return undefined; + for (let i = 0; i < row.slots.length; i++) { + const slot = row.slots[i]; + if (slot.card !== null && (slot.card as any).id === cardId) { + return { slotIndex: i, card: slot.card }; + } + } + return undefined; + }, + + findCardAnywhere( + cardId: string, + ): { rowId: string; slotIndex: number; card: TCard } | undefined { + for (const [rowId, row] of rows) { + for (let i = 0; i < row.slots.length; i++) { + const slot = row.slots[i]; + if (slot.card !== null && (slot.card as any).id === cardId) { + return { rowId, slotIndex: i, card: slot.card }; + } + } + } + return undefined; + }, + + removeCard(rowId: string, slotIndex: number): TCard { + const slot = validateSlot(rows, rowId, slotIndex); + if (slot.card === null) { + throw new Error(`Slot ${slotIndex} in row '${rowId}' is already empty.`); + } + const card = slot.card; + slot.card = null; + return card; + }, + + getEmptySlots(rowId: string): number[] { + const row = rows.get(rowId); + if (!row) return []; + const empty: number[] = []; + for (let i = 0; i < row.slots.length; i++) { + if (row.slots[i].card === null) { + empty.push(i); + } + } + return empty; + }, + + lockSlot(rowId: string, slotIndex: number): void { + validateSlot(rows, rowId, slotIndex).locked = true; + }, + + unlockSlot(rowId: string, slotIndex: number): void { + validateSlot(rows, rowId, slotIndex).locked = false; + }, + + isSlotLocked(rowId: string, slotIndex: number): boolean { + return validateSlot(rows, rowId, slotIndex).locked; + }, + + refillRow(rowId: string, deck: TCard[]): number { + const row = rows.get(rowId); + if (!row) return 0; + let refilled = 0; + for (const slot of row.slots) { + if (slot.card === null && !slot.locked && deck.length > 0) { + slot.card = deck.pop()!; + refilled++; + } + } + return refilled; + }, + + isEmpty(rowId: string): boolean { + const row = rows.get(rowId); + if (!row) return true; + return row.slots.every(s => s.card === null); + }, + + countCards(rowId: string): number { + const row = rows.get(rowId); + if (!row) return 0; + return row.slots.filter(s => s.card !== null).length; + }, + + forEachCard(rowId: string, fn: (card: TCard, slotIndex: number) => void): void { + const row = rows.get(rowId); + if (!row) return; + for (let i = 0; i < row.slots.length; i++) { + const card = row.slots[i].card; + if (card !== null) { + fn(card, i); + } + } + }, + }; +} diff --git a/src/card-system/index.ts b/src/card-system/index.ts index 4aa08743..6f2185f9 100644 --- a/src/card-system/index.ts +++ b/src/card-system/index.ts @@ -27,3 +27,13 @@ export { rankValue } from './rankValue'; // Pile abstraction export { Pile } from './Pile'; + +// Market Offer Engine +export { + createMarketOfferEngine, + type MarketOfferEngine, + type MarketRow, + type MarketSlot, + type MarketRowConfig, + type PurchaseResult, +} from './MarketOfferEngine'; diff --git a/src/core-engine/ActionCommands.ts b/src/core-engine/ActionCommands.ts new file mode 100644 index 00000000..a303f2f4 --- /dev/null +++ b/src/core-engine/ActionCommands.ts @@ -0,0 +1,131 @@ +/** + * Action Commands + * + * A generic adapter that wraps reversible game actions as `Command` objects + * compatible with the shared `UndoRedoManager`. This decouples action + * definitions from the undo/redo infrastructure, allowing games to define + * actions as pure functions and reuse the same command lifecycle. + * + * Key design decisions: + * - Actions are defined as `ReversibleAction` objects with + * `do(state)` and `undo(state)` methods. + * - The `toCommand()` factory wraps any `ReversibleAction` into a `Command` + * ready for use with `UndoRedoManager`. + * - State management (e.g., snapshot capture) is the responsibility of the + * action implementation, not the adapter. + * - This matches the pattern used by MainStreetCommands.ts where each + * command captures a pre-snapshot on first `execute()` and restores it + * on `undo()`. + * + * @module + */ + +import type { Command } from './UndoRedoManager'; + +// ── Types ─────────────────────────────────────────────────── + +/** + * A reversible game action that can be applied and undone. + * + * @typeParam TState - The game state type that the action operates on. + * + * @example + * ```ts + * const buyAction: ReversibleAction = { + * description: 'Buy item for 10 coins', + * do(state) { + * state.coins -= 10; + * state.inventory.push('item'); + * }, + * undo(state) { + * state.coins += 10; + * state.inventory.pop(); + * }, + * }; + * + * const command = toCommand(state, buyAction); + * undoRedo.execute(command); + * ``` + */ +export interface ReversibleAction { + /** Apply the action (forward). */ + do(state: TState): void; + /** Reverse the action (backward). */ + undo(state: TState): void; + /** Optional human-readable description for debugging/transcripts. */ + readonly description?: string; +} + +// ── Factory ───────────────────────────────────────────────── + +/** + * Wraps a `ReversibleAction` into a `Command` compatible with `UndoRedoManager`. + * + * The returned `Command` object binds the action to the provided state so + * that `execute()` calls `action.do(state)` and `undo()` calls `action.undo(state)`. + * + * @param state The game state that the action will mutate. + * @param action The reversible action to wrap. + * @returns A `Command` object ready for use with `UndoRedoManager.execute()`. + * + * @example + * ```ts + * const cmd = toCommand(myState, myAction); + * undoRedoManager.execute(cmd); + * // Later: + * undoRedoManager.undo(); + * ``` + */ +export function toCommand( + state: TState, + action: ReversibleAction, +): Command { + return { + execute(): void { + action.do(state); + }, + undo(): void { + action.undo(state); + }, + description: action.description, + }; +} + +/** + * Creates a reversible action that snaps the state before `do()` and restores + * it on `undo()`. This is a convenience for actions that need snapshot-based + * undo as used by Main Street's commands. + * + * The snapshot is captured on the first call to `do()` (or can be pre-captured). + * Subsequent `do()` calls reuse the initial snapshot (no re-capture). + * + * @param doFn The forward operation to apply. + * @param undoFn The reverse operation (called with the snapshot + current state). + * Note: snapshot-based undo typically restores the entire state + * rather than computing deltas. + * @param description Optional human-readable label. + * @param captureSnapshot A function that deep-clones the relevant parts of state. + * @returns A `ReversibleAction` with snapshot-based undo semantics. + */ +export function createSnapshotAction( + doFn: (state: TState) => void, + undoFn: (state: TState, snapshot: TState) => void, + description: string | undefined, + captureSnapshot: (state: TState) => TState, +): ReversibleAction { + let snapshot: TState | null = null; + + return { + description, + do(state: TState): void { + if (snapshot === null) { + snapshot = captureSnapshot(state); + } + doFn(state); + }, + undo(state: TState): void { + if (snapshot === null) return; + undoFn(state, snapshot); + }, + }; +} diff --git a/src/core-engine/index.ts b/src/core-engine/index.ts index f89f4bb9..dd0149e8 100644 --- a/src/core-engine/index.ts +++ b/src/core-engine/index.ts @@ -31,6 +31,13 @@ export { export type { Command } from './UndoRedoManager'; export { CompoundCommand, UndoRedoManager } from './UndoRedoManager'; +// Action Commands adapter +export { + toCommand, + createSnapshotAction, + type ReversibleAction, +} from './ActionCommands'; + // Transcript persistence (consolidated module — CG-0MP12WI75001L9P4) export type { StoredTranscript, TranscriptStoreOptions } from './TranscriptStore'; export { TranscriptStore } from './TranscriptStore'; diff --git a/src/rule-engine/EconomyLedger.ts b/src/rule-engine/EconomyLedger.ts new file mode 100644 index 00000000..3b35a770 --- /dev/null +++ b/src/rule-engine/EconomyLedger.ts @@ -0,0 +1,171 @@ +/** + * Economy Ledger + * + * A generic resource-tracking component for managing mutable game economy + * values (coins, reputation, score). Captures the baseline mutation + * semantics extracted from Main Street so that future games can reuse + * the same resource-delta patterns without copying game-specific code. + * + * Key design decisions inherited from Main Street baseline: + * - Coins are allowed to go negative (bankruptcy is checked downstream). + * - Reputation is allowed to go negative (collapse is checked downstream). + * - Score is a read-only derived value by default (computed from coins + + * reputation), but can be set directly via `setScore()` for games that + * treat score as an independent resource. + * - No `canApply` guard prevents negative balances — the game engine is + * responsible for loss-condition checks after mutations. + * + * @module + */ + +// ── Types ─────────────────────────────────────────────────── + +/** + * A delta to apply to one or more economy resources. + * Each field is optional — only specified resources are mutated. + */ +export interface ResourceDelta { + coins?: number; + reputation?: number; + score?: number; +} + +/** + * Readonly snapshot of current resource values. + */ +export interface ResourceSnapshot { + coins: number; + reputation: number; + score: number; +} + +/** + * Optional constraints applied during `canApply` checks. + * When omitted, `canApply` always returns true (matching Main Street's + * baseline where negative balances are permitted and checked later). + */ +export interface EconomyConstraints { + /** If set, `canApply` returns false when coins would drop below this floor. */ + minCoins?: number; + /** If set, `canApply` returns false when reputation would drop below this floor. */ + minReputation?: number; +} + +// ── EconomyLedger ─────────────────────────────────────────── + +/** + * Manages a set of economy resources with get/apply semantics. + * + * The ledger does NOT enforce loss conditions (bankruptcy, reputation + * collapse, etc.). Those are the responsibility of the game engine's + * win/loss detection logic, which reads from the ledger after mutations. + * + * @example + * ```ts + * const ledger = createEconomyLedger({ coins: 10, reputation: 3 }); + * + * // Purchase a business + * if (ledger.canApply({ coins: -cost })) { + * ledger.apply({ coins: -cost }, 'buy-business'); + * } + * + * // Earn income (with reputation multiplier applied upstream) + * ledger.apply({ coins: incomeAmount }, 'income'); + * + * // Event resolution + * ledger.apply({ coins: coinDelta, reputation: repDelta }, 'event-resolve'); + * ``` + */ +export interface EconomyLedger { + /** Returns the current value of a resource. */ + get(resource: keyof ResourceDelta): number; + + /** Returns a snapshot of all resource values. */ + snapshot(): ResourceSnapshot; + + /** + * Checks whether a delta can be applied given the current constraints. + * + * With no constraints (the default), always returns true — matching + * Main Street's baseline where negative balances are allowed. + */ + canApply(delta: ResourceDelta): boolean; + + /** + * Applies a resource delta. Mutates the ledger state in-place. + * + * @param delta The resource changes to apply (each field is additive). + * @param reason A label for logging/debugging (not stored). + */ + apply(delta: ResourceDelta, reason?: string): void; + + /** + * Sets the score to an absolute value (for games where score is an + * independent resource rather than a derived computation). + */ + setScore(value: number): void; +} + +/** + * Configuration for creating an EconomyLedger. + */ +export interface EconomyLedgerConfig { + /** Initial coins (default: 0). */ + coins?: number; + /** Initial reputation (default: 0). */ + reputation?: number; + /** Initial score (default: 0). */ + score?: number; + /** Optional constraints for `canApply` checks. */ + constraints?: EconomyConstraints; +} + +/** + * Creates an EconomyLedger with the given initial values and constraints. + * + * @param config Initial resource values and optional constraints. + * @returns A new EconomyLedger instance. + */ +export function createEconomyLedger(config: EconomyLedgerConfig = {}): EconomyLedger { + let coins = config.coins ?? 0; + let reputation = config.reputation ?? 0; + let score = config.score ?? 0; + const constraints = config.constraints ?? {}; + + return { + get(resource: keyof ResourceDelta): number { + switch (resource) { + case 'coins': + return coins; + case 'reputation': + return reputation; + case 'score': + return score; + } + }, + + snapshot(): ResourceSnapshot { + return { coins, reputation, score }; + }, + + canApply(delta: ResourceDelta): boolean { + if (delta.coins !== undefined && constraints.minCoins !== undefined) { + if (coins + delta.coins < constraints.minCoins) return false; + } + if (delta.reputation !== undefined && constraints.minReputation !== undefined) { + if (reputation + delta.reputation < constraints.minReputation) return false; + } + return true; + }, + + apply(delta: ResourceDelta, _reason?: string): void { + if (delta.coins !== undefined) coins += delta.coins; + if (delta.reputation !== undefined) reputation += delta.reputation; + if (delta.score !== undefined) score += delta.score; + }, + + setScore(value: number): void { + score = value; + }, + }; +} diff --git a/src/rule-engine/index.ts b/src/rule-engine/index.ts index c81702f2..6fc508df 100644 --- a/src/rule-engine/index.ts +++ b/src/rule-engine/index.ts @@ -34,3 +34,14 @@ export const RULE_ENGINE_VERSION = '0.1.0'; export type LegalityResult = | { legal: true } | { legal: false; reason: string }; + +// ── Economy Ledger ────────────────────────────────────────── + +export { + createEconomyLedger, + type EconomyLedger, + type EconomyLedgerConfig, + type EconomyConstraints, + type ResourceDelta, + type ResourceSnapshot, +} from './EconomyLedger'; diff --git a/src/ui/GymSceneUtils.ts b/src/ui/GymSceneUtils.ts new file mode 100644 index 00000000..824530d1 --- /dev/null +++ b/src/ui/GymSceneUtils.ts @@ -0,0 +1,438 @@ +/** + * GymSceneUtils – Shared rendering utilities for Gym demo scenes. + * + * Extracts common patterns (event log rendering, deck grid rendering, + * slider setup) into reusable functions so Gym scenes stay focused on + * their demo-specific logic. + * + * All functions accept an options object for customization and return + * references to the created objects for testability. + * + * @module src/ui/GymSceneUtils + */ + +import Phaser from 'phaser'; +import type { Card } from '@card-system/Card'; +import { createHudText } from './Renderer'; +import { getCardTexture } from './CardTextureHelpers'; +import { GAME_W, CARD_W, CARD_H } from './constants'; + +// --------------------------------------------------------------------------- +// Event Log +// --------------------------------------------------------------------------- + +/** Options for {@link createEventLog}. */ +export interface EventLogOptions { + /** Header text displayed above the log lines. Defaults to "── Event Log ──". */ + headerText?: string; + /** Vertical spacing between log lines in pixels. Defaults to 17. */ + lineHeight?: number; + /** Color of log line text. Defaults to "#aaddaa". */ + textColor?: string; + /** Maximum number of log lines displayed. Defaults to 14. */ + maxLines?: number; + /** Font size for log lines. Defaults to "11px". */ + fontSize?: string; + /** X position of the log header (centered). Defaults to GAME_W / 2. */ + headerX?: number; + /** X position of the log lines. Defaults to 40. */ + lineX?: number; + /** Font size for the header. Defaults to "12px". */ + headerFontSize?: string; + /** Color for the header text. Defaults to "#669966". */ + headerColor?: string; +} + +/** Result returned by {@link createEventLog}. */ +export interface EventLogResult { + /** The header text object. */ + header: Phaser.GameObjects.Text; + /** Array of current line text objects (updated on each render). */ + lines: Phaser.GameObjects.Text[]; + /** The base Y position of the log area. */ + baseY: number; + /** + * Render the current set of lines. Call this after modifying the lines + * array to update the display. + * + * @param lines Array of log line strings to render. + */ + render: (lines: string[]) => void; + /** Destroy all created text objects. */ + destroy: () => void; +} + +/** + * Create an event log display area (header + scrollable log lines). + * + * Renders a centered header at the top of the log area, then each line + * at `lineX` with `lineHeight` vertical spacing starting from `baseY + offset`. + * + * @param scene The Phaser scene to add objects to. + * @param baseY The Y position of the first log line (header is placed above it). + * @param options Optional configuration overrides. + * @returns An {@link EventLogResult} with references to the created objects. + */ +export function createEventLog( + scene: Phaser.Scene, + baseY: number, + options?: EventLogOptions, +): EventLogResult { + const { + headerText = '── Event Log ──', + lineHeight = 17, + textColor = '#aaddaa', + maxLines = 14, + fontSize = '11px', + headerX = GAME_W / 2, + lineX = 40, + headerFontSize = '12px', + headerColor = '#669966', + } = options ?? {}; + + const header = createHudText(scene, headerX, baseY - lineHeight, headerText, headerColor, { + fontSize: headerFontSize, + }).setOrigin(0.5, 1); + + const lines: Phaser.GameObjects.Text[] = []; + + function render(newLines: string[]): void { + // Destroy old line objects + for (const t of lines) { + try { t.destroy(); } catch (_) { /* ignore */ } + } + lines.splice(0, lines.length); + + // Render up to maxLines entries + const startIdx = Math.max(0, newLines.length - maxLines); + const visibleLines = newLines.slice(startIdx); + + for (let i = 0; i < visibleLines.length; i++) { + const txt = createHudText(scene, lineX, baseY + i * lineHeight, visibleLines[i], textColor, { + fontSize, + }); + lines.push(txt); + } + } + + return { + header, + lines, + baseY, + render, + destroy: () => { + try { header.destroy(); } catch (_) { /* ignore */ } + for (const t of lines) { + try { t.destroy(); } catch (_) { /* ignore */ } + } + lines.splice(0, lines.length); + }, + }; +} + +// --------------------------------------------------------------------------- +// Deck Grid +// --------------------------------------------------------------------------- + +/** Options for {@link createDeckGrid}. */ +export interface DeckGridOptions { + /** Horizontal gap between cards in pixels. Defaults to 4. */ + gapX?: number; + /** Vertical gap between cards in pixels. Defaults to 4. */ + gapY?: number; + /** Number of columns in the grid. Defaults to 8. */ + cols?: number; + /** Center X of the grid (falls back to GAME_W / 2). */ + centerX?: number; + /** Center Y of the grid (falls back to cardDisplay zone or scene center + 100). */ + centerY?: number; + /** Scale factor for each card sprite. Defaults to auto-computed from CARD_W. */ + cardScale?: number; +} + +/** Result returned by {@link createDeckGrid}. */ +export interface DeckGridResult { + /** Array of card sprite Image objects in grid order (row-major). */ + sprites: Phaser.GameObjects.Image[]; + /** Destroy all sprites. */ + destroy: () => void; +} + +/** + * Render a deck of cards as a compact face-up grid. + * + * Cards are scaled down (or by {@link DeckGridOptions.cardScale}) and laid + * out in a centered grid with configurable columns and spacing. + * + * @param scene The Phaser scene to add objects to. + * @param deck Array of cards to render. Each card is set face-up. + * @param options Optional configuration overrides. + * @returns A {@link DeckGridResult} with the sprite array and a destroy method. + */ +export function createDeckGrid( + scene: Phaser.Scene, + deck: Card[], + options?: DeckGridOptions, +): DeckGridResult { + const { + gapX = 4, + gapY = 4, + cols = 8, + centerX = GAME_W / 2, + centerY = 370, // default gym position + cardScale, + } = options ?? {}; + + // Compute card scale — preserve legacy 48px width appearance + const LEGACY_CARD_W = 48; + const SCALE_UP = 1.15; + const computedScale = Math.min(1, (LEGACY_CARD_W / CARD_W) * SCALE_UP); + const scale = cardScale ?? computedScale; + + const scaledCardW = CARD_W * scale; + const scaledCardH = CARD_H * scale; + const stepX = scaledCardW + gapX; + const stepY = scaledCardH + gapY; + const totalWidth = cols * stepX - gapX; + const numRows = Math.ceil(deck.length / cols); + const totalHeight = numRows * stepY - gapY; + const gridStartX = centerX - totalWidth / 2 + scaledCardW / 2; + const gridStartY = centerY - totalHeight / 2 + scaledCardH / 2; + + const sprites: Phaser.GameObjects.Image[] = []; + + for (let i = 0; i < deck.length; i++) { + const card = deck[i]; + card.faceUp = true; + const col = i % cols; + const row = Math.floor(i / cols); + const x = gridStartX + col * stepX; + const y = gridStartY + row * stepY; + const texture = getCardTexture(card); + const sprite = scene.add.image(x, y, texture); + sprite.setScale(scale); + sprites.push(sprite); + } + + return { + sprites, + destroy: () => { + for (const sprite of sprites) { + try { sprite.destroy(); } catch (_) { /* ignore */ } + } + sprites.splice(0, sprites.length); + }, + }; +} + +// --------------------------------------------------------------------------- +// Slider +// --------------------------------------------------------------------------- + +/** Options for {@link createSlider}. */ +export interface SliderOptions { + /** Initial slider value. Defaults to 0.5. */ + initialValue?: number; + /** Minimum value. Defaults to 0. */ + minValue?: number; + /** Maximum value. Defaults to 1. */ + maxValue?: number; + /** Label text displayed above the slider. Defaults to empty string. */ + label?: string; + /** Width of the slider track in pixels. Defaults to 150. */ + width?: number; + /** Height of the slider track in pixels. Defaults to 6. */ + trackHeight?: number; + /** Color of the track (fill). Defaults to 0x334433. */ + trackColor?: number; + /** Color of the fill bar. Defaults to 0x88ff88. */ + fillColor?: number; + /** Color of the handle. Defaults to 0xffffff. */ + handleColor?: number; + /** Font size for the value text. Defaults to "11px". */ + fontSize?: string; + /** Text color for the value and label. Defaults to "#88ff88". */ + textColor?: string; +} + +/** Result returned by {@link createSlider}. */ +export interface SliderResult { + /** Current slider value. */ + value: number; + /** The track rectangle. */ + track: Phaser.GameObjects.Rectangle; + /** The fill rectangle. */ + fill: Phaser.GameObjects.Rectangle; + /** The handle graphics. */ + handle: Phaser.GameObjects.Graphics; + /** The value/label text. */ + valueText: Phaser.GameObjects.Text; + /** The interactive hit zone. */ + hitArea: Phaser.GameObjects.Zone; + /** + * Callback invoked when the slider value changes. + * Set by the caller to wire up scene-specific logic. + */ + onValueChange: ((value: number) => void) | null; + /** + * Programmatically set the slider value (clamped to min/max) and + * update visuals. Does NOT fire `onValueChange`. + */ + setValue: (value: number) => void; + /** + * Destroy all slider objects and clean up input handlers. + */ + destroy: () => void; + /** + * Handle a pointer move event for this slider. + * Called by the scene's pointermove handler. + */ + handlePointerMove: (pointerX: number) => void; + /** + * Handle a pointer up event for this slider. + * Called by the scene's pointerup handler. + */ + handlePointerUp: () => void; +} + +/** + * Create a horizontal slider with track, fill bar, handle, and value text. + * + * Drag logic uses pointer events: call {@link SliderResult.handlePointerMove} + * from your scene's `pointermove` handler, and + * {@link SliderResult.handlePointerUp} from `pointerup`. + * + * @param scene The Phaser scene. + * @param x X position of the slider track (left edge). + * @param y Y position (center of the track). + * @param options Optional configuration overrides. + * @returns A {@link SliderResult} with controls and references. + */ +export function createSlider( + scene: Phaser.Scene, + x: number, + y: number, + options?: SliderOptions, +): SliderResult { + const { + initialValue = 0.5, + minValue = 0, + maxValue = 1, + label = '', + width = 150, + trackHeight = 6, + trackColor = 0x334433, + fillColor = 0x88ff88, + handleColor = 0xffffff, + fontSize = '11px', + textColor = '#88ff88', + } = options ?? {}; + + let currentValue = initialValue; + let isDragging = false; + const _callbacks: { onChange: ((value: number) => void) | null } = { onChange: null }; + + // --- Create visual elements --- + + const track = scene.add.rectangle(x, y, width, trackHeight, trackColor, 1) + .setOrigin(0, 0.5); + + const fill = scene.add.rectangle(x, y, 1, trackHeight, fillColor, 1) + .setOrigin(0, 0.5); + + const handle = scene.add.graphics(); + + const valueText = createHudText(scene, x + width / 2, y - 20, '', textColor, { + fontSize, + }).setOrigin(0.5); + + // --- Hit zone --- + + const hitArea = scene.add.zone(x + width / 2, y, width + 24, 28) + .setInteractive({ useHandCursor: true }); + + hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + isDragging = true; + setValueFromPointer(pointer.x); + }); + + // --- Internal helpers --- + + function setValueFromPointer(pointerX: number): void { + const clampedX = Math.max(x, Math.min(x + width, pointerX)); + const ratio = (clampedX - x) / width; + const nextValue = minValue + ratio * (maxValue - minValue); + currentValue = Math.max(minValue, Math.min(maxValue, nextValue)); + updateVisuals(); + if (_callbacks.onChange) { + _callbacks.onChange(currentValue); + } + } + + function updateVisuals(): void { + const ratio = maxValue !== minValue + ? (currentValue - minValue) / (maxValue - minValue) + : 1; + const clampedRatio = Math.max(0, Math.min(1, ratio)); + const fillWidth = Math.max(1, width * clampedRatio); + const handleX = track.x + fillWidth; + const handleY = track.y; + + fill.setSize(fillWidth, trackHeight); + fill.setPosition(track.x, handleY); + + handle.clear(); + handle.fillStyle(handleColor, 1); + handle.fillCircle(handleX, handleY, 8); + handle.lineStyle(2, fillColor, 1); + handle.strokeCircle(handleX, handleY, 8); + + const displayLabel = label ? `${label}: ${currentValue.toFixed(currentValue >= 100 ? 0 : (currentValue >= 10 ? 1 : 2))}` : `${currentValue.toFixed(currentValue >= 100 ? 0 : (currentValue >= 10 ? 1 : 2))}`; + valueText.setText(displayLabel); + } + + // --- Initial render --- + + updateVisuals(); + + // --- Input handler helpers for the scene --- + + function handlePointerMove(pointerX: number): void { + if (!isDragging) return; + setValueFromPointer(pointerX); + } + + function handlePointerUp(): void { + isDragging = false; + } + + const destroy = (): void => { + try { track.destroy(); } catch (_) { /* ignore */ } + try { fill.destroy(); } catch (_) { /* ignore */ } + try { handle.destroy(); } catch (_) { /* ignore */ } + try { valueText.destroy(); } catch (_) { /* ignore */ } + try { hitArea.destroy(); } catch (_) { /* ignore */ } + }; + + return { + get value(): number { return currentValue; }, + track, + fill, + handle, + valueText, + hitArea, + get onValueChange(): ((value: number) => void) | null { + return _callbacks.onChange; + }, + set onValueChange(fn: ((value: number) => void) | null) { + _callbacks.onChange = fn; + }, + setValue: (value: number) => { + currentValue = Math.max(minValue, Math.min(maxValue, value)); + updateVisuals(); + }, + destroy, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/src/ui/Renderer/adapters/FeudalismAdapter.ts b/src/ui/Renderer/adapters/FeudalismAdapter.ts index 2028855e..73e31c6c 100644 --- a/src/ui/Renderer/adapters/FeudalismAdapter.ts +++ b/src/ui/Renderer/adapters/FeudalismAdapter.ts @@ -8,6 +8,9 @@ * Feudalism's original private method used the button's vertical centre as * the `y` coordinate, while the shared helper uses the top edge. * + * Additionally re-exports the shared `createActionButton` so other code can + * use the unwrapped helper when the Feudalism-specific styling is not desired. + * * @module FeudalismAdapter */ @@ -17,8 +20,12 @@ import { ActionButtonOptions, } from '../index'; +// Re-export the shared helper for callers that prefer it. +export { sharedCreateActionButton as createActionButton }; +export type { ActionButtonOptions }; + // --------------------------------------------------------------------------- -// Action button +// Feudalism-specific action button // --------------------------------------------------------------------------- /** @@ -37,6 +44,7 @@ import { * @param y - Y position (vertical centre of the button). * @param text - Label text. * @param callback - Click handler. + * @param options - Optional styling overrides. * @returns A Phaser.Container containing the button. */ export function createFeudalismActionButton( @@ -45,6 +53,7 @@ export function createFeudalismActionButton( y: number, text: string, callback: () => void, + options?: ActionButtonOptions, ): Phaser.GameObjects.Container { const btnW = 155; const btnH = 42; @@ -55,5 +64,6 @@ export function createFeudalismActionButton( strokeColor: 0x55aa55, textColor: '#88ff88', fontSize: '17px', + ...options, } as ActionButtonOptions); } diff --git a/src/ui/index.ts b/src/ui/index.ts index 16abe606..306480df 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -217,3 +217,19 @@ export type { RequestTextureFn, EnsureTextureResult, } from './Renderer'; + +// Shared Gym scene utilities – event log, deck grid, slider +// These helpers extract common rendering patterns from Gym demo scenes. +export { + createEventLog, + createDeckGrid, + createSlider, +} from './GymSceneUtils'; +export type { + EventLogOptions, + EventLogResult, + DeckGridOptions, + DeckGridResult, + SliderOptions, + SliderResult, +} from './GymSceneUtils'; diff --git a/tests/core-engine/action-commands.test.ts b/tests/core-engine/action-commands.test.ts new file mode 100644 index 00000000..52e48312 --- /dev/null +++ b/tests/core-engine/action-commands.test.ts @@ -0,0 +1,435 @@ +/** + * Action Commands — Unit & Integration Tests + * + * Tests for the shared ActionCommands module extracted into + * `src/core-engine/ActionCommands.ts`. These tests lock in the + * baseline command-wrapper behavior for do/undo semantics, + * description propagation, and UndoRedoManager compatibility. + * + * Coverage: + * - toCommand wrapper do/undo semantics + * - Description propagation + * - UndoRedoManager integration (execute → undo → redo) + * - Snapshot-based actions via createSnapshotAction + * - Negative paths (empty actions, null state) + * - Main Street command integration parity + * + * Work items: CG-0MPWZ5RPC000OB02, CG-0MPWZ5SIS00553H8 + */ +import { describe, it, expect } from 'vitest'; + +import { toCommand, createSnapshotAction, type ReversibleAction } from '../../src/core-engine/ActionCommands'; +import { UndoRedoManager, CompoundCommand } from '../../src/core-engine/UndoRedoManager'; + +// ── Simple test state ─────────────────────────────────────── + +interface SimpleState { + counter: number; + history: string[]; +} + +function createSimpleState(): SimpleState { + return { counter: 0, history: [] }; +} + +// ── toCommand tests ───────────────────────────────────────── + +describe('toCommand', () => { + describe('do/undo semantics', () => { + it('applies the action on execute (do)', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + description: 'Increment counter', + do(s) { s.counter += 1; s.history.push('do-increment'); }, + undo(s) { s.counter -= 1; s.history.push('undo-increment'); }, + }; + const cmd = toCommand(state, action); + cmd.execute(); + expect(state.counter).toBe(1); + expect(state.history).toEqual(['do-increment']); + }); + + it('reverses the action on undo', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + description: 'Increment counter', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const cmd = toCommand(state, action); + cmd.execute(); + expect(state.counter).toBe(1); + cmd.undo(); + expect(state.counter).toBe(0); + }); + + it('supports multiple do/undo cycles', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + description: 'Add value', + do(s) { s.counter += 5; }, + undo(s) { s.counter -= 5; }, + }; + const cmd = toCommand(state, action); + cmd.execute(); + expect(state.counter).toBe(5); + cmd.undo(); + expect(state.counter).toBe(0); + cmd.execute(); + expect(state.counter).toBe(5); + cmd.undo(); + expect(state.counter).toBe(0); + }); + + it('handles complex state mutations', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + description: 'Complex operation', + do(s) { + s.counter += 10; + s.history.push('op1', 'op2'); + }, + undo(s) { + s.counter -= 10; + s.history.pop(); + s.history.pop(); + }, + }; + const cmd = toCommand(state, action); + cmd.execute(); + expect(state.counter).toBe(10); + expect(state.history).toEqual(['op1', 'op2']); + cmd.undo(); + expect(state.counter).toBe(0); + expect(state.history).toEqual([]); + }); + }); + + describe('description propagation', () => { + it('propagates the action description to the command', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + description: 'My custom action', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const cmd = toCommand(state, action); + expect(cmd.description).toBe('My custom action'); + }); + + it('sets description to undefined when action has no description', () => { + const state = createSimpleState(); + const action: ReversibleAction = { + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const cmd = toCommand(state, action); + expect(cmd.description).toBeUndefined(); + }); + }); + + describe('isolated state', () => { + it('does not affect other state objects', () => { + const stateA = createSimpleState(); + const stateB = createSimpleState(); + const action: ReversibleAction = { + description: 'Modify state', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const cmdA = toCommand(stateA, action); + const cmdB = toCommand(stateB, action); + cmdA.execute(); + expect(stateA.counter).toBe(1); + expect(stateB.counter).toBe(0); + cmdB.execute(); + expect(stateA.counter).toBe(1); + expect(stateB.counter).toBe(1); + cmdA.undo(); + expect(stateA.counter).toBe(0); + expect(stateB.counter).toBe(1); + }); + }); +}); + +// ── createSnapshotAction tests ────────────────────────────── + +describe('createSnapshotAction', () => { + it('captures snapshot on first do and restores on undo', () => { + interface TmpState { items: string[]; count: number } + const state: TmpState = { items: ['a', 'b'], count: 2 }; + const action = createSnapshotAction( + (s: TmpState) => { s.items.push('c'); s.count = s.items.length; }, + (s: TmpState, snap: TmpState) => { s.items = [...snap.items]; s.count = snap.count; }, + 'Add item with snapshot', + (s: TmpState) => ({ items: [...s.items], count: s.count }), + ); + action.do(state); + expect(state.items).toEqual(['a', 'b', 'c']); + expect(state.count).toBe(3); + action.undo(state); + expect(state.items).toEqual(['a', 'b']); + expect(state.count).toBe(2); + }); + + it('reuses the same snapshot across multiple do calls', () => { + interface TmpState { value: number } + const state: TmpState = { value: 10 }; + let captureCount = 0; + const action = createSnapshotAction( + (s: TmpState) => { s.value += 5; }, + (s: TmpState, snap: TmpState) => { s.value = snap.value; }, + 'Snapshot-once', + (s: TmpState) => { captureCount++; return { value: s.value }; }, + ); + action.do(state); // First do captures snapshot + expect(state.value).toBe(15); + expect(captureCount).toBe(1); + action.undo(state); + expect(state.value).toBe(10); + action.do(state); // Second do does NOT re-capture + expect(state.value).toBe(15); + expect(captureCount).toBe(1); // Still 1 + }); + + it('is a no-op for undo when snapshot is null', () => { + interface TmpState { value: number } + const state: TmpState = { value: 10 }; + const action = createSnapshotAction( + (s: TmpState) => { s.value += 5; }, + (s: TmpState, snap: TmpState) => { s.value = snap.value; }, + undefined, + (s: TmpState) => ({ value: s.value }), + ); + // Calling undo before do should not throw + expect(() => action.undo(state)).not.toThrow(); + expect(state.value).toBe(10); + }); +}); + +// ── UndoRedoManager integration ───────────────────────────── + +describe('UndoRedoManager integration', () => { + it('supports execute → undo → redo cycle with toCommand', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + + const incAction: ReversibleAction = { + description: 'Increment', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const cmd = toCommand(state, incAction); + + manager.execute(cmd); + expect(state.counter).toBe(1); + expect(manager.canUndo()).toBe(true); + expect(manager.canRedo()).toBe(false); + + const undone = manager.undo(); + expect(undone).toBeDefined(); + expect(state.counter).toBe(0); + expect(manager.canUndo()).toBe(false); + expect(manager.canRedo()).toBe(true); + + const redone = manager.redo(); + expect(redone).toBeDefined(); + expect(state.counter).toBe(1); + expect(manager.canUndo()).toBe(true); + expect(manager.canRedo()).toBe(false); + }); + + it('clears redo stack when new command is executed after undo', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + + const action1: ReversibleAction = { + description: 'Add 1', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + const action2: ReversibleAction = { + description: 'Add 2', + do(s) { s.counter += 2; }, + undo(s) { s.counter -= 2; }, + }; + + manager.execute(toCommand(state, action1)); + manager.execute(toCommand(state, action2)); + expect(state.counter).toBe(3); + + manager.undo(); // Undo action2 + expect(state.counter).toBe(1); + expect(manager.canRedo()).toBe(true); + + manager.execute(toCommand(state, action1)); // New command clears redo + expect(state.counter).toBe(2); + expect(manager.canRedo()).toBe(false); + expect(manager.canUndo()).toBe(true); + }); + + it('supports multiple sequential commands', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + + for (let i = 1; i <= 5; i++) { + const action: ReversibleAction = { + description: `Add ${i}`, + do(s) { s.counter += i; }, + undo(s) { s.counter -= i; }, + }; + manager.execute(toCommand(state, action)); + } + + expect(state.counter).toBe(15); // 1+2+3+4+5 + + // Undo 3 steps (removes 5+4+3 from 15) + manager.undo(); + manager.undo(); + manager.undo(); + expect(state.counter).toBe(3); // 15-5-4-3 + + // Redo 2 steps (adds back 3+4) + manager.redo(); + manager.redo(); + expect(state.counter).toBe(10); // 3+3+4 + + expect(manager.undoSize).toBe(4); + expect(manager.redoSize).toBe(1); + }); + + it('handles compound commands (CompoundCommand) wrapping action commands', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + + const step1: ReversibleAction = { + description: 'Step 1: +2', + do(s) { s.counter += 2; }, + undo(s) { s.counter -= 2; }, + }; + const step2: ReversibleAction = { + description: 'Step 2: +3', + do(s) { s.counter += 3; }, + undo(s) { s.counter -= 3; }, + }; + + const compound = new CompoundCommand( + [toCommand(state, step1), toCommand(state, step2)], + 'Compound: +5', + ); + + manager.execute(compound); + expect(state.counter).toBe(5); + + manager.undo(); + expect(state.counter).toBe(0); + + manager.redo(); + expect(state.counter).toBe(5); + }); +}); + +// ── Negative / edge-case tests ────────────────────────────── + +describe('negative / edge cases', () => { + it('handles no-op action (do and undo do nothing)', () => { + const state = createSimpleState(); + const noop: ReversibleAction = { + do(_s) { /* no-op */ }, + undo(_s) { /* no-op */ }, + }; + const cmd = toCommand(state, noop); + cmd.execute(); + expect(state.counter).toBe(0); + cmd.undo(); + expect(state.counter).toBe(0); + }); + + it('handles multiple undos in sequence', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + const action: ReversibleAction = { + description: '+1', + do(s) { s.counter += 1; }, + undo(s) { s.counter -= 1; }, + }; + manager.execute(toCommand(state, action)); + manager.undo(); + // Extra undos should be no-ops + const result = manager.undo(); + expect(result).toBeUndefined(); + expect(state.counter).toBe(0); + }); + + it('handles redo when redo stack is empty', () => { + const manager = new UndoRedoManager(); + const result = manager.redo(); + expect(result).toBeUndefined(); + }); + + it('handles execute on a command that throws', () => { + const state = createSimpleState(); + const badAction: ReversibleAction = { + do(_s) { throw new Error('Action failed'); }, + undo(s) { s.counter -= 1; }, + }; + const cmd = toCommand(state, badAction); + expect(() => cmd.execute()).toThrow('Action failed'); + // State should be unchanged after thrown error + expect(state.counter).toBe(0); + }); +}); + +// ── Main Street integration parity ────────────────────────── + +describe('Main Street action command parity', () => { + it('toCommand produces a Command-compatible object usable by UndoRedoManager', () => { + const manager = new UndoRedoManager(); + const state = createSimpleState(); + + const action: ReversibleAction = { + description: 'Market buy', + do(s) { s.counter -= 5; s.history.push('bought-card'); }, + undo(s) { s.counter += 5; s.history.pop(); }, + }; + + const cmd = toCommand(state, action); + expect(typeof cmd.execute).toBe('function'); + expect(typeof cmd.undo).toBe('function'); + + manager.execute(cmd); + expect(state.counter).toBe(-5); + expect(state.history).toEqual(['bought-card']); + + manager.undo(); + expect(state.counter).toBe(0); + expect(state.history).toEqual([]); + + manager.redo(); + expect(state.counter).toBe(-5); + expect(state.history).toEqual(['bought-card']); + }); + + it('supports the snapshot pattern used by Main Street commands', () => { + interface TmpState { coins: number; items: string[] } + const state: TmpState = { coins: 100, items: ['tool'] }; + + // Simulate Main Street's snapshot pattern: + // capture snapshot before do, restore on undo + const action = createSnapshotAction( + (s: TmpState) => { s.coins -= 10; s.items.push('gadget'); }, + (s: TmpState, snap: TmpState) => { s.coins = snap.coins; s.items = [...snap.items]; }, + 'Buy gadget', + (s: TmpState) => ({ coins: s.coins, items: [...s.items] }), + ); + + const cmd = toCommand(state, action); + cmd.execute(); + expect(state.coins).toBe(90); + expect(state.items).toEqual(['tool', 'gadget']); + + cmd.undo(); + expect(state.coins).toBe(100); + expect(state.items).toEqual(['tool']); + }); +}); diff --git a/tests/gym/GymDeckRngGrid.smoke.browser.test.ts b/tests/gym/GymDeckRngGrid.smoke.browser.test.ts new file mode 100644 index 00000000..d89e14b4 --- /dev/null +++ b/tests/gym/GymDeckRngGrid.smoke.browser.test.ts @@ -0,0 +1,119 @@ +/** + * GymDeckRngGrid Smoke Test + * + * Boots GymDeckRngScene in a headless Phaser browser environment and + * verifies the deck grid renders the expected number of card objects + * with correct positions and spacing. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import Phaser from 'phaser'; +import { GymDeckRngScene } from '../../example-games/gym/scenes/GymDeckRngScene'; +import { GYM_DECK_RNG_KEY } from '../../example-games/gym/GymRegistry'; +import { waitForScene } from '../helpers/waitForScene'; + +describe('GymDeckRngScene deck grid smoke', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + if (game) game.destroy(true, false); + game = null; + const container = document.getElementById('game-container'); + if (container) container.remove(); + }); + + async function bootScene(): Promise { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.AUTO, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#1a2a1a', + scene: [GymDeckRngScene], + }); + + await waitForScene(game, GYM_DECK_RNG_KEY); + const scene = game.scene.getScene(GYM_DECK_RNG_KEY); + expect(scene).toBeTruthy(); + expect(scene.sys.isActive()).toBe(true); + return scene; + } + + /** + * Get card sprite snapshots sorted by grid position (row-major). + */ + function getCardSprites(scene: Phaser.Scene): { x: number; y: number; textureKey: string }[] { + return scene.children.list + .filter((child): child is Phaser.GameObjects.Image => child instanceof Phaser.GameObjects.Image) + .sort((a, b) => { + const yDiff = a.y - b.y; + if (Math.abs(yDiff) > 5) return yDiff; + return a.x - b.x; + }) + .map((img) => ({ + x: Math.round(img.x), + y: Math.round(img.y), + textureKey: img.texture.key, + })); + } + + // ── AC 3: 52 card sprites rendered (full deck, within grid capacity) ── + + it('renders exactly 52 card sprites (AC 3)', async () => { + const scene = await bootScene(); + const sprites = getCardSprites(scene); + expect(sprites.length).toBe(52); + }); + + // ── AC 4: Cards positioned in grid with correct spacing ────────────── + + it('positions cards in a grid pattern with consistent row/column spacing (AC 4)', async () => { + const scene = await bootScene(); + const sprites = getCardSprites(scene); + + // First row: 8 cards, all with same Y + const row0Cards = sprites.slice(0, 8); + const row0Y = row0Cards[0].y; + for (const card of row0Cards) { + expect(Math.abs(card.y - row0Y)).toBeLessThanOrEqual(3); + } + + // Columns should be evenly spaced + const colGaps: number[] = []; + for (let i = 1; i < row0Cards.length; i++) { + colGaps.push(row0Cards[i].x - row0Cards[i - 1].x); + } + // All column gaps should be similar (within 1px tolerance) + for (let i = 1; i < colGaps.length; i++) { + expect(Math.abs(colGaps[i] - colGaps[0])).toBeLessThanOrEqual(2); + } + + // Row gaps should be consistent + const row0FirstX = row0Cards[0].x; + const row1First = sprites[8]; + const rowGap = row1First.y - row0Y; + + // Row 1 should be below row 0 + expect(rowGap).toBeGreaterThan(0); + + // Check rows 0 and 1 have consistent X positions + expect(Math.abs(sprites[8].x - row0FirstX)).toBeLessThanOrEqual(3); + }); + + // ── AC 5: Grid capacity handling (52 > 8*6=48, but 52 cards fit in 7 rows) ── + + it('renders all cards without errors when deck fits in grid (no overflow) (AC 5)', async () => { + const scene = await bootScene(); + const sprites = getCardSprites(scene); + // 52 cards fit in 7 rows of 8 (56 slots), so all render + expect(sprites.length).toBe(52); + + // The last row should have 4 cards (52 % 8 = 4) + const lastRowStart = Math.floor(52 / 8) * 8; + const lastRowCards = sprites.slice(lastRowStart); + expect(lastRowCards.length).toBe(4); + }); +}); diff --git a/tests/gym/GymEventLogSmoke.smoke.browser.test.ts b/tests/gym/GymEventLogSmoke.smoke.browser.test.ts new file mode 100644 index 00000000..779c038b --- /dev/null +++ b/tests/gym/GymEventLogSmoke.smoke.browser.test.ts @@ -0,0 +1,146 @@ +/** + * GymEventLogSmoke Smoke Test + * + * Boots GymTranscriptScene and GymUndoRedoScene in a headless Phaser + * browser environment and verifies event log objects are rendered + * with correct header text and line count. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import Phaser from 'phaser'; +import { GymTranscriptScene } from '../../example-games/gym/scenes/GymTranscriptScene'; +import { GymUndoRedoScene } from '../../example-games/gym/scenes/GymUndoRedoScene'; +import { + GYM_TRANSCRIPT_KEY, + GYM_UNDO_REDO_KEY, +} from '../../example-games/gym/GymRegistry'; +import { waitForScene } from '../helpers/waitForScene'; + +describe('GymTranscriptScene event log smoke', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + if (game) game.destroy(true, false); + game = null; + const container = document.getElementById('game-container'); + if (container) container.remove(); + }); + + async function bootScene(SceneClass: any, key: string): Promise { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.AUTO, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#1a2a1a', + scene: [SceneClass], + }); + + await waitForScene(game, key); + const scene = game.scene.getScene(key); + expect(scene).toBeTruthy(); + expect(scene.sys.isActive()).toBe(true); + return scene; + } + + function countText(scene: Phaser.Scene, text: string): number { + return scene.children.list.filter( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && child.text === text, + ).length; + } + + function countTextStartingWith(scene: Phaser.Scene, prefix: string): number { + return scene.children.list.filter( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && child.text.startsWith(prefix), + ).length; + } + + // AC 2: Transcript scene event log header + + it('Transcript scene has event log header text (AC 2)', async () => { + const scene = await bootScene(GymTranscriptScene, GYM_TRANSCRIPT_KEY); + expect(countText(scene, '── Event Log ──')).toBeGreaterThanOrEqual(1); + }); + + // AC 3: UndoRedo scene event log header + + it('UndoRedo scene has event log header text (AC 3)', async () => { + const scene = await bootScene(GymUndoRedoScene, GYM_UNDO_REDO_KEY); + expect(countText(scene, '── Event Log ──')).toBeGreaterThanOrEqual(1); + }); + + // AC 4: Log entry count (at most 12 for UndoRedo, 16 for Transcript) + + it('UndoRedo scene renders at most 12 log lines (AC 4)', async () => { + const scene = await bootScene(GymUndoRedoScene, GYM_UNDO_REDO_KEY); + + // UndoRedo log entries start with "Executed", "Undid", "Redid", or "History " + // The status text "History: (empty)" does NOT start with "History " + const logEntryCount = ( + countTextStartingWith(scene, 'Executed') + + countTextStartingWith(scene, 'Undid') + + countTextStartingWith(scene, 'Redid') + + countTextStartingWith(scene, 'History ') + ); + + expect(logEntryCount).toBeLessThanOrEqual(12); + }); + + it('Transcript scene renders at most 16 log lines (AC 4)', async () => { + const scene = await bootScene(GymTranscriptScene, GYM_TRANSCRIPT_KEY); + + // TranscriptScene logs 'New session (seed=42)' on create() + const logEntryCount = ( + countTextStartingWith(scene, 'New session') + + countTextStartingWith(scene, 'Recorded') + + countTextStartingWith(scene, 'Finalized') + + countTextStartingWith(scene, 'Transcript') + + countTextStartingWith(scene, 'Playing') + + countTextStartingWith(scene, 'No session') + + countTextStartingWith(scene, 'No events') + + countTextStartingWith(scene, '[PLAY]') + + countTextStartingWith(scene, ' ->') + ); + + expect(logEntryCount).toBeLessThanOrEqual(16); + }); + + // AC 5: Log truncation when exceeding maxLines + + it('UndoRedo event log drops oldest entries when exceeding maxLines (AC 5)', async () => { + const scene = await bootScene(GymUndoRedoScene, GYM_UNDO_REDO_KEY); + + // Click buttons to generate many log entries (exceeding maxLines=12) + const buttons = scene.children.list.filter( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && + ['[ +1 ]', '[ +5 ]', '[ -3 ]', '[ Undo ]', '[ Redo ]', '[ Clear History ]'].includes(child.text), + ); + + for (let click = 0; click < 3; click++) { + for (const btn of buttons) { + btn.emit('pointerdown'); + } + } + + // After many clicks, log should still have at most 12 entries + const logEntryCount = ( + countTextStartingWith(scene, 'Executed') + + countTextStartingWith(scene, 'Undid') + + countTextStartingWith(scene, 'Redid') + + countTextStartingWith(scene, 'History ') + ); + + expect(logEntryCount).toBeLessThanOrEqual(12); + + // With 3 clicks x 6 buttons = 18 entries, we should have some entries + if (logEntryCount > 0) { + expect(logEntryCount).toBeGreaterThanOrEqual(1); + } + }); +}); diff --git a/tests/gym/GymHandPile.test.ts b/tests/gym/GymHandPile.test.ts index 59bb7eab..a8f09fd8 100644 --- a/tests/gym/GymHandPile.test.ts +++ b/tests/gym/GymHandPile.test.ts @@ -234,8 +234,8 @@ describe('Gym Hand & Pile integration with HandView/PileView', () => { expect(source).toContain('HAND_BASE_Y = GAME_H - CARD_H - 80'); expect(source).toContain('showLabels: false'); expect(source).toContain('arcRadius: this.arcRadius'); - expect(source).toContain('ARC_RADIUS_MIN = 0'); - expect(source).toContain('ARC_RADIUS_MAX = 200'); + expect(source).toContain('minValue: 0'); + expect(source).toContain('maxValue: 200'); expect(source).toContain('setArcRadius'); }); }); \ No newline at end of file diff --git a/tests/gym/GymHandPileRotation.test.ts b/tests/gym/GymHandPileRotation.test.ts index ec4d7699..5b6e5797 100644 --- a/tests/gym/GymHandPileRotation.test.ts +++ b/tests/gym/GymHandPileRotation.test.ts @@ -3,9 +3,9 @@ import fs from 'fs'; import path from 'path'; describe('GymHandPileScene rotation slider presence', () => { - it('scene source contains createRotationSlider and setMaxRotationDegrees usage', () => { + it('scene source contains rotationSlider and setMaxRotationDegrees usage', () => { const source = fs.readFileSync(path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), 'utf-8'); - expect(source).toContain('createRotationSlider'); + expect(source).toContain('rotationSlider'); expect(source).toContain('setMaxRotationDegrees('); }); diff --git a/tests/gym/GymHandPileSpacing.test.ts b/tests/gym/GymHandPileSpacing.test.ts index 0b06dc41..6d8b343b 100644 --- a/tests/gym/GymHandPileSpacing.test.ts +++ b/tests/gym/GymHandPileSpacing.test.ts @@ -3,9 +3,9 @@ import fs from 'fs'; import path from 'path'; describe('GymHandPileScene spacing slider presence', () => { - it('source contains createSpacingSlider and setSpacing usage', () => { + it('source contains createSlider for spacing and setSpacing usage', () => { const source = fs.readFileSync(path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), 'utf-8'); - expect(source).toContain('createSpacingSlider'); + expect(source).toContain('spacingSlider'); expect(source).toContain('setSpacing('); expect(source).toContain('CARD_W'); }); diff --git a/tests/gym/GymSaveLoad.test.ts b/tests/gym/GymSaveLoad.test.ts index 8255e744..48506d15 100644 --- a/tests/gym/GymSaveLoad.test.ts +++ b/tests/gym/GymSaveLoad.test.ts @@ -121,4 +121,121 @@ describe('Gym Save/Load scenarios', () => { const afterRemove = await store.loadSerialized('run-checkpoint', 'gym-test-rm', 'slot-rm', DEMO_SERIALIZER); expect(afterRemove).toBeNull(); }); +}); + +// ── Snapshot data persistence ─────────────────────────────────── + +describe('Snapshot data persistence in SaveLoadStore', () => { + /** State type that includes a snapshot data URL. */ + interface SnapState { + counter: number; + label: string; + snapshotDataUrl: string | null; + } + + interface SnapSerialized { + c: number; + l: string; + s: string | null; + } + + const SNAP_SERIALIZER: SaveSerializer = { + schemaVersion: 1, + serialize(state: SnapState): SnapSerialized { + return { c: state.counter, l: state.label, s: state.snapshotDataUrl }; + }, + deserialize(data: SnapSerialized): SnapState { + return { counter: data.c, label: data.l, snapshotDataUrl: data.s ?? null }; + }, + }; + + beforeEach(() => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('localStorage', createLocalStorageMock()); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('serializes and deserializes snapshot data correctly', () => { + const state: SnapState = { + counter: 1, + label: 'snap-test', + snapshotDataUrl: 'data:image/png;base64,abc123', + }; + const serialized = SNAP_SERIALIZER.serialize(state); + expect(serialized.c).toBe(1); + expect(serialized.l).toBe('snap-test'); + expect(serialized.s).toBe('data:image/png;base64,abc123'); + + const deserialized = SNAP_SERIALIZER.deserialize(serialized); + expect(deserialized.counter).toBe(1); + expect(deserialized.label).toBe('snap-test'); + expect(deserialized.snapshotDataUrl).toBe('data:image/png;base64,abc123'); + }); + + it('snapshot data survives save-to-load round-trip', async () => { + const store = new SaveLoadStore({ localStoragePrefix: 'gym-test-snap-rt' }); + const state: SnapState = { + counter: 5, + label: 'round-trip', + snapshotDataUrl: 'data:image/png;base64,xyz789', + }; + + await store.saveSerialized('run-checkpoint', 'gym-snap', 'slot-rt', SNAP_SERIALIZER, state); + const loaded = await store.loadSerialized('run-checkpoint', 'gym-snap', 'slot-rt', SNAP_SERIALIZER); + + expect(loaded).not.toBeNull(); + expect(loaded!.counter).toBe(5); + expect(loaded!.label).toBe('round-trip'); + expect(loaded!.snapshotDataUrl).toBe('data:image/png;base64,xyz789'); + + await store.clear('run-checkpoint', 'gym-snap'); + }); + + it('null snapshot dataUrl is handled correctly', async () => { + const store = new SaveLoadStore({ localStoragePrefix: 'gym-test-snap-null' }); + const state: SnapState = { + counter: 10, + label: 'null-snap', + snapshotDataUrl: null, + }; + + await store.saveSerialized('run-checkpoint', 'gym-snap-null', 'slot-n', SNAP_SERIALIZER, state); + const loaded = await store.loadSerialized('run-checkpoint', 'gym-snap-null', 'slot-n', SNAP_SERIALIZER); + + expect(loaded).not.toBeNull(); + expect(loaded!.snapshotDataUrl).toBeNull(); + + await store.clear('run-checkpoint', 'gym-snap-null'); + }); + + it('clearing snapshot and re-saving removes persisted snapshot data', async () => { + const store = new SaveLoadStore({ localStoragePrefix: 'gym-test-snap-clear' }); + + // Save with snapshot + const stateWithSnap: SnapState = { + counter: 20, + label: 'with-snap', + snapshotDataUrl: 'data:image/png;base64,clear-me', + }; + await store.saveSerialized('run-checkpoint', 'gym-clear', 'slot-c', SNAP_SERIALIZER, stateWithSnap); + + // Overwrite without snapshot + const stateWithoutSnap: SnapState = { + counter: 20, + label: 'with-snap', + snapshotDataUrl: null, + }; + await store.saveSerialized('run-checkpoint', 'gym-clear', 'slot-c', SNAP_SERIALIZER, stateWithoutSnap); + + const loaded = await store.loadSerialized('run-checkpoint', 'gym-clear', 'slot-c', SNAP_SERIALIZER); + expect(loaded).not.toBeNull(); + expect(loaded!.snapshotDataUrl).toBeNull(); + + await store.clear('run-checkpoint', 'gym-clear'); + }); }); \ No newline at end of file diff --git a/tests/gym/GymSceneUtils.smoke.test.ts b/tests/gym/GymSceneUtils.smoke.test.ts new file mode 100644 index 00000000..3b93f468 --- /dev/null +++ b/tests/gym/GymSceneUtils.smoke.test.ts @@ -0,0 +1,341 @@ +/** + * GymSceneUtils Smoke Test Suite + * + * Integration smoke tests for the createEventLog, createDeckGrid, + * and createSlider utilities exported from src/ui/GymSceneUtils.ts. + * + * Uses a minimal Phaser mock to test each helper's public API surface. + * Mocks the Renderer module to avoid Phaser import in node environment. + */ +import { describe, expect, it, vi } from 'vitest'; + +// Mock the Renderer module to avoid Phaser dependency in node environment. +// This allows unit-style testing of GymSceneUtils without browser runtime. +vi.mock('../../src/ui/Renderer', () => { + const FONT_FAMILY = 'monospace'; + const createHudText = vi.fn((_scene: any, x: number, y: number, text: string, color: string, options?: any) => ({ + x, y, text, color, options, + setOrigin: vi.fn().mockReturnThis(), + setText: vi.fn().mockImplementation(function (this: any, t: string) { this.text = t; }), + setPosition: vi.fn().mockImplementation(function (this: any, px: number, py: number) { this.x = px; this.y = py; }), + setColor: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + destroy: vi.fn(), + })); + return { createHudText, FONT_FAMILY }; +}); + +// Mock CardTextureHelpers to avoid Phaser dependency +vi.mock('../../src/ui/CardTextureHelpers', () => ({ + getCardTexture: vi.fn((_card: any) => 'mock-texture'), +})); + +// Mock constants to avoid Phaser dependency +vi.mock('../../src/ui/constants', () => ({ + GAME_W: 1280, + GAME_H: 720, + CARD_W: 48, + CARD_H: 65, +})); + +import { createEventLog, createDeckGrid, createSlider } from '../../src/ui/GymSceneUtils'; +import type { Card } from '../../src/card-system/Card'; + +// ── Minimal Phaser mock ───────────────────────────────────── + +function createMockScene(): any { + const mockText = (x: number, y: number, text: string) => ({ + x, y, text, + setOrigin: vi.fn().mockReturnThis(), + setText: vi.fn().mockImplementation(function (this: any, t: string) { this.text = t; }), + setPosition: vi.fn().mockImplementation(function (this: any, px: number, py: number) { this.x = px; this.y = py; }), + setColor: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }); + + const mockImage = (x: number, y: number, texture: string) => ({ + x, y, texture: { key: texture }, + setInteractive: vi.fn().mockReturnThis(), + setScale: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setTexture: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + destroy: vi.fn(), + active: true, + }); + + const mockRectangle = (x: number, y: number, w: number, h: number) => ({ + x, y, width: w, height: h, + setOrigin: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockImplementation(function (this: any, px: number, py: number) { this.x = px; this.y = py; }), + setSize: vi.fn().mockImplementation(function (this: any, pw: number, ph: number) { this.width = pw; this.height = ph; }), + setStrokeStyle: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }); + + const mockGraphics = () => ({ + clear: vi.fn().mockReturnThis(), + fillStyle: vi.fn().mockReturnThis(), + fillCircle: vi.fn().mockReturnThis(), + lineStyle: vi.fn().mockReturnThis(), + strokeCircle: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }); + + const mockZone = (x: number, y: number, w: number, h: number) => ({ + x, y, width: w, height: h, + setInteractive: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }); + + const objects: any[] = []; + const addTracker = (obj: any) => { objects.push(obj); return obj; }; + + return { + add: { + text: vi.fn().mockImplementation((x: number, y: number, text: string) => addTracker(mockText(x, y, text))), + image: vi.fn().mockImplementation((x, y, texture) => addTracker(mockImage(x, y, texture))), + rectangle: vi.fn().mockImplementation((x: number, y: number, w: number, h: number) => addTracker(mockRectangle(x, y, w, h))), + graphics: vi.fn().mockImplementation(() => addTracker(mockGraphics())), + zone: vi.fn().mockImplementation((x, y, w, h) => addTracker(mockZone(x, y, w, h))), + }, + input: { + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + }, + events: { + on: vi.fn().mockReturnThis(), + once: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + }, + children: { list: objects }, + }; +} + +// ── createEventLog tests ──────────────────────────────────── + +describe('createEventLog', () => { + it('returns header and line objects with default header text', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200); + + expect(result).toBeDefined(); + expect(result.header).toBeDefined(); + expect(result.header.text).toBe('── Event Log ──'); + expect(typeof result.render).toBe('function'); + expect(typeof result.destroy).toBe('function'); + }); + + it('renders log lines with correct count', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200, { maxLines: 14 }); + + result.render(['line 1', 'line 2', 'line 3']); + + expect(result.lines.length).toBe(3); + expect(result.lines[0].text).toBe('line 1'); + expect(result.lines[2].text).toBe('line 3'); + }); + + it('truncates lines beyond maxLines', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200, { maxLines: 2 }); + + result.render(['line 1', 'line 2', 'line 3']); + + expect(result.lines.length).toBe(2); + expect(result.lines[0].text).toBe('line 2'); + expect(result.lines[1].text).toBe('line 3'); + }); + + it('handles empty lines array', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200); + + result.render([]); + + expect(result.lines.length).toBe(0); + expect(result.header).toBeDefined(); + }); + + it('accepts custom header text', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200, { headerText: '── Sound Call Log ──' }); + + expect(result.header.text).toBe('── Sound Call Log ──'); + }); + + it('destroy cleans up objects without error', () => { + const scene = createMockScene(); + const result = createEventLog(scene, 200); + result.render(['line 1']); + result.destroy(); + expect(result.lines.length).toBe(0); + }); +}); + +// ── createDeckGrid tests ──────────────────────────────────── + +describe('createDeckGrid', () => { + it('returns sprite array for a non-empty deck', () => { + const scene = createMockScene(); + const mockDeck: Card[] = [ + { rank: 'A', suit: 'hearts', faceUp: false } as Card, + { rank: 'K', suit: 'spades', faceUp: false } as Card, + { rank: 'Q', suit: 'diamonds', faceUp: false } as Card, + ]; + + const result = createDeckGrid(scene, mockDeck, { cols: 8, centerX: 400, centerY: 300 }); + + expect(result).toBeDefined(); + expect(result.sprites).toBeDefined(); + expect(Array.isArray(result.sprites)).toBe(true); + expect(result.sprites.length).toBe(3); + expect(typeof result.destroy).toBe('function'); + }); + + it('sets cards face-up', () => { + const scene = createMockScene(); + const mockDeck: Card[] = [ + { rank: 'A', suit: 'hearts', faceUp: false } as Card, + ]; + + createDeckGrid(scene, mockDeck); + + expect(mockDeck[0].faceUp).toBe(true); + }); + + it('handles empty deck without errors', () => { + const scene = createMockScene(); + const result = createDeckGrid(scene, []); + expect(result.sprites).toBeDefined(); + expect(result.sprites.length).toBe(0); + }); + + it('renders cards in grid pattern with multiple rows', () => { + const scene = createMockScene(); + const mockDeck: Card[] = Array.from({ length: 10 }, (_, i) => ({ + rank: String(i), suit: 'clubs', faceUp: false, + }) as unknown as Card); + + const result = createDeckGrid(scene, mockDeck, { cols: 8, gapX: 4, gapY: 4 }); + + expect(result.sprites.length).toBe(10); + + const row0Y = result.sprites[0].y; + const row1Y = result.sprites[8].y; + expect(row1Y).toBeGreaterThan(row0Y); + }); + + it('destroy cleans up all sprites', () => { + const scene = createMockScene(); + const mockDeck: Card[] = [ + { rank: 'A', suit: 'hearts', faceUp: false } as Card, + { rank: 'K', suit: 'spades', faceUp: false } as Card, + ]; + + const result = createDeckGrid(scene, mockDeck); + expect(result.sprites.length).toBe(2); + + result.destroy(); + expect(result.sprites.length).toBe(0); + }); +}); + +// ── createSlider tests ────────────────────────────────────── + +describe('createSlider', () => { + it('returns config object with visual elements and handlers', () => { + const scene = createMockScene(); + const result = createSlider(scene, 100, 200, { + initialValue: 0.5, minValue: 0, maxValue: 1, label: 'Test', + }); + + expect(result).toBeDefined(); + expect(result.track).toBeDefined(); + expect(result.fill).toBeDefined(); + expect(result.handle).toBeDefined(); + expect(result.valueText).toBeDefined(); + expect(result.hitArea).toBeDefined(); + expect(typeof result.setValue).toBe('function'); + expect(typeof result.handlePointerMove).toBe('function'); + expect(typeof result.handlePointerUp).toBe('function'); + expect(typeof result.destroy).toBe('function'); + }); + + it('initializes with correct default value', () => { + const scene = createMockScene(); + const result = createSlider(scene, 100, 200, { + initialValue: 0.75, minValue: 0, maxValue: 1, + }); + + expect(result.value).toBeCloseTo(0.75, 5); + }); + + it('setValue clamps to min/max', () => { + const scene = createMockScene(); + const result = createSlider(scene, 100, 200, { + initialValue: 0.5, minValue: 0, maxValue: 100, + }); + + result.setValue(150); + expect(result.value).toBe(100); + + result.setValue(-10); + expect(result.value).toBe(0); + }); + + it('handlePointerMove updates value while dragging', () => { + const scene = createMockScene(); + const result = createSlider(scene, 100, 200, { + initialValue: 0, minValue: 0, maxValue: 100, width: 200, + }); + + // Simulate pointerdown via hitArea + const onMock = result.hitArea.on as unknown as ReturnType; + for (const call of onMock.mock.calls) { + if (call[0] === 'pointerdown') { + call[1]({ x: 150 }); + } + } + + result.handlePointerMove(200); + expect(result.value).toBeGreaterThan(0); + + const valueBeforeUp = result.value; + result.handlePointerUp(); + + // After pointer up, pointermove should not change value + expect(result.value).toBe(valueBeforeUp); + }); + + it('destroy cleans up all objects', () => { + const scene = createMockScene(); + const result = createSlider(scene, 100, 200); + result.destroy(); + // No crash on second destroy + result.destroy(); + }); + + it('fires onValueChange when value changes', () => { + const scene = createMockScene(); + const onChange = vi.fn(); + const result = createSlider(scene, 100, 200, { + initialValue: 0, minValue: 0, maxValue: 100, width: 200, + }); + + result.onValueChange = onChange; + + const onMock = result.hitArea.on as unknown as ReturnType; + for (const call of onMock.mock.calls) { + if (call[0] === 'pointerdown') { + call[1]({ x: 200 }); + } + } + + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/tests/main-street/README.md b/tests/main-street/README.md index a1d8a831..049f775b 100644 --- a/tests/main-street/README.md +++ b/tests/main-street/README.md @@ -1,5 +1,35 @@ # Main Street test notes +## Market Offer Engine — Extraction Parity Tests + +`market-extraction-parity.test.ts` (CG-0MPWZ5R1M001MZ3B) locks in current Main Street +market behavior before the `MarketOfferEngine` is extracted into `src/card-system`. +These tests serve as the regression oracle during migration. + +### Covered scenarios + +| Category | Functions under test | Count | +|---|---|---| +| Market row retrieval | `findTargetBusinessSlot`, `getAffordableUpgradeCards` | 10 | +| Positive-path buy eligibility | `canPurchaseBusiness`, `canPurchaseUpgrade`, `canPurchaseEvent` | 3 | +| Negative-path buy eligibility | `canPurchaseBusiness`, `canPurchaseUpgrade`, `canPurchaseEvent`, `canRefreshInvestments` | 12 | +| Positive-path purchase results | `purchaseBusiness`, `purchaseUpgrade`, `purchaseEvent`, `refreshInvestments` | 5 | +| Invalid row/slot selection | `purchaseBusiness`, `purchaseUpgrade`, `purchaseEvent`, `refreshInvestments` | 7 | +| Refill policy — incident queue | `refillIncidentQueue` | 5 | +| Refill policy — exhaustion | `refillInvestmentsMarket`, `refillBusinessMarket`, `refillAllMarkets` | 3 | +| Refill policy — reshuffle from discard | `reshuffleIfNeeded` (business/upgrade/event decks) | 5 | +| Multi-turn integration | `executeDayStart`, `processEndOfTurn`, `executeAction` | 7 | + +**Total: 57 tests** + +### Known gaps + +- These tests use Main Street's current implementation as the oracle; they do not yet validate + against a future `src/card-system/MarketOfferEngine` module (follow-up work). +- Audio/feedback side effects of market operations are not covered here (see + `GymAudioFeedback.test.ts`). +- Browser-level UI rendering of the market is covered by separate layout tests. + ## Layout regression maintenance The browser test `MainStreetLayoutAnchors.browser.test.ts` asserts explicit numeric bounds for: diff --git a/tests/main-street/market-extraction-parity.test.ts b/tests/main-street/market-extraction-parity.test.ts new file mode 100644 index 00000000..e713a0a3 --- /dev/null +++ b/tests/main-street/market-extraction-parity.test.ts @@ -0,0 +1,992 @@ +/** + * Main Street: Market Offer Engine — Extraction Parity Tests + * + * Tests that lock in current Market Offer behavior before extraction + * from Main Street to shared `src/card-system`. These tests cover: + * - Market row retrieval helpers (findTargetBusinessSlot, refillIncidentQueue) + * - Buy eligibility negative paths (insufficient coins, incident events, wrong phase) + * - Purchase result edge cases + * - Refill policy behaviors (incident queue, deck exhaustion) + * - Integration: multi-turn market flow parity (purchase → end-turn → refill) + * + * @module + */ +import { describe, it, expect } from 'vitest'; + +import { setupMainStreetGame, type MainStreetState } from '../../example-games/main-street/MainStreetState'; +import { + canPurchaseBusiness, + canPurchaseUpgrade, + canPurchaseEvent, + purchaseBusiness, + purchaseUpgrade, + purchaseEvent, + refillBusinessMarket, + refillInvestmentsMarket, + refillIncidentQueue, + refillAllMarkets, + canRefreshInvestments, + refreshInvestments, + findTargetBusinessSlot, + getAffordableUpgradeCards, + getEmptySlots, + getAffordableBusinessCards, + type RefreshResult, +} from '../../example-games/main-street/MainStreetMarket'; +import { executeDayStart, processEndOfTurn, executeAction } from '../../example-games/main-street/MainStreetEngine'; +import { + GRID_SIZE, + MARKET_BUSINESS_SLOTS, + MARKET_INVESTMENT_SLOTS, + MARKET_INVESTMENT_UPGRADE_COUNT, + INCIDENT_QUEUE_SIZE, + REFRESH_INVESTMENTS_COST, + type UpgradeCard, + type EventCard, +} from '../../example-games/main-street/MainStreetCards'; + +// ── Helpers ───────────────────────────────────────────────── + +function createTestState(seed: string = 'extraction-parity'): MainStreetState { + return setupMainStreetGame({ seed }); +} + +// ── Market Row Retrieval ──────────────────────────────────── + +describe('MarketOfferEngine — row retrieval', () => { + describe('findTargetBusinessSlot', () => { + it('should return the slot index of a matching business at the required level', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + // Place a matching business at the required level + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[3] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + + const slot = findTargetBusinessSlot(state, upgrade); + expect(slot).toBe(3); + }); + + it('should return -1 when no matching business exists on the street', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + // Street is empty + const slot = findTargetBusinessSlot(state, upgrade); + expect(slot).toBe(-1); + }); + + it('should return -1 when the matching business is at a different level', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + // Place at wrong level + state.streetGrid[0] = { ...biz, level: (upgrade.requiredLevel ?? 0) + 1 }; + + const slot = findTargetBusinessSlot(state, upgrade); + expect(slot).toBe(-1); + }); + + it('should return -1 when the matching business is already at maxLevel', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + // Place at max level + state.streetGrid[0] = { ...biz, level: biz.maxLevel }; + + const slot = findTargetBusinessSlot(state, upgrade); + expect(slot).toBe(-1); + }); + + it('should default requiredLevel to 0 when not specified', () => { + const state = createTestState(); + // Create a synthetic upgrade without requiredLevel + const syntheticUpgrade: UpgradeCard = { + family: 'upgrade', + id: 'syn-upgrade-no-level', + name: 'Synthetic Upgrade', + targetBusiness: 'Pizzeria', + cost: 3, + incomeBonus: 1, + synergyRangeBonus: 0, + description: 'Test upgrade without requiredLevel', + // requiredLevel omitted — should default to 0 + }; + + // Place a Pizzeria at level 0 + const biz = state.decks.business.find(b => b.name === 'Pizzeria'); + if (!biz) return; + state.streetGrid[2] = { ...biz, level: 0 }; + + const slot = findTargetBusinessSlot(state, syntheticUpgrade); + expect(slot).toBe(2); + }); + + it('should return the first matching slot when multiple candidates exist', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + + // Place matching businesses in slots 5 and 7 + state.streetGrid[5] = { ...biz, id: `${biz.id}-a`, level: upgrade.requiredLevel ?? 0 }; + state.streetGrid[7] = { ...biz, id: `${biz.id}-b`, level: upgrade.requiredLevel ?? 0 }; + + const slot = findTargetBusinessSlot(state, upgrade); + expect(slot).toBe(5); // First matching slot + }); + }); + + describe('getAffordableUpgradeCards', () => { + it('should return upgrades the player can afford with valid targets', () => { + const state = createTestState(); + // Place a business that can be upgraded + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + state.resourceBank.coins = 100; + + const affordable = getAffordableUpgradeCards(state); + const affordableIds = affordable.map(c => c.id); + expect(affordableIds).toContain(upgrade.id); + }); + + it('should exclude upgrades when player cannot afford them', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + // Set coins below the upgrade cost + state.resourceBank.coins = Math.max(0, upgrade.cost - 1); + + const affordable = getAffordableUpgradeCards(state); + const affordableIds = affordable.map(c => c.id); + expect(affordableIds).not.toContain(upgrade.id); + }); + + it('should exclude upgrades when no valid target business exists', () => { + const state = createTestState(); + state.resourceBank.coins = 100; + // Street is empty — no targets + const affordable = getAffordableUpgradeCards(state); + // Any returned upgrades must have valid targets (the filter checks this) + for (const card of affordable) { + const hasTarget = state.streetGrid.some( + b => b !== null && b.name === card.targetBusiness && b.level < b.maxLevel, + ); + expect(hasTarget).toBe(true); + } + }); + + it('should exclude upgrades targeting businesses already at maxLevel', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + // Place at max level + state.streetGrid[0] = { ...biz, level: biz.maxLevel }; + state.resourceBank.coins = 100; + + const affordable = getAffordableUpgradeCards(state); + const affordableIds = affordable.map(c => c.id); + expect(affordableIds).not.toContain(upgrade.id); + }); + }); +}); + +// ── Negative-Path: Buy Eligibility ────────────────────────── + +describe('MarketOfferEngine — negative-path buy eligibility', () => { + describe('canPurchaseBusiness — insufficient coins', () => { + it('should reject when coins equal cost minus 1', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = card.cost - 1; + const result = canPurchaseBusiness(state, card.id, 0); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('Not enough coins'); + expect(result.reason).toContain(String(card.cost)); + } + }); + + it('should reject when coins are exactly zero and card costs more than zero', () => { + const state = createTestState(); + state.resourceBank.coins = 0; + const card = state.market.business.find(c => c.cost > 0); + if (!card) return; + const result = canPurchaseBusiness(state, card.id, 0); + expect(result.legal).toBe(false); + }); + }); + + describe('canPurchaseUpgrade — insufficient coins', () => { + it('should reject upgrade purchase when coins are less than card cost', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + state.resourceBank.coins = upgrade.cost - 1; + + const result = canPurchaseUpgrade(state, upgrade.id); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('Not enough coins'); + expect(result.reason).toContain(String(upgrade.cost)); + } + }); + }); + + describe('canPurchaseEvent — incident events not purchasable', () => { + it('should reject purchase of an Incident-trigger event', () => { + const state = createTestState(); + // Find an Incident event and inject it into the investments row + const incidentEvent = state.decks.event.find(e => e.trigger === 'Incident'); + if (!incidentEvent) return; + + state.market.investments = [incidentEvent as EventCard]; + state.resourceBank.coins = 100; + + const result = canPurchaseEvent(state, incidentEvent.id); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('Incident'); + expect(result.reason).toContain('purchased'); + } + }); + + it('should reject event purchase when coins are insufficient', () => { + const state = createTestState(); + const investmentEvent = state.market.investments.find( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard | undefined; + if (!investmentEvent) return; + + state.resourceBank.coins = investmentEvent.cost - 1; + const result = canPurchaseEvent(state, investmentEvent.id); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('Not enough coins'); + expect(result.reason).toContain(String(investmentEvent.cost)); + } + }); + }); + + describe('canRefreshInvestments — negative paths', () => { + it('should reject refresh outside MarketPhase', () => { + const state = createTestState(); + state.phase = 'DayStart'; + state.resourceBank.coins = REFRESH_INVESTMENTS_COST + 10; + + const result = canRefreshInvestments(state); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('MarketPhase'); + } + }); + + it('should reject refresh when coins exactly equal cost minus 1', () => { + const state = createTestState(); + state.phase = 'MarketPhase'; + state.resourceBank.coins = REFRESH_INVESTMENTS_COST - 1; + + const result = canRefreshInvestments(state); + expect(result.legal).toBe(false); + }); + + it('should allow refresh when coins exactly equal cost', () => { + const state = createTestState(); + state.phase = 'MarketPhase'; + state.resourceBank.coins = REFRESH_INVESTMENTS_COST; + + const result = canRefreshInvestments(state); + expect(result.legal).toBe(true); + }); + }); + + describe('canPurchaseBusiness — card not found', () => { + it('should reject when the card ID does not exist in the business market', () => { + const state = createTestState(); + const result = canPurchaseBusiness(state, 'nonexistent-card-id', 0); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('not found'); + } + }); + }); + + describe('canPurchaseUpgrade — card not found', () => { + it('should reject when the card ID does not exist in the investments row', () => { + const state = createTestState(); + state.resourceBank.coins = 100; + const result = canPurchaseUpgrade(state, 'nonexistent-upgrade-id'); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('not found'); + } + }); + }); + + describe('canPurchaseEvent — card not found and already holding', () => { + it('should reject when the card ID does not exist in the investments row', () => { + const state = createTestState(); + const result = canPurchaseEvent(state, 'nonexistent-event-id'); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('not found'); + } + }); + + it('should reject when the player is already holding an Investment event', () => { + const state = createTestState(); + // Set a held event + state.heldEvent = { + family: 'event', + id: 'evt-held-parity', + name: 'Held Event', + trigger: 'Investment', + effect: 'test', + target: 'All', + coinDelta: 0, + reputationDelta: 0, + cost: 0, + }; + // Find an Investment event in investments row + const investmentEvent = state.market.investments.find( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard | undefined; + if (!investmentEvent) return; + + state.resourceBank.coins = 100; + const result = canPurchaseEvent(state, investmentEvent.id); + expect(result.legal).toBe(false); + if (!result.legal) { + expect(result.reason).toContain('Already holding'); + } + }); + }); +}); + +// ── Positive-Path: Buy Eligibility ────────────────────────── + +describe('MarketOfferEngine — positive-path buy eligibility', () => { + describe('canPurchaseBusiness — success', () => { + it('should allow purchase when player has enough coins and slot is empty', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = card.cost; + const result = canPurchaseBusiness(state, card.id, 0); + expect(result.legal).toBe(true); + }); + }); + + describe('canPurchaseUpgrade — success', () => { + it('should allow upgrade purchase when player can afford and target exists', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + state.resourceBank.coins = upgrade.cost; + + const result = canPurchaseUpgrade(state, upgrade.id); + expect(result.legal).toBe(true); + }); + }); + + describe('canPurchaseEvent — success', () => { + it('should allow purchase of an Investment-trigger event when player can afford', () => { + const state = createTestState(); + const investmentEvent = state.market.investments.find( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard | undefined; + if (!investmentEvent) return; + + state.resourceBank.coins = investmentEvent.cost; + const result = canPurchaseEvent(state, investmentEvent.id); + expect(result.legal).toBe(true); + }); + }); +}); + +// ── Positive-Path: Purchase Results ───────────────────────── + +describe('MarketOfferEngine — positive-path purchase results', () => { + describe('purchaseBusiness — success', () => { + it('should deduct coins, place card in slot, and remove from market', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = 100; + const coinsBefore = state.resourceBank.coins; + + const result = purchaseBusiness(state, card.id, 0); + + expect(result.card.id).toBe(card.id); + expect(result.cost).toBe(card.cost); + expect(state.resourceBank.coins).toBe(coinsBefore - card.cost); + expect(state.streetGrid[0]).not.toBeNull(); + expect(state.streetGrid[0]!.id).toBe(card.id); + expect(state.market.business.map(c => c.id)).not.toContain(card.id); + }); + + it('should not refill the market immediately after purchase', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = 100; + purchaseBusiness(state, card.id, 0); + expect(state.market.business).toHaveLength(MARKET_BUSINESS_SLOTS - 1); + }); + }); + + describe('purchaseUpgrade — success', () => { + it('should deduct coins and level up the target business', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + state.resourceBank.coins = 100; + const coinsBefore = state.resourceBank.coins; + const levelBefore = state.streetGrid[0]!.level; + const incomeBefore = state.streetGrid[0]!.incomeBonus; + const rangeBefore = state.streetGrid[0]!.synergyRangeBonus; + + purchaseUpgrade(state, upgrade.id); + + expect(state.resourceBank.coins).toBe(coinsBefore - upgrade.cost); + expect(state.streetGrid[0]!.level).toBe(levelBefore + 1); + expect(state.streetGrid[0]!.incomeBonus).toBe(incomeBefore + upgrade.incomeBonus); + expect(state.streetGrid[0]!.synergyRangeBonus).toBe(rangeBefore + upgrade.synergyRangeBonus); + }); + }); + + describe('purchaseEvent — success', () => { + it('should set heldEvent and remove event from investments row', () => { + const state = createTestState(); + const investmentEvt: EventCard = { + family: 'event', + id: 'evt-parity-test', + name: 'Parity Test Festival', + trigger: 'Investment', + effect: '+1 coin test', + target: 'All', + coinDelta: 1, + reputationDelta: 0, + cost: 3, + }; + state.market.investments = [investmentEvt]; + state.resourceBank.coins = investmentEvt.cost; + const coinsBefore = state.resourceBank.coins; + + purchaseEvent(state, investmentEvt.id); + + expect(state.heldEvent).not.toBeNull(); + expect(state.heldEvent!.id).toBe(investmentEvt.id); + expect(state.resourceBank.coins).toBe(coinsBefore - investmentEvt.cost); + expect(state.market.investments).toHaveLength(0); + }); + }); + + describe('refreshInvestments — success', () => { + it('should deduct cost, discard current investments, and refill the row', () => { + const state = createTestState(); + state.phase = 'MarketPhase'; + state.resourceBank.coins = REFRESH_INVESTMENTS_COST + 10; + + const invBefore = state.market.investments.map(c => c.id).slice(); + expect(invBefore.length).toBeGreaterThan(0); + const coinsBefore = state.resourceBank.coins; + + const result: RefreshResult = refreshInvestments(state); + + expect(result.cost).toBe(REFRESH_INVESTMENTS_COST); + expect(state.resourceBank.coins).toBe(coinsBefore - REFRESH_INVESTMENTS_COST); + // All previously visible cards should be discarded + const discardedIds = [ + ...state.discards.upgrade.map(c => c.id), + ...state.discards.event.map(c => c.id), + ]; + for (const id of result.replaced.map(c => c.id)) { + expect(discardedIds).toContain(id); + } + // Investments row refilled within slot limits + expect(state.market.investments.length).toBeLessThanOrEqual(MARKET_INVESTMENT_SLOTS); + // No duplicate IDs in the refreshed row + const refreshedIds = state.market.investments.map(c => c.id); + expect(new Set(refreshedIds).size).toBe(refreshedIds.length); + }); + }); +}); + +// ── Negative-Path: Invalid Row/Slot Selection ─────────────── + +describe('MarketOfferEngine — negative-path invalid row/slot', () => { + describe('purchaseBusiness — invalid slot', () => { + it('should throw when slot index equals GRID_SIZE', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = 100; + expect(() => purchaseBusiness(state, card.id, GRID_SIZE)).toThrow('Invalid slot'); + }); + + it('should throw when slot index is negative', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = 100; + expect(() => purchaseBusiness(state, card.id, -1)).toThrow('Invalid slot'); + }); + + it('should throw when slot is occupied', () => { + const state = createTestState(); + const card = state.market.business[0]; + state.resourceBank.coins = 100; + state.streetGrid[0] = state.decks.business[0]; + expect(() => purchaseBusiness(state, card.id, 0)).toThrow('occupied'); + }); + }); + + describe('purchaseUpgrade — invalid targeting', () => { + it('should throw when purchasing upgrade with insufficient coins', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + state.resourceBank.coins = upgrade.cost - 1; + + expect(() => purchaseUpgrade(state, upgrade.id)).toThrow('Not enough coins'); + }); + + it('should throw when targeting a specific slot with a non-matching business (but another valid target exists)', () => { + const state = createTestState(); + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + + // Place a non-matching business in slot 0 (the targeted slot) + const nonMatchingBiz = state.decks.business.find(b => b.name !== upgrade.targetBusiness); + if (!nonMatchingBiz) return; + state.streetGrid[0] = { ...nonMatchingBiz, level: upgrade.requiredLevel ?? 0 }; + + // Place a valid matching business elsewhere so canPurchaseUpgrade passes + const matchingBiz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!matchingBiz) return; + state.streetGrid[1] = { ...matchingBiz, level: upgrade.requiredLevel ?? 0 }; + + state.resourceBank.coins = 100; + + // Targeting slot 0 (non-matching) should throw + expect(() => purchaseUpgrade(state, upgrade.id, 0)).toThrow('not a valid target'); + }); + }); + + describe('purchaseEvent — insufficient coins', () => { + it('should throw when buying event with insufficient coins', () => { + const state = createTestState(); + const investmentEvent = state.market.investments.find( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard | undefined; + if (!investmentEvent) return; + + state.resourceBank.coins = investmentEvent.cost - 1; + expect(() => purchaseEvent(state, investmentEvent.id)).toThrow('Not enough coins'); + }); + }); + + describe('refreshInvestments — insufficient coins', () => { + it('should throw when refreshing with insufficient coins', () => { + const state = createTestState(); + state.phase = 'MarketPhase'; + state.resourceBank.coins = REFRESH_INVESTMENTS_COST - 1; + + expect(() => refreshInvestments(state)).toThrow('Not enough coins'); + }); + }); +}); + +// ── Refill Policy — Incident Queue ────────────────────────── + +describe('MarketOfferEngine — refill policy: incident queue', () => { + describe('refillIncidentQueue', () => { + it('should fill the incident queue to INCIDENT_QUEUE_SIZE when deck has enough incidents', () => { + const state = createTestState(); + state.incidentQueue = []; + + const availableIncidents = state.decks.event.filter(e => e.trigger === 'Incident').length; + if (availableIncidents >= INCIDENT_QUEUE_SIZE) { + refillIncidentQueue(state); + expect(state.incidentQueue.length).toBe(INCIDENT_QUEUE_SIZE); + } + }); + + it('should only draw Incident-trigger cards into the queue', () => { + const state = createTestState(); + state.incidentQueue = []; + refillIncidentQueue(state); + + for (const card of state.incidentQueue) { + expect(card.trigger).toBe('Incident'); + } + }); + + it('should not add duplicates to the incident queue', () => { + const state = createTestState(); + const beforeIds = state.incidentQueue.map(c => c.id); + refillIncidentQueue(state); + const afterIds = state.incidentQueue.map(c => c.id); + + // New cards should not duplicate existing queue cards + const newCards = afterIds.filter(id => !beforeIds.includes(id)); + const uniqueNewCards = new Set(newCards); + expect(uniqueNewCards.size).toBe(newCards.length); + }); + + it('should stop filling when no more Incident cards are available', () => { + const state = createTestState(); + // Remove all Incident cards from deck and discards + state.decks.event = state.decks.event.filter(e => e.trigger !== 'Incident'); + state.discards.event = state.discards.event.filter(e => e.trigger !== 'Incident'); + state.incidentQueue = []; + + refillIncidentQueue(state); + expect(state.incidentQueue.length).toBe(0); + }); + + it('should not remove Investment events from the event deck', () => { + const state = createTestState(); + const investmentCountBefore = state.decks.event.filter(e => e.trigger === 'Investment').length; + state.incidentQueue = []; + refillIncidentQueue(state); + const investmentCountAfter = state.decks.event.filter(e => e.trigger === 'Investment').length; + expect(investmentCountAfter).toBe(investmentCountBefore); + }); + }); +}); + +// ── Refill Policy — Deck Exhaustion Edge Cases ────────────── + +describe('MarketOfferEngine — refill policy: exhaustion edge cases', () => { + describe('refillInvestmentsMarket — dual exhaustion', () => { + it('should produce empty investments row when both upgrade and event decks are empty', () => { + const state = createTestState(); + state.market.investments = []; + state.decks.upgrade = []; + state.decks.event = []; + state.discards.upgrade = []; + state.discards.event = []; + + refillInvestmentsMarket(state); + expect(state.market.investments).toHaveLength(0); + }); + + it('should only fill upgrades when event deck has no Investment-trigger cards', () => { + const state = createTestState(); + state.market.investments = []; + state.decks.event = state.decks.event.filter(e => e.trigger !== 'Investment'); + // Ensure upgrade deck has cards + expect(state.decks.upgrade.length).toBeGreaterThanOrEqual(MARKET_INVESTMENT_UPGRADE_COUNT); + + refillInvestmentsMarket(state); + + const upgrades = state.market.investments.filter(c => c.family === 'upgrade'); + const events = state.market.investments.filter(c => c.family === 'event'); + expect(upgrades.length).toBe(MARKET_INVESTMENT_UPGRADE_COUNT); + expect(events.length).toBe(0); + }); + }); + + describe('refillBusinessMarket — complete exhaustion', () => { + it('should leave market partially empty when deck and discard are both empty', () => { + const state = createTestState(); + state.market.business = []; + state.decks.business = []; + state.discards.business = []; + + refillBusinessMarket(state); + expect(state.market.business).toHaveLength(0); + }); + }); + + describe('refillAllMarkets — idempotency', () => { + it('should not change a fully-refilled market when called again', () => { + const state = createTestState(); + const bizBefore = state.market.business.map(c => c.id).slice(); + const invBefore = state.market.investments.map(c => c.id).slice(); + + refillAllMarkets(state); + + expect(state.market.business.map(c => c.id)).toEqual(bizBefore); + expect(state.market.investments.map(c => c.id)).toEqual(invBefore); + }); + }); +}); + +// ── Refill Policy — Reshuffle from Discard ───────────────── + +describe('MarketOfferEngine — refill policy: reshuffle from discard', () => { + describe('reshuffleIfNeeded — business deck', () => { + it('should reshuffle business discards into deck when deck is empty and refill draws cards', () => { + const state = createTestState(); + // Move some business cards into the discard pile and empty the deck + const moved = state.decks.business.splice(0, 3); + state.discards.business.push(...moved); + state.decks.business.length = 0; + // Clear visible market so refill must draw from reshuffled deck + state.market.business = []; + refillBusinessMarket(state); + expect(state.market.business.length).toBeGreaterThan(0); + expect(state.discards.business.length).toBe(0); + }); + + it('should leave market empty when both business deck and discard are empty', () => { + const state = createTestState(); + state.decks.business = []; + state.discards.business = []; + state.market.business = []; + refillBusinessMarket(state); + expect(state.market.business).toHaveLength(0); + }); + }); + + describe('reshuffleIfNeeded — upgrade deck', () => { + it('should reshuffle upgrade discards into deck when upgrade deck is empty', () => { + const state = createTestState(); + const moved = state.decks.upgrade.splice(0, 2); + state.discards.upgrade.push(...moved); + state.decks.upgrade.length = 0; + state.market.investments = []; + refillInvestmentsMarket(state); + // Upgrades should have been drawn from the reshuffled discard + expect(state.discards.upgrade.length).toBe(0); + expect(state.market.investments.filter(c => c.family === 'upgrade').length).toBeGreaterThan(0); + }); + }); + + describe('reshuffleIfNeeded — event deck for incident queue', () => { + it('should reshuffle event discards into deck when filling incident queue', () => { + const state = createTestState(); + // Pull out some incident cards and put them into discards + const incidentCards = state.decks.event.filter(e => e.trigger === 'Incident').slice(0, 2); + // Empty the event deck and place incident cards into discards + state.decks.event = []; + state.discards.event.push(...incidentCards); + state.incidentQueue = []; + refillIncidentQueue(state); + expect(state.incidentQueue.length).toBeGreaterThan(0); + expect(state.discards.event.length).toBe(0); + }); + }); + + describe('reshuffleNeeded — event deck for investments market', () => { + it('should reshuffle event discards to find Investment events for the market row', () => { + const state = createTestState(); + // Move ALL event cards (including Investment events) from deck to discard + const allEvents = state.decks.event.slice(); + state.decks.event = []; + state.discards.event.push(...allEvents); + // Ensure we have Investment events in discards + const investInDiscard = state.discards.event.filter(e => e.trigger === 'Investment').length; + if (investInDiscard === 0) return; // Skip if no Investment events available + state.market.investments = []; + // Ensure upgrade deck has cards so we can test event reshuffle + expect(state.decks.upgrade.length).toBeGreaterThanOrEqual(MARKET_INVESTMENT_UPGRADE_COUNT); + refillInvestmentsMarket(state); + // Events should have been drawn from reshuffled discards + const events = state.market.investments.filter(c => c.family === 'event'); + expect(events.length).toBeGreaterThan(0); + }); + }); +}); + +// ── Integration: Multi-Turn Market Flow ───────────────────── + +describe('MarketOfferEngine — multi-turn market flow parity', () => { + function playGreedyTurn(state: MainStreetState): void { + executeDayStart(state); + const affordable = getAffordableBusinessCards(state); + affordable.sort((a, b) => a.cost - b.cost); + const empty = getEmptySlots(state); + if (affordable.length > 0 && empty.length > 0) { + const card = affordable[0]; + const slot = empty[0]; + executeAction(state, { type: 'buy-business', cardId: card.id, slotIndex: slot }); + } + processEndOfTurn(state); + } + + describe('purchase → end-turn → refill cycle', () => { + it('should refill the business market at DayStart after a purchase', () => { + const state = createTestState('flow-refill-1'); + state.resourceBank.coins = 100; + + // Day 1: buy a business + executeDayStart(state); + expect(state.market.business).toHaveLength(MARKET_BUSINESS_SLOTS); + const card = state.market.business[0]; + executeAction(state, { type: 'buy-business', cardId: card.id, slotIndex: 0 }); + // After purchase, market should have one fewer card (no immediate refill) + expect(state.market.business).toHaveLength(MARKET_BUSINESS_SLOTS - 1); + + // End turn → Day 2: market should be refilled + processEndOfTurn(state); + executeDayStart(state); + // Market should be refilled to capacity (or deck limit) + expect(state.market.business.length).toBeGreaterThanOrEqual(1); + expect(state.market.business.length).toBeLessThanOrEqual(MARKET_BUSINESS_SLOTS); + }); + + it('should refill the investments row at DayStart after purchasing an upgrade', () => { + const state = createTestState('flow-refill-2'); + state.resourceBank.coins = 100; + + // Place a target business + const upgrade = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgrade) return; + const biz = state.decks.business.find(b => b.name === upgrade.targetBusiness); + if (!biz) return; + state.streetGrid[0] = { ...biz, level: upgrade.requiredLevel ?? 0 }; + + // Day 1 + executeDayStart(state); + const invBefore = state.market.investments.length; + executeAction(state, { type: 'buy-upgrade', cardId: upgrade.id }); + // After purchase, investments row has one fewer card + expect(state.market.investments.length).toBe(invBefore - 1); + + // End turn → Day 2: investments should be refilled + processEndOfTurn(state); + executeDayStart(state); + const upgCount = state.market.investments.filter(c => c.family === 'upgrade').length; + const evtCount = state.market.investments.filter(c => c.family === 'event').length; + expect(upgCount).toBeGreaterThanOrEqual(0); + expect(evtCount).toBeGreaterThanOrEqual(0); + expect(state.market.investments.length).toBeLessThanOrEqual(MARKET_INVESTMENT_SLOTS); + }); + + it('should maintain no duplicate card IDs in the business market across 5 turns', () => { + const state = createTestState('flow-no-dupes'); + state.resourceBank.coins = 200; + + for (let turn = 0; turn < 5; turn++) { + if (state.gameResult !== 'playing') break; + playGreedyTurn(state); + + const ids = state.market.business.map(c => c.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size, `Turn ${turn + 1}: duplicate IDs found`).toBe(ids.length); + expect(state.market.business.length).toBeLessThanOrEqual(MARKET_BUSINESS_SLOTS); + } + }); + + it('should maintain no duplicate card IDs in the investments row across 5 turns', () => { + const state = createTestState('flow-no-dupes-inv'); + state.resourceBank.coins = 200; + + for (let turn = 0; turn < 5; turn++) { + if (state.gameResult !== 'playing') break; + playGreedyTurn(state); + + const ids = state.market.investments.map(c => c.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size, `Turn ${turn + 1}: duplicate IDs found`).toBe(ids.length); + expect(state.market.investments.length).toBeLessThanOrEqual(MARKET_INVESTMENT_SLOTS); + } + }); + + it('should maintain incident queue integrity across 5 turns', () => { + const state = createTestState('flow-queue-integrity'); + state.resourceBank.coins = 200; + + for (let turn = 0; turn < 5; turn++) { + if (state.gameResult !== 'playing') break; + playGreedyTurn(state); + + expect(state.incidentQueue.length).toBeLessThanOrEqual(INCIDENT_QUEUE_SIZE); + for (const card of state.incidentQueue) { + expect(card.trigger).toBe('Incident'); + } + // No duplicates in queue + const ids = state.incidentQueue.map(c => c.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + } + }); + + it('should produce deterministic market states with the same seed', () => { + const state1 = createTestState('flow-deterministic'); + const state2 = createTestState('flow-deterministic'); + state1.resourceBank.coins = 100; + state2.resourceBank.coins = 100; + + for (let turn = 0; turn < 3; turn++) { + playGreedyTurn(state1); + playGreedyTurn(state2); + + expect(state1.market.business.map(c => c.id)).toEqual( + state2.market.business.map(c => c.id), + ); + expect(state1.market.investments.map(c => c.id)).toEqual( + state2.market.investments.map(c => c.id), + ); + expect(state1.incidentQueue.map(c => c.id)).toEqual( + state2.incidentQueue.map(c => c.id), + ); + } + }); + }); +}); diff --git a/tests/main-street/transcript-recording.test.ts b/tests/main-street/transcript-recording.test.ts index 9c698298..d48dc773 100644 --- a/tests/main-street/transcript-recording.test.ts +++ b/tests/main-street/transcript-recording.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState'; import { executeDayStart } from '../../example-games/main-street/MainStreetEngine'; import { UndoRedoManager } from '../../src/core-engine/UndoRedoManager'; -import { BuyBusinessCommand } from '../../example-games/main-street/MainStreetCommands'; +import { buyBusinessCommand } from '../../example-games/main-street/MainStreetCommands'; import { MainStreetTranscriptRecorder, setMainStreetRecorder, @@ -34,7 +34,7 @@ describe('Main Street transcript recording (action, undo, redo)', () => { setMainStreetRecorder(recorder); const mgr = new UndoRedoManager(); - const cmd = new BuyBusinessCommand(state, cardId, slot); + const cmd = buyBusinessCommand(state, cardId, slot); // Execute command via manager (like scene would) mgr.execute(cmd); diff --git a/tests/rule-engine/EconomyLedger.test.ts b/tests/rule-engine/EconomyLedger.test.ts new file mode 100644 index 00000000..4b058824 --- /dev/null +++ b/tests/rule-engine/EconomyLedger.test.ts @@ -0,0 +1,843 @@ +/** + * Economy Ledger — Unit & Integration Tests + * + * Tests for the shared EconomyLedger module extracted into + * `src/rule-engine/EconomyLedger.ts`. These tests lock in the baseline + * economy/resource mutation semantics from Main Street so that future + * extractions preserve game behavior. + * + * Coverage: + * - `get` semantics for coins, reputation, score + * - `canApply` with and without constraints + * - `apply` semantics for resource deltas + * - Invariant checks (no illegal underflow guards, deterministic ordering) + * - Integration with Main Street turn/economy outcomes + * + * Work item: CG-0MPWZ5RFI001DJUA + */ +import { describe, it, expect } from 'vitest'; + +import { + createEconomyLedger, + type EconomyLedger, + type EconomyLedgerConfig, +} from '../../src/rule-engine/EconomyLedger'; + +import { + setupMainStreetGame, + type MainStreetState, +} from '../../example-games/main-street/MainStreetState'; + +import { + purchaseBusiness, + purchaseUpgrade, + purchaseEvent, +} from '../../example-games/main-street/MainStreetMarket'; + +import { + executeDayStart, + processEndOfTurn, + resolveEvent, + playHeldEvent, + computeScore, +} from '../../example-games/main-street/MainStreetEngine'; + +import { + applyIncome, +} from '../../example-games/main-street/MainStreetAdjacency'; + +import { + applyReputationMultiplier, +} from '../../example-games/main-street/MainStreetDifficulty'; + +import type { EventCard, UpgradeCard } from '../../example-games/main-street/MainStreetCards'; + +// ── Helpers ───────────────────────────────────────────────── + +function createLedger(config: EconomyLedgerConfig = {}): EconomyLedger { + return createEconomyLedger(config); +} + +// ── Unit tests: get ───────────────────────────────────────── + +describe('EconomyLedger — get', () => { + it('returns default 0 for all resources when no config provided', () => { + const ledger = createLedger(); + expect(ledger.get('coins')).toBe(0); + expect(ledger.get('reputation')).toBe(0); + expect(ledger.get('score')).toBe(0); + }); + + it('returns configured initial values', () => { + const ledger = createLedger({ coins: 10, reputation: 5, score: 50 }); + expect(ledger.get('coins')).toBe(10); + expect(ledger.get('reputation')).toBe(5); + expect(ledger.get('score')).toBe(50); + }); + + it('returns individual resources after mutations', () => { + const ledger = createLedger({ coins: 10 }); + ledger.apply({ coins: -3, reputation: 2 }); + expect(ledger.get('coins')).toBe(7); + expect(ledger.get('reputation')).toBe(2); + expect(ledger.get('score')).toBe(0); + }); +}); + +// ── Unit tests: snapshot ──────────────────────────────────── + +describe('EconomyLedger — snapshot', () => { + it('returns a copy of current resource values', () => { + const ledger = createLedger({ coins: 8, reputation: 3, score: 0 }); + const snap = ledger.snapshot(); + expect(snap).toEqual({ coins: 8, reputation: 3, score: 0 }); + }); + + it('snapshot is independent — mutations after snapshot do not affect it', () => { + const ledger = createLedger({ coins: 10 }); + const snap = ledger.snapshot(); + ledger.apply({ coins: -5 }); + expect(snap.coins).toBe(10); + expect(ledger.get('coins')).toBe(5); + }); + + it('snapshot reflects latest state', () => { + const ledger = createLedger({ coins: 10, reputation: 3 }); + ledger.apply({ coins: -5, reputation: -1 }); + const snap = ledger.snapshot(); + expect(snap).toEqual({ coins: 5, reputation: 2, score: 0 }); + }); +}); + +// ── Unit tests: canApply (no constraints — Main Street baseline) ── + +describe('EconomyLedger — canApply (unconstrained / Main Street baseline)', () => { + it('always returns true when no constraints are set', () => { + const ledger = createLedger({ coins: 5 }); + // Even a delta that would make coins negative is allowed + expect(ledger.canApply({ coins: -10 })).toBe(true); + expect(ledger.canApply({ coins: -1000 })).toBe(true); + }); + + it('returns true for zero deltas', () => { + const ledger = createLedger({ coins: 5, reputation: 3 }); + expect(ledger.canApply({})).toBe(true); + expect(ledger.canApply({ coins: 0 })).toBe(true); + expect(ledger.canApply({ reputation: 0 })).toBe(true); + }); + + it('returns true for positive deltas', () => { + const ledger = createLedger({ coins: 5 }); + expect(ledger.canApply({ coins: 100 })).toBe(true); + expect(ledger.canApply({ coins: 100, reputation: 50 })).toBe(true); + }); + + it('returns true for reputation going negative', () => { + const ledger = createLedger({ reputation: 3 }); + expect(ledger.canApply({ reputation: -10 })).toBe(true); + }); +}); + +// ── Unit tests: canApply (with constraints) ──────────────── + +describe('EconomyLedger — canApply (with constraints)', () => { + it('rejects coin delta that would go below minCoins', () => { + const ledger = createLedger({ + coins: 5, + constraints: { minCoins: 0 }, + }); + expect(ledger.canApply({ coins: -5 })).toBe(true); // exactly at floor + expect(ledger.canApply({ coins: -6 })).toBe(false); // below floor + }); + + it('rejects reputation delta that would go below minReputation', () => { + const ledger = createLedger({ + reputation: 3, + constraints: { minReputation: 0 }, + }); + expect(ledger.canApply({ reputation: -3 })).toBe(true); + expect(ledger.canApply({ reputation: -4 })).toBe(false); + }); + + it('allows delta when one resource is constrained and another is not', () => { + const ledger = createLedger({ + coins: 5, + reputation: 3, + constraints: { minCoins: 0 }, + }); + // Coins constrained, reputation not + expect(ledger.canApply({ coins: -6, reputation: -100 })).toBe(false); + expect(ledger.canApply({ coins: -3, reputation: -100 })).toBe(true); + }); + + it('applies constraints after partial delta application (additive check)', () => { + const ledger = createLedger({ + coins: 5, + constraints: { minCoins: 0 }, + }); + // Multiple fields: coins passes, rep has no constraint + expect(ledger.canApply({ coins: -3, reputation: -10 })).toBe(true); + }); +}); + +// ── Unit tests: apply ────────────────────────────────────── + +describe('EconomyLedger — apply', () => { + it('adds positive coin deltas', () => { + const ledger = createLedger({ coins: 10 }); + ledger.apply({ coins: 5 }); + expect(ledger.get('coins')).toBe(15); + }); + + it('subtracts negative coin deltas', () => { + const ledger = createLedger({ coins: 10 }); + ledger.apply({ coins: -7 }); + expect(ledger.get('coins')).toBe(3); + }); + + it('allows coins to go negative (bankruptcy checked downstream)', () => { + const ledger = createLedger({ coins: 3 }); + ledger.apply({ coins: -10 }); + expect(ledger.get('coins')).toBe(-7); + }); + + it('adds reputation deltas (positive and negative)', () => { + const ledger = createLedger({ reputation: 5 }); + ledger.apply({ reputation: 3 }); + expect(ledger.get('reputation')).toBe(8); + ledger.apply({ reputation: -4 }); + expect(ledger.get('reputation')).toBe(4); + }); + + it('allows reputation to go negative', () => { + const ledger = createLedger({ reputation: 2 }); + ledger.apply({ reputation: -5 }); + expect(ledger.get('reputation')).toBe(-3); + }); + + it('adds score deltas', () => { + const ledger = createLedger({ score: 50 }); + ledger.apply({ score: 10 }); + expect(ledger.get('score')).toBe(60); + ledger.apply({ score: -5 }); + expect(ledger.get('score')).toBe(55); + }); + + it('applies multiple resources in a single call', () => { + const ledger = createLedger({ coins: 10, reputation: 5, score: 0 }); + ledger.apply({ coins: -3, reputation: 2, score: 10 }); + expect(ledger.get('coins')).toBe(7); + expect(ledger.get('reputation')).toBe(7); + expect(ledger.get('score')).toBe(10); + }); + + it('only mutates specified resources (unspecified fields unchanged)', () => { + const ledger = createLedger({ coins: 10, reputation: 5, score: 0 }); + ledger.apply({ coins: -3 }); + expect(ledger.get('coins')).toBe(7); + expect(ledger.get('reputation')).toBe(5); + expect(ledger.get('score')).toBe(0); + }); + + it('accepts an empty delta (no-op)', () => { + const ledger = createLedger({ coins: 10 }); + ledger.apply({}); + expect(ledger.get('coins')).toBe(10); + }); + + it('accepts a reason string (not stored, for logging)', () => { + const ledger = createLedger({ coins: 10 }); + // Should not throw + ledger.apply({ coins: -3 }, 'purchase-business'); + expect(ledger.get('coins')).toBe(7); + }); +}); + +// ── Unit tests: setScore ─────────────────────────────────── + +describe('EconomyLedger — setScore', () => { + it('sets score to an absolute value', () => { + const ledger = createLedger({ score: 10 }); + ledger.setScore(50); + expect(ledger.get('score')).toBe(50); + }); + + it('allows setting score to 0', () => { + const ledger = createLedger({ score: 50 }); + ledger.setScore(0); + expect(ledger.get('score')).toBe(0); + }); + + it('allows setting score to negative', () => { + const ledger = createLedger({ score: 10 }); + ledger.setScore(-5); + expect(ledger.get('score')).toBe(-5); + }); +}); + +// ── Invariant tests ───────────────────────────────────────── + +describe('EconomyLedger — invariants', () => { + describe('No illegal underflow guards (Main Street baseline)', () => { + it('coins can go below zero — bankruptcy is an engine-level check', () => { + const ledger = createLedger({ coins: 3 }); + // In Main Street, coins can go negative; bankruptcy is checked + // separately in checkImmediateLoss() + ledger.apply({ coins: -10 }); + expect(ledger.get('coins')).toBeLessThan(0); + }); + + it('reputation can go below zero — collapse is an engine-level check', () => { + const ledger = createLedger({ reputation: 1 }); + // In Main Street, reputation can go negative; collapse is checked + // separately in checkImmediateLoss() + ledger.apply({ reputation: -5 }); + expect(ledger.get('reputation')).toBeLessThan(0); + }); + }); + + describe('Deterministic delta application ordering', () => { + it('multiple apply calls are order-dependent (sequential, not commutative)', () => { + // When a canApply check is involved, order matters: + // apply(-5), then apply(-5) → -10 + // vs. apply(-10) directly → -10 (same result for unconstrained) + const ledger1 = createLedger({ coins: 8 }); + ledger1.apply({ coins: -5 }); + ledger1.apply({ coins: -5 }); + + const ledger2 = createLedger({ coins: 8 }); + ledger2.apply({ coins: -10 }); + + expect(ledger1.get('coins')).toBe(ledger2.get('coins')); + }); + + it('snapshot captures state at a point in time for reproducible checks', () => { + const ledger = createLedger({ coins: 10, reputation: 3 }); + const before = ledger.snapshot(); + + ledger.apply({ coins: -5, reputation: 2 }); + const after = ledger.snapshot(); + + // Delta can be computed from snapshots + expect(after.coins - before.coins).toBe(-5); + expect(after.reputation - before.reputation).toBe(2); + }); + }); + + describe('Additive semantics', () => { + it('apply is purely additive — no multiplicative or clamping behavior', () => { + const ledger = createLedger({ coins: 100 }); + ledger.apply({ coins: 50 }); + ledger.apply({ coins: -30 }); + ledger.apply({ coins: -30 }); + // 100 + 50 - 30 - 30 = 90 + expect(ledger.get('coins')).toBe(90); + }); + + it('zero delta is a true no-op', () => { + const ledger = createLedger({ coins: 7, reputation: 3, score: 20 }); + const before = ledger.snapshot(); + ledger.apply({ coins: 0, reputation: 0, score: 0 }); + expect(ledger.snapshot()).toEqual(before); + }); + }); + + describe('Independence of resources', () => { + it('mutating coins does not affect reputation or score', () => { + const ledger = createLedger({ coins: 10, reputation: 5, score: 50 }); + ledger.apply({ coins: -100 }); + expect(ledger.get('reputation')).toBe(5); + expect(ledger.get('score')).toBe(50); + }); + + it('mutating reputation does not affect coins or score', () => { + const ledger = createLedger({ coins: 10, reputation: 5, score: 50 }); + ledger.apply({ reputation: -100 }); + expect(ledger.get('coins')).toBe(10); + expect(ledger.get('score')).toBe(50); + }); + }); +}); + +// ── Integration tests: Main Street economy parity ─────────── + +describe('EconomyLedger — Main Street integration parity', () => { + /** + * Helper: build a ledger from a MainStreetState's resource bank. + * This captures the baseline state that the ledger must reproduce. + */ + function ledgerFromState(state: MainStreetState): EconomyLedger { + return createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + } + + /** + * Helper: apply the same delta that MainStreetEngine/Market would apply, + * and verify the ledger matches the state's resource bank. + */ + function verifyParity( + state: MainStreetState, + ledger: EconomyLedger, + delta: { coins: number; reputation: number }, + ): void { + ledger.apply({ coins: delta.coins, reputation: delta.reputation }); + expect(ledger.get('coins')).toBe(state.resourceBank.coins); + expect(ledger.get('reputation')).toBe(state.resourceBank.reputation); + } + + describe('Purchase parity', () => { + it('purchaseBusiness: ledger matches state after business purchase', () => { + const state = setupMainStreetGame({ seed: 'ledger-purchase-biz' }); + const ledger = ledgerFromState(state); + + const coinsBefore = state.resourceBank.coins; + const businessCard = state.market.business[0]; + purchaseBusiness(state, businessCard.id, 0); + + const expectedDelta = state.resourceBank.coins - coinsBefore; + verifyParity(state, ledger, { coins: expectedDelta, reputation: 0 }); + }); + + it('purchaseUpgrade: ledger matches state after upgrade purchase', () => { + const state = setupMainStreetGame({ seed: 'ledger-purchase-upgrade' }); + + // Place a matching business for the first upgrade in market + const upgradeCard = state.market.investments.find( + c => c.family === 'upgrade', + ) as UpgradeCard | undefined; + if (!upgradeCard) { + return; // skip if no upgrade available + } + + const matchingBiz = state.decks.business.find(b => b.name === upgradeCard.targetBusiness); + if (!matchingBiz) { + return; // skip if no matching business + } + state.streetGrid[0] = { ...matchingBiz, level: upgradeCard.requiredLevel ?? 0 }; + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + + purchaseUpgrade(state, upgradeCard.id); + + const expectedDelta = state.resourceBank.coins - coinsBefore; + verifyParity(state, ledger, { coins: expectedDelta, reputation: 0 }); + }); + + it('purchaseEvent: ledger matches state after event purchase', () => { + const state = setupMainStreetGame({ seed: 'ledger-purchase-event' }); + + const eventCard = state.market.investments.find( + c => c.family === 'event' && (c as EventCard).trigger === 'Investment', + ) as EventCard | undefined; + if (!eventCard) { + return; // skip if no investment event available + } + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + + purchaseEvent(state, eventCard.id); + + const expectedDelta = state.resourceBank.coins - coinsBefore; + verifyParity(state, ledger, { coins: expectedDelta, reputation: 0 }); + }); + }); + + describe('Income parity', () => { + it('applyIncome: ledger matches state after income with reputation multiplier', () => { + const state = setupMainStreetGame({ seed: 'ledger-income' }); + + // Place a single business for predictable income + const biz = state.decks.business[0]; + state.streetGrid.fill(null); + state.streetGrid[0] = { ...biz }; + + const ledger = ledgerFromState(state); + + const incomeResult = applyIncome(state); + const multipliedIncome = applyReputationMultiplier( + incomeResult.total, + state.resourceBank.reputation, + state.config, + ); + + verifyParity(state, ledger, { coins: multipliedIncome, reputation: 0 }); + }); + + it('income at reputation=0: no scaling, ledger matches', () => { + const state = setupMainStreetGame({ seed: 'ledger-income-zero-rep' }); + state.resourceBank.reputation = 0; + + const biz = state.decks.business[0]; + state.streetGrid.fill(null); + state.streetGrid[0] = { ...biz, baseIncome: 10, synergyTypes: [] }; + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + + applyIncome(state); + + const expectedDelta = state.resourceBank.coins - coinsBefore; + verifyParity(state, ledger, { coins: expectedDelta, reputation: 0 }); + }); + }); + + describe('Event resolution parity', () => { + it('resolveEvent (All, positive): ledger matches state', () => { + const state = setupMainStreetGame({ seed: 'ledger-event-positive' }); + state.resourceBank.reputation = 10; // give some reputation for multiplier + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + const event: EventCard = { + family: 'event', + id: 'evt-test-positive', + name: 'Test Festival', + trigger: 'Incident', + effect: '+5 coins', + target: 'All', + coinDelta: 5, + reputationDelta: 2, + cost: 0, + }; + + resolveEvent(state, event); + + verifyParity(state, ledger, { + coins: state.resourceBank.coins - coinsBefore, + reputation: state.resourceBank.reputation - repBefore, + }); + }); + + it('resolveEvent (All, negative penalty): ledger matches state', () => { + const state = setupMainStreetGame({ seed: 'ledger-event-negative' }); + state.resourceBank.reputation = 20; // high rep should NOT scale penalties + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + const event: EventCard = { + family: 'event', + id: 'evt-test-negative', + name: 'Test Robbery', + trigger: 'Incident', + effect: '-3 coins', + target: 'All', + coinDelta: -3, + reputationDelta: -1, + cost: 0, + }; + + resolveEvent(state, event); + + verifyParity(state, ledger, { + coins: state.resourceBank.coins - coinsBefore, + reputation: state.resourceBank.reputation - repBefore, + }); + }); + + it('playHeldEvent: ledger matches state after playing held investment', () => { + const state = setupMainStreetGame({ seed: 'ledger-play-held' }); + state.resourceBank.reputation = 5; + + // Give the player a held event + const event: EventCard = { + family: 'event', + id: 'evt-held-test', + name: 'Held Investment', + trigger: 'Investment', + effect: '+3 coins, +1 rep', + target: 'All', + coinDelta: 3, + reputationDelta: 1, + cost: 0, + }; + state.heldEvent = event; + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + playHeldEvent(state); + + verifyParity(state, ledger, { + coins: state.resourceBank.coins - coinsBefore, + reputation: state.resourceBank.reputation - repBefore, + }); + }); + }); + + describe('Full turn parity', () => { + it('executeDayStart + processEndOfTurn: ledger matches full turn economy', () => { + const state = setupMainStreetGame({ seed: 'ledger-full-turn' }); + + // Place a business for income + const biz = state.decks.business[0]; + state.streetGrid.fill(null); + state.streetGrid[0] = { ...biz }; + + const ledger = ledgerFromState(state); + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + executeDayStart(state); + processEndOfTurn(state); + + verifyParity(state, ledger, { + coins: state.resourceBank.coins - coinsBefore, + reputation: state.resourceBank.reputation - repBefore, + }); + }); + + it('multi-turn economy parity: 3 consecutive turns', () => { + const state = setupMainStreetGame({ seed: 'ledger-multi-turn' }); + + // Place businesses for income + state.streetGrid.fill(null); + state.streetGrid[0] = { ...state.decks.business[0] }; + state.streetGrid[1] = { ...state.decks.business[1] }; + + const ledger = ledgerFromState(state); + + for (let turn = 0; turn < 3; turn++) { + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + executeDayStart(state); + processEndOfTurn(state); + + ledger.apply({ + coins: state.resourceBank.coins - coinsBefore, + reputation: state.resourceBank.reputation - repBefore, + }); + + expect(ledger.get('coins')).toBe(state.resourceBank.coins); + expect(ledger.get('reputation')).toBe(state.resourceBank.reputation); + } + }); + }); + + describe('Score computation parity', () => { + it('ledger score matches Main Street computeScore formula', () => { + const state = setupMainStreetGame({ seed: 'ledger-score' }); + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + + // Main Street score: coins + (reputation * reputationScoreMultiplier) + (challengesCompleted * challengeBonusPoints) + const expectedScore = + state.resourceBank.coins + + state.resourceBank.reputation * state.config.reputationScoreMultiplier + + state.challengesCompleted.length * state.config.challengeBonusPoints; + + ledger.setScore(expectedScore); + expect(ledger.get('score')).toBe(expectedScore); + }); + + it('score updates correctly after resource changes', () => { + const state = setupMainStreetGame({ seed: 'ledger-score-update' }); + + // Place a business and run a turn + state.streetGrid.fill(null); + state.streetGrid[0] = { ...state.decks.business[0] }; + executeDayStart(state); + processEndOfTurn(state); + + // Compute score the Main Street way + const expectedScore = + state.resourceBank.coins + + state.resourceBank.reputation * state.config.reputationScoreMultiplier; + + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + ledger.setScore(expectedScore); + + expect(ledger.get('score')).toBe(expectedScore); + }); + + it('ledger.setScore matches actual computeScore() function output', () => { + const state = setupMainStreetGame({ seed: 'ledger-computeScore-direct' }); + + // Place businesses and run a few turns to get interesting state + state.streetGrid.fill(null); + state.streetGrid[0] = { ...state.decks.business[0] }; + state.streetGrid[1] = { ...state.decks.business[1] }; + executeDayStart(state); + processEndOfTurn(state); + executeDayStart(state); + processEndOfTurn(state); + + // Call the actual computeScore function + const actualScore = computeScore(state); + + // Set ledger score to the same value and verify + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + score: 0, + }); + ledger.setScore(actualScore); + + expect(ledger.get('score')).toBe(actualScore); + expect(ledger.get('score')).toBe(state.resourceBank.coins + state.resourceBank.reputation * state.config.reputationScoreMultiplier + state.challengesCompleted.length * state.config.challengeBonusPoints); + }); + }); + + describe('Negative economy integration (bankruptcy / reputation collapse scenarios)', () => { + it('coins driven negative via event resolution — ledger tracks bankruptcy state', () => { + const state = setupMainStreetGame({ seed: 'ledger-bankruptcy' }); + state.resourceBank.coins = 2; // low coins so a big penalty drives them negative + state.resourceBank.reputation = 5; + + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + const coinsBefore = state.resourceBank.coins; + + // Resolve a large negative event + const event: EventCard = { + family: 'event', + id: 'evt-bankruptcy', + name: 'Market Crash', + trigger: 'Incident', + effect: '-20 coins', + target: 'All', + coinDelta: -20, + reputationDelta: 0, + cost: 0, + }; + + resolveEvent(state, event); + + const coinsDelta = state.resourceBank.coins - coinsBefore; + ledger.apply({ coins: coinsDelta, reputation: 0 }); + + // Verify ledger matches state (coins now negative) + expect(ledger.get('coins')).toBe(state.resourceBank.coins); + expect(ledger.get('coins')).toBeLessThan(0); + // Ledger allows negative (bankruptcy is engine-level check) + expect(ledger.canApply({ coins: -1 })).toBe(true); + }); + + it('reputation driven negative via event resolution — ledger tracks collapse state', () => { + const state = setupMainStreetGame({ seed: 'ledger-rep-collapse' }); + state.resourceBank.coins = 10; + state.resourceBank.reputation = 2; // low reputation + state.turn = 3; // past turn 1 so collapse would trigger + + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + const repBefore = state.resourceBank.reputation; + + // Resolve a large negative reputation event + const event: EventCard = { + family: 'event', + id: 'evt-rep-collapse', + name: 'Public Scandal', + trigger: 'Incident', + effect: '-10 reputation', + target: 'All', + coinDelta: 0, + reputationDelta: -10, + cost: 0, + }; + + resolveEvent(state, event); + + const repDelta = state.resourceBank.reputation - repBefore; + ledger.apply({ coins: 0, reputation: repDelta }); + + // Verify ledger matches state (reputation now negative) + expect(ledger.get('reputation')).toBe(state.resourceBank.reputation); + expect(ledger.get('reputation')).toBeLessThan(0); + // Ledger allows negative (collapse is engine-level check) + expect(ledger.canApply({ reputation: -1 })).toBe(true); + }); + + it('both coins and reputation negative simultaneously via event resolution', () => { + const state = setupMainStreetGame({ seed: 'ledger-double-negative' }); + state.resourceBank.coins = 3; + state.resourceBank.reputation = 2; + state.turn = 3; + + const ledger = createLedger({ + coins: state.resourceBank.coins, + reputation: state.resourceBank.reputation, + }); + const coinsBefore = state.resourceBank.coins; + const repBefore = state.resourceBank.reputation; + + // Resolve an event that hits both resources hard + const event: EventCard = { + family: 'event', + id: 'evt-double-hit', + name: 'Catastrophic Failure', + trigger: 'Incident', + effect: '-15 coins, -8 reputation', + target: 'All', + coinDelta: -15, + reputationDelta: -8, + cost: 0, + }; + + resolveEvent(state, event); + + const coinsDelta = state.resourceBank.coins - coinsBefore; + const repDelta = state.resourceBank.reputation - repBefore; + ledger.apply({ coins: coinsDelta, reputation: repDelta }); + + // Verify both resources are negative and match state + expect(ledger.get('coins')).toBe(state.resourceBank.coins); + expect(ledger.get('reputation')).toBe(state.resourceBank.reputation); + expect(ledger.get('coins')).toBeLessThan(0); + expect(ledger.get('reputation')).toBeLessThan(0); + // Ledger permits both negative simultaneously + expect(ledger.canApply({ coins: -1, reputation: -1 })).toBe(true); + }); + }); +}); + +// ── Test Matrix Summary ───────────────────────────────────── +// +// The following table maps test cases to ledger behaviors: +// +// | Test Group | Behavior Tested | AC Coverage | +// |----------------------------|------------------------------------------|-------------| +// | get — defaults | get returns 0 for unconfigured resources | AC-1 | +// | get — configured | get returns initial values | AC-1 | +// | get — after mutations | get reflects applied deltas | AC-1 | +// | snapshot — copy | snapshot returns independent copy | AC-1 | +// | snapshot — independence | mutations after snapshot don't affect it | AC-2 | +// | canApply — unconstrained | always true (Main St baseline) | AC-1, AC-2 | +// | canApply — with constraints| minCoins/minReputation guards | AC-1 | +// | apply — positive/negative | additive delta application | AC-1 | +// | apply — negative allowed | coins/rep can go below zero | AC-2 | +// | apply — multiple resources | combined delta in single call | AC-1 | +// | apply — selective | only specified fields change | AC-1 | +// | setScore — absolute | score set to explicit value | AC-1 | +// | Invariant — no underflow | no guards prevent negative balance | AC-2 | +// | Invariant — deterministic | sequential apply is reproducible | AC-2 | +// | Invariant — additive | purely additive, no clamping | AC-2 | +// | Invariant — independence | resources don't affect each other | AC-2 | +// | Integration — purchase | business/upgrade/event purchase parity | AC-3 | +// | Integration — income | income with rep multiplier parity | AC-3 | +// | Integration — events | event resolution (pos/neg) parity | AC-3 | +// | Integration — full turn | full turn cycle parity | AC-3 | +// | Integration — multi-turn | 3 consecutive turns parity | AC-3 | +// | Integration — score | score formula parity | AC-3 | +// | Integration — computeScore | direct computeScore() equivalence | AC-3 | +// | Integration — bankruptcy | coins driven negative via events | AC-2, AC-3 | +// | Integration — rep collapse | reputation driven negative via events | AC-2, AC-3 | +// | Integration — double neg | coins + rep negative simultaneously | AC-2, AC-3 |