diff --git a/.worklog/config.yaml b/.worklog/config.yaml deleted file mode 100644 index b5d45d6e..00000000 --- a/.worklog/config.yaml +++ /dev/null @@ -1,4 +0,0 @@ -projectName: Tableau Card Engine -prefix: CG -autoExport: true -autoSync: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..90b48311 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# Changelog + +## v0.1.4 (2026-07-10) +### Features +- Migrate Gym scenes to Screen Layout Language (SLL) (CG-0MPF8WT920076RQ5) +- Layout ownership runtime (CG-0MPFM2MJF0000TIF) +- Help text in Gym scenes (CG-0MPXUDVRE006Y1Y6) +- Shared Game over screen (CG-0MQJZUOPJ001USHB) +- Generalize synergy bonus system to support per-card custom values (CG-0MQRA9QTA0012PNZ) +- Income rounding error: fractional baseIncome lost via Math.floor in applyReputationMultiplier (CG-0MRER3RE300418SG) +- Main Street income label: add Income: prefix, center on card, show in market (CG-0MRCQL9KO007SUUJ) +- More intuitive chopsticks UX (CG-0MQK03TE4005FP3A) +- Main Street upcoming cards stretched — fix image aspect ratio (CG-0MRCR08VB001N7EA) +- CSV Auto-Balancing Tool and Card Cost/Reward Spread Pass (CG-0MRDE9EYB0013E20) +- CLI framework with CSV I/O and backup management (CG-0MRDM4YGI002SK1E) +- Npm script, guardrail updates, and integration (CG-0MRDM5BJQ009YL8N) +- Event family balancing (CG-0MRDM5BJS006XU7X) +- Upgrade and staff family balancing (CG-0MRDM5BJF004LUNC) +- Business and Community Space family balancing (CG-0MRDM4YGO005X1H9) +- Animate card reordering when sorting hand in Gym scene (CG-0MQFEXIIH009AUZF) +- Feature: HandView animateAddCard + Gym migration (CG-0MQ6HR1H0000SA16) +### Bug Fixes +- Fix discard pile showing oversized empty card-back (CG-0MR9WK4BO003GTEM) +- Fix findTextObject to search inside Phaser Containers (CG-0MRE3ZKEM003BUHJ) +- Fix syncAllVisibility no-op in GymLayoutOwnershipScene (CG-0MRE3ZKE9000EAIZ) +- Fix toggle label format inconsistency in GymLayoutOwnershipScene (CG-0MRE3ZKF10074GER) +- Fix removeUngrouped to only target ungrouped items (CG-0MRE3ZKFW002S3ND) +### Other +- Migrate lost-cities SVG usage to core-engine SvgHelpers (CG-0MPAZX4KL002MXNX) +- Migrate one Gym scene to SLL as proof-of-concept (CG-0MQQHESGK001BN55) +- Migrate remaining Gym scenes to SLL (CG-0MQQHETFH004ARLQ) +- Verify visual and functional parity after SLL migration (CG-0MQQHEUA4009S5ZG) +- Browser regression tests for shared visibility helper (CG-0MPGVOA4H004E2WX) +- Migrate existing games to use shared GameOverOverlay (CG-0MQQHETKK000K7YP) +- Create GameOverOverlay component in core engine UI (CG-0MQQHESJ000187PR) +- Tests: The Mind reduced motion support (CG-0MQLEMSVG001DU86) +- Tests: Feudalism reduced motion support (CG-0MQLEMSW900437KM) +- Tests: Lost Cities reduced motion support (CG-0MQLEMSWC00530R1) +- Tests: Beleaguered Castle reduced motion support (CG-0MQLEMSWE001EUZ5) +- Lost Cities: reduced motion support (CG-0MQLESCC3005I9IP) +- The Mind: reduced motion support (CG-0MQLESCC5009PLXF) +- Beleaguered Castle: reduced motion support (CG-0MQLESCCS004J9V4) +- Feudalism: reduced motion support (CG-0MQLESCE4009A494) +- Release Readiness Audit: Gym scenes and Games (CG-0MRCNVTS8008FTGF) +- Release Readiness Audit: Gym — Layout Ownership (NEW) (CG-0MRCQBB1M004T63U) +- Release Readiness Audit: Main Street (CG-0MRCQCWTO001FA3J) +- Release Readiness Audit: The Mind (CG-0MRCQCWLR007VLZ9) +- Clean up orphaned Mind fixture and comment references (CG-0MRDVOH8H007QQDL) +- Release Readiness Audit: Gym — Overlay & UI Config (CG-0MRCQAXSP0047NVU) +- Release Readiness Audit: Gym — Transcript Recording (CG-0MRCQAXUW009TWNU) +- Release Readiness Audit: Gym — Undo / Redo (CG-0MRCQAXYO000IYNZ) +- Release Readiness Audit: Gym — Save / Load State (CG-0MRCQAY00006NXIX) +- Release Readiness Audit: Gym — Audio & Feedback Config (CG-0MRCQBB0N007XRPH) +- Release Readiness Audit: Gym — Screen Layout Language (SLL) (CG-0MRCQBB1Z0047JP0) +- Release Readiness Audit: Gym — Shader & Blend Spike (CG-0MRCQBB33005RPV4) +- Release Readiness Audit: Gym — Tooltip Manager (NEW) (CG-0MRCQBB4Z0003RQ1) +- Release Readiness Audit: Gym — Lighting Spike (CG-0MRCQBB80001A175) +- Release Readiness Audit: Gym — HUD Components (NEW) (CG-0MRCQBB82007PFP5) +- Release Readiness Audit: Gym (demo suite) (CG-0MRCQCWKW002U22T) +- Release Readiness Audit: Beleaguered Castle (CG-0MRCQCWLU0003BDE) +- Release Readiness Audit: Sushi Go! (CG-0MRCQCWMM003RUTQ) +- Release Readiness Audit: Lost Cities (CG-0MRCQCWMT006O0LV) +- Release Readiness Audit: Feudalism (CG-0MRCQCWNM000QYY5) +- Release Readiness Audit: Golf (CG-0MRCQCWQK003WMHE) +- Release Readiness Audit: Gym — Deck & Seeded RNG (CG-0MRCQAXSP006VUNL) +- Core balancing algorithm unit tests (CG-0MRDM4F9F008A22D) +- Upgrade and staff balancing algorithm tests (CG-0MRDM4NRM0051KD5) +- Event balancing and Monte Carlo validation tests (CG-0MRDM4NRV0066Z9P) +- Balancing methodology document and migration (CG-0MRDM5BJ3004AE1W) +- Move tier definitions into the CSV (CG-0MR91VLA6009V1XG) +- Round all coin displays to 3 decimal places in the UI (CG-0MREYZO7E00729S0) +- Fix synergy calculations for adjacent businesses (CG-0MQQHBYA50060YPX) +- Add visual synergy lines between adjacent businesses (CG-0MQQHBZ3D002J02E) +- Test: GymHandPileScene animation integration tests (CG-0MQ6HQRSE006A9OJ) +- Task: Documentation updates (CG-0MQ6HR1H0008RW3O) +- Remove The Mind completely (CG-0MQK1N2XS000OSOI) + diff --git a/docs/main-street/balancing-methodology.md b/docs/main-street/balancing-methodology.md new file mode 100644 index 00000000..5454bb84 --- /dev/null +++ b/docs/main-street/balancing-methodology.md @@ -0,0 +1,128 @@ +# Main Street Balancing Methodology + +## Overview + +This document describes the automated balancing methodology used by the `run-balance-cards` CLI tool to perform a data-driven balancing pass on the Main Street card data CSV. The tool implements a **hybrid approach** combining curve-fitting and tier-band analysis. + +## Algorithm Approach + +The tool uses two complementary techniques: + +### 1. Curve-Fitting + +Each card family has a family-specific expected cost function that models cost as a function of card stats: + +| Family | Input Variables | Formula | +|--------|----------------|---------| +| **Business / Community Space** | baseIncome, synergyTypes, synergyCoinBonus, synergyRepBonus, reputationPerTurn, incomeBonus, tier | `tier * 2 + 2 + baseIncome * 4 + synergyCount * 3 + bonuses` | +| **Investment Events** | coinDelta, reputationDelta, targetSynergy, tier | `tier * 1.5 + 0.5 + coinDelta * 1.5 + repDelta * 2` | +| **Upgrades** | incomeBonus, synergyRangeBonus, requiredLevel, reputationBonus, tier | `tier * 1.5 + 1 + incomeBonus * 3 + synergyRange * 3 + repBonus * 10` | +| **Staff** | ongoingCost, handSlotsAdded | `ongoingCost * 5 + handSlotsAdded * 5` | + +### 2. Tier Band Analysis + +Expected costs are mapped to cost bands using percentile-based assignment: + +| Band | Percentile | Cost Range | Label | +|------|-----------|------------|-------| +| **Budget** | 0–20% | 0–3 | Basic, affordable cards | +| **Economy** | 20–40% | 4–5 | Entry-level value | +| **Standard** | 40–60% | 6–7 | Mid-range cards | +| **Premium** | 60–80% | 8–9 | High-value cards | +| **Flagship** | 80–100% | 10–14 | Premium, high-impact cards | + +The algorithm ensures each band has representation by computing percentile position within each family's cost distribution. + +### 3. Cost Spread Enforcement + +After tier band assignment, a post-processing pass ensures no single cost value exceeds **1/3 of the cards** in any family. Excess cards at a clustered cost value are spread to adjacent values (±1, ±2) to improve strategic differentiation. + +### 4. Reward Spread + +When a card's cost changes, its reward fields (baseIncome, coinDelta, synergy bonuses, etc.) are adjusted proportionally: + +- **Cost increase of >10%**: Rewards scaled up proportionally (costRatio × 0.7) +- **Cost decrease of >10%**: Rewards scaled down proportionally +- **Premium/Flagship cards (cost ≥ 8)**: Awarded synergyCoinBonus and synergyRepBonus if missing + +### 5. Exclusion Rules + +| Rule | Cards Affected | Rationale | +|------|---------------|-----------| +| **Incidents remain free** | All 23 Incident-trigger events | Incidents are negative events; cost would make them purchaseable, changing game balance | + +### 6. Special Cases + +| Card | Handling | +|------|----------| +| **Pawn Shop** | No synergy bonuses; cost reduced by 2 from curve estimate | +| **Clinic** | reputationPerTurn = +0.2 factored into cost calculation (weight × 30) | + +## Per-Family Strategy + +### Business (18 cards) and Community Space (2 cards) + +- **Goal**: Wider cost spread (target: range increase ≥ 30%) +- **Inputs**: baseIncome, synergyCount, synergy bonuses, reputation, tier +- **Algorithm**: Curve-fitted cost + tier-driven base → clamped to tier bands → spread enforcement + +### Investment Events (13 of 36 events) + +- **Goal**: Cost range wider than current 2–4 (target: range ≥ 3) +- **Inputs**: coinDelta, reputationDelta, target scope (All vs SpecificSynergy) +- **Scope multiplier**: All = 1.0×, SpecificSynergy = 1.2× + +### Incidents (23 of 36 events) + +- **Not adjusted** — all remain at cost 0 + +### Upgrades (27 cards) + +- **Goal**: Wider cost spread (target: range ≥ 6) +- **Inputs**: incomeBonus, synergyRangeBonus, requiredLevel, reputationBonus, tier +- **Minimum cost**: 2 + +### Staff (3 cards) + +- **Goal**: Cost spread maintained (target: range ≥ 9) +- **Inputs**: ongoingCost, handSlotsAdded +- **Ongoing cost adjusted proportionally**: Higher purchase cost → proportionally higher ongoing cost + +## Rationale Codes + +| Code | Description | +|------|-------------| +| `TIER_REASSIGN` | Cost adjusted due to tier band reassignment | +| `BAND_BALANCE` | Minor band adjustment to improve cost distribution | +| `COST_CURVE_FIT` | Cost adjusted via curve-fitting model | +| `INCOME_ADJUST` | baseIncome adjusted to reflect new cost tier | +| `SYNERGY_BONUS_ADJ` | Synergy bonus added/removed for premium cards | +| `REPUTATION_ADJ` | Reputation bonus adjusted for cost tier | +| `REWARD_SPREAD` | Reward fields widened to match new cost | +| `ONGOING_COST_ADJ` | Staff ongoing cost adjusted proportionally | +| `INCIDENT_FREE` | Incident event excluded from cost adjustment | +| `SPECIAL_CASE` | Special handling (e.g., Pawn Shop no-synergy) | + +## Determinism + +The tool is fully deterministic — running on the same input CSV produces identical output on every run. No random noise is used in any phase of the algorithm. + +## Usage + +```bash +# Run with defaults (reads card-data.csv, writes card-data.balanced.csv) +npx tsx scripts/run-balance-cards.ts + +# Custom input/output paths +npx tsx scripts/run-balance-cards.ts --input path/to/input.csv --output path/to/output.csv +``` + +## Migration + +This document consolidates all balancing methodology content previously scattered across: + +- `docs/main-street/content-design-and-progression.md` — Section 4 (Difficulty and Balance), 4.1 (Provisional Numeric Balance Targets), 4.2 (Tuning Levers) +- `docs/main-street/the-build-gdd.md` — Section 4 (Difficulty and Balance) +- `docs/main-street/card-catalog.md` — Event Balance Summary table, Upgrade Cost Distribution table + +The origin documents now contain cross-references to this document. diff --git a/docs/main-street/card-catalog-baseline.json b/docs/main-street/card-catalog-baseline.json index 3a3356ef..04f16678 100644 --- a/docs/main-street/card-catalog-baseline.json +++ b/docs/main-street/card-catalog-baseline.json @@ -1,6 +1,6 @@ { "source": "Tier 1 baseline from example-games/main-street/MainStreetTiers.ts", - "capturedAt": "2026-06-23T09:24:24.962Z", + "capturedAt": "2026-07-06T01:12:46.202Z", "perTier": { "tier1": { "business": 6, diff --git a/docs/main-street/card-catalog.md b/docs/main-street/card-catalog.md index 144591c8..8c4a836c 100644 --- a/docs/main-street/card-catalog.md +++ b/docs/main-street/card-catalog.md @@ -1,38 +1,44 @@ # Main Street: Card Catalog -> **Source of truth:** `example-games/main-street/MainStreetCards.ts` -> **Last updated:** Expanded pool verification (work item CG-0MOKJPBOX006UQDO) +> **Source of truth:** `example-games/main-street/card-data.csv` (CSV) — loaded by `MainStreetCards.ts` at build time (work item CG-0MR6ZR23J006ZDNZ) +> **Last updated:** CSV externalisation (work item CG-0MR6ZR23J006ZDNZ) -This document lists every card template in the Main Street card pool, organised by family (Business, Event, Upgrade). Each entry includes all gameplay-relevant fields and a short design rationale. +This document lists every card template in the Main Street card pool, organised by family (Business, Event, Upgrade, Community Space, Staff). Each entry includes all gameplay-relevant fields and a short design rationale. + +Card templates are stored as rows in `card-data.csv` and parsed at build time by `MainStreetCards.ts`. To add cards, edit the CSV and regenerate metadata (see guidance below). **Deck sizes (default copies):** -| Family | Templates | Copies each | Total cards | -|----------|-----------|-------------|-------------| -| Business | 18 | 3 | 54 | -| Event | 18 | 3 | 54 | -| Upgrade | 27 | 2 | 54 | +| Family | Templates | Copies each | Total cards | +|---------------|-----------|-------------|-------------| +| Business | 18 | 3 | 54 | +| Event | 36 | 3 | 108 | +| Upgrade | 27 | 2 | 54 | +| Community Space | 2 | 3 | 6 | +| Staff | 3 | 3 | 9 | **Synergy types:** Food, Culture, Commerce, Service (M2), Entertainment (M2), Health (M2) ## Expansion summary (baseline vs current) -| Snapshot | Business | Event | Upgrade | Total templates | -|---|---:|---:|---:|---:| -| Tier 1 baseline (`docs/main-street/card-catalog-baseline.json`) | 7 | 6 | 5 | 18 | -| Current catalog (`MainStreetCards.ts`) | 18 | 18 | 27 | 63 | -| Net increase | +11 | +12 | +22 | +45 | +| Snapshot | Business | Event | Upgrade | Community Space | Staff | Total templates | +|---|---:|---:|---:|---:|---:|---:| +| Tier 1 baseline (`docs/main-street/card-catalog-baseline.json`) | 7 | 6 | 5 | — | — | 18 | +| Current catalog (`card-data.csv`) | 18 | 36 | 27 | 2 | 3 | 86 | +| Net increase | +11 | +30 | +22 | +2 | +3 | +68 | - 2x target from baseline: `>= 36` templates -- Current total: `63` templates (`3.50x` baseline) +- Current total: `86` templates (`4.78x` baseline) - Non-baseline card IDs are tracked in `docs/main-street/expanded-card-manifest.json` ### Guidance: adding more cards safely -1. Add card templates in `example-games/main-street/MainStreetCards.ts`. +1. Add rows to `example-games/main-street/card-data.csv` using the correct family column value (`business`, `event`, `upgrade`, `community-space`, or `staff`). 2. Regenerate metadata artifacts: + - `npx tsx scripts/generate-card-csv.ts` — regenerates `card-data.csv` from TS (only if editing TS directly; normally edit CSV) - `npx tsx scripts/generate-main-street-catalog-baseline.ts` - - `npx tsx scripts/generate-main-street-expanded-card-manifest.ts` + - `npx vite-node scripts/generate-main-street-expanded-card-manifest.ts` + _(uses Vite-aware ESM loader because it imports deck-building functions from `MainStreetCards.ts`)_ 3. Regenerate placeholder art: - `node scripts/generate-main-street-card-svgs.mjs` 4. Run regression tests: diff --git a/docs/main-street/card-dimensions.md b/docs/main-street/card-dimensions.md index db0a47b1..2338f97f 100644 --- a/docs/main-street/card-dimensions.md +++ b/docs/main-street/card-dimensions.md @@ -41,6 +41,7 @@ UI slot mappings (recommendations) - Market slot thumbnail / Street slot: 140×80 (Main Street canonical) - Street small thumbnail: fit to 105×110 - UI small (compact hand): CARD_W × CARD_H (48×65 default runtime) + - Incident queue thumbnail: 120×69 (preserves 7:4 card SVG aspect ratio) - Selector thumbnail: 120×68 (scene screenshot) ## Layout notes (Main Street) diff --git a/docs/main-street/content-design-and-progression.md b/docs/main-street/content-design-and-progression.md index e4003008..503a420b 100644 --- a/docs/main-street/content-design-and-progression.md +++ b/docs/main-street/content-design-and-progression.md @@ -67,38 +67,9 @@ The core economic loop consists of two primary resources: ## 4. Difficulty and Balance -Main Street is intended to be approachable for 10‑15‑minute play sessions while still offering strategic depth. Difficulty is managed through the following levers: - -| Lever | Effect | -|-------|--------| -| **Slot Count** | The base game uses 10 slots; increasing to 12 slots adds decision space without extending playtime significantly. | -| **Coin Starting Amount** | Adjusting the initial budget (e.g., 8 → 10 coins) can make early rounds easier or tighter. | -| **Synergy Bonus Value** | Changing the synergy bonus from `+1` to `+2` per matching neighbor raises the impact of placement decisions. | -| **Event Frequency** | Adding more Incident events to the deck increases variance. The visible incident queue (2 cards) lets the player plan ahead. | -| **Challenge Targets** | Scaling challenge thresholds (e.g., “Build a Foodie Row” requiring 3 Food businesses) adjusts difficulty. | - -Balancing targets are defined in the runtime difficulty config (Medium defaults: `maxTurns = 20`, `winThreshold = 150`, `startingCoins = 8`, `synergyBonusPerNeighbor = 1`). Playtesting should verify that a typical run reaches the score threshold in the mid-to-late game with meaningful risk from incidents. - -### 4.1 Provisional Numeric Balance Targets (Medium) - -These are draft targets for iterative tuning and are validated using deterministic Monte Carlo sweeps: - -| Metric | Target (draft) | Notes | -|-------|-----------------|-------| -| Win rate | 85%-97% over 100 deterministic seeds | Greedy market strategy baseline (`market-greedy`) | -| Median final score | 150-170 | Should stay near the score-threshold band | -| Dominant loss reason share | >= 50% of losses | Ensures one primary failure mode remains measurable | -| Avg no-action turns | >= 0.75 turns/run | Confirms meaningful affordability tension | -| Avg turn street reaches 5/10 slots | 4.5-7.5 | Early/mid-game growth checkpoint | -| Avg turn street reaches 10/10 slots | 9-12 | Mid/late-game saturation checkpoint | - -### 4.2 Tuning Levers - -- `startingCoins`: early purchasing power and bankruptcy risk. -- `winThreshold`: pacing pressure for score-based wins. -- `synergyBonusPerNeighbor`: strength of adjacency strategy. -- `maxTurns`: upper bound for pacing and turn-exhaustion pressure. -- incident/event card composition and penalties: volatility and loss-vector mix. +The balancing methodology and targets for Main Street have been consolidated into a dedicated document. See [balancing-methodology.md](balancing-methodology.md) for the full methodology. + +> **Migrated content**: Section 4 (Difficulty and Balance), 4.1 (Provisional Numeric Balance Targets), and 4.2 (Tuning Levers) have been moved to `balancing-methodology.md`. This section now serves as a cross-reference. --- diff --git a/docs/main-street/expanded-card-manifest.json b/docs/main-street/expanded-card-manifest.json index e1213651..34ae90fd 100644 --- a/docs/main-street/expanded-card-manifest.json +++ b/docs/main-street/expanded-card-manifest.json @@ -1,6 +1,6 @@ { "source": "Generated from MainStreetCards.ts and Tier 1 IDs from MainStreetTiers.ts", - "generatedAt": "2026-06-24T23:00:00.000Z", + "generatedAt": "2026-07-06T01:10:05.874Z", "baselineTier1CardIds": [ "biz-bakery", "biz-bookshop", @@ -19,8 +19,8 @@ "upg-bistro", "upg-community-hub", "upg-garden", - "upg-readers-cafe", "upg-patisserie", + "upg-readers-cafe", "upg-vintage-shop" ], "expandedCardIds": { diff --git a/docs/main-street/monte-carlo-baseline.json b/docs/main-street/monte-carlo-baseline.json index 5801c49b..adc7667e 100644 --- a/docs/main-street/monte-carlo-baseline.json +++ b/docs/main-street/monte-carlo-baseline.json @@ -1,11 +1,12 @@ { "source": "Generated from MainStreetMonteCarlo.runMonteCarlo", - "generatedAt": "2026-06-30T14:25:29.089Z", + "generatedAt": "2026-07-10T10:29:03.028Z", "seeds": 200, "maxTurns": 25, "strategy": "greedy", + "note": "Updated after CG-0MRER3RE300418SG: fractional income fix removed Math.floor from applyReputationMultiplier and Math.round from applyActiveEffectMultiplier. Win rate and coins-per-turn increased because fractional income values are no longer truncated.", "metrics": { - "winRate": 0.505, - "averageCoinsPerTurn": 2.0112805746240356 + "winRate": 0.65, + "averageCoinsPerTurn": 3.300755112659271 } } diff --git a/docs/main-street/the-build-gdd.md b/docs/main-street/the-build-gdd.md index c12f417e..de5e5bfe 100644 --- a/docs/main-street/the-build-gdd.md +++ b/docs/main-street/the-build-gdd.md @@ -308,16 +308,9 @@ The core economic loop consists of two primary resources: ## 4. Difficulty and Balance -Main Street is intended to be approachable for 10‑15‑minute play sessions while still offering strategic depth. Difficulty is managed through the following levers: -| Lever | Effect | -|-------|--------| -| **Slot Count** | The base game uses 10 slots; increasing to 12 slots adds decision space without extending playtime significantly. | -| **Coin Starting Amount** | Adjusting the initial budget (e.g., 8 → 10 coins) can make early rounds easier or tighter. | -| **Synergy Bonus Value** | Changing the synergy bonus from `+1` to `+2` per matching neighbor raises the impact of placement decisions. | -| **Event Frequency** | Adding more Incident events increases variance and potential swing moments. | -| **Challenge Targets** | Scaling challenge thresholds (e.g., “Build a Foodie Row” requiring 3 Food businesses) adjusts difficulty. | - -Balancing targets are defined in the **GameState** type (`MAX_TURNS = 20`, `WIN_THRESHOLD = 150`). Playtesting should verify that a typical run ends near the turn limit with a final score around the win threshold. +The balancing methodology and targets for Main Street have been consolidated into a dedicated document at `docs/main-street/balancing-methodology.md`. See [balancing-methodology.md](balancing-methodology.md) for the full methodology. + +> **Migrated content**: Section 4 (Difficulty and Balance) has been moved to `balancing-methodology.md`. This section now serves as a cross-reference. --- diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts index f2aa4afc..a597e5da 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts @@ -20,6 +20,7 @@ import { preloadCardAssets, OverlayManager, audioPathWithFallback, + createGameOverOverlay, } from '../../../src/ui'; import type { EventSoundMapping } from '../../../src/core-engine/SoundManager'; import type { HelpSection } from '../../../src/ui'; @@ -29,9 +30,8 @@ import { SFX_KEYS, ANIM_DURATION, AUTO_COMPLETE_STAGGER_MS, AUTO_COMPLETE_MIN_DURATION, OVERLAY_DEPTH, OVERLAY_BG_ALPHA, - OVERLAY_TITLE_FONT_SIZE, OVERLAY_STATS_FONT_SIZE, - OVERLAY_WIN_TITLE_Y_OFFSET, OVERLAY_CONTENT_Y_OFFSET, - OVERLAY_BUTTON_Y_OFFSET, + OVERLAY_STATS_FONT_SIZE, + OVERLAY_CONTENT_Y_OFFSET, RESUME_TITLE_FONT_SIZE, RESUME_TITLE_Y_OFFSET, RESUME_INFO_FONT_SIZE, RESUME_INFO_Y_OFFSET, RESUME_BUTTON_SPACING, RESUME_BUTTON_Y_OFFSET, @@ -696,39 +696,22 @@ export class BeleagueredCastleScene extends CardGameScene { // ── Overlay helpers ──────────────────────────────────── private showWinOverlay(elapsedSeconds: number, _soundManager?: { play: (key: string) => void } | null): void { - const BUTTON_DEPTH = OVERLAY_DEPTH + 1; - - this.overlayManager.showOverlay({ - type: 'game-over', - backgroundOptions: { depth: OVERLAY_DEPTH, alpha: OVERLAY_BG_ALPHA }, - }); - const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; const mm = String(minutes).padStart(2, '0'); const ss = String(seconds).padStart(2, '0'); - const title = this.add.text(GAME_W / 2, GAME_H / 2 + OVERLAY_WIN_TITLE_Y_OFFSET, 'You Win!', { - fontSize: OVERLAY_TITLE_FONT_SIZE, color: '#88ff88', fontFamily: FONT_FAMILY, fontStyle: 'bold', - }).setOrigin(0.5).setDepth(BUTTON_DEPTH); - this.overlayManager.add(title); - - const stats = createHudText(this, GAME_W / 2, GAME_H / 2 + OVERLAY_CONTENT_Y_OFFSET, - `Moves: ${this.gameState.moveCount} Time: ${mm}:${ss}`, '#aaccaa', { - fontSize: OVERLAY_STATS_FONT_SIZE, - originX: 0.5, - originY: 0.5, - }); - stats.setDepth(BUTTON_DEPTH); - this.overlayManager.add(stats); - - const newGameBtn = createOverlayButton(this, GAME_W / 2 - 150, GAME_H / 2 + OVERLAY_BUTTON_Y_OFFSET, '[ New Game ]', BUTTON_DEPTH); - newGameBtn.on('pointerdown', () => this.onNewGame?.()); - this.overlayManager.add(newGameBtn); - - const restartBtn = createOverlayButton(this, GAME_W / 2, GAME_H / 2 + OVERLAY_BUTTON_Y_OFFSET, '[ Restart ]', BUTTON_DEPTH); - restartBtn.on('pointerdown', () => this.onRestart?.()); - this.overlayManager.add(restartBtn); + const result = createGameOverOverlay(this, { + title: 'You Win!', + titleColor: '#88ff88', + summaryText: `Moves: ${this.gameState.moveCount} Time: ${mm}:${ss}`, + onPlayAgain: () => this.onNewGame?.(), + onMenu: () => this.scene.start('GameSelectorScene'), + playAgainLabel: 'Play Again', + menuLabel: 'Menu', + extraButtons: [{ label: 'Restart', onClick: () => this.onRestart?.() }], + }); + this.overlayManager.add(...result.objects); } private showNoMovesOverlay(): void { diff --git a/example-games/feudalism/scenes/FeudalismOverlays.ts b/example-games/feudalism/scenes/FeudalismOverlays.ts index b60776ca..61a9ad89 100644 --- a/example-games/feudalism/scenes/FeudalismOverlays.ts +++ b/example-games/feudalism/scenes/FeudalismOverlays.ts @@ -11,6 +11,7 @@ import { autoSaveTranscript, TranscriptStore } from '../../../src/core-engine/tr import { GAME_W, GAME_H, FONT_FAMILY, createOverlayButton, + createGameOverOverlay, OverlayManager, } from '../../../src/ui'; import { SFX_KEYS } from './FeudalismConstants'; @@ -37,43 +38,29 @@ export class FeudalismOverlayHelper { autoSaveTranscript(transcriptStore, 'feudalism', transcript, '[FeudalismScene]'); } - this.overlayManager.showOverlay({ - type: 'custom', - backgroundOptions: { depth: 10, alpha: 0.01 }, - box: { width: 520, height: 340, alpha: 0.9 }, - }); - - const winnerText = winnerIdx === 0 ? 'You Win!' : 'AI Wins!'; + const winnerText = winnerIdx === 0 ? 'You Win!' : 'Game Over'; + const winnerColor = winnerIdx === 0 ? '#88ff88' : '#ff6666'; const human = this.session.players[0]; const ai = this.session.players[1]; const humanInfluence = getInfluence(human); const aiInfluence = getInfluence(ai); - const lines = [ - winnerText, - '', - `You: ${humanInfluence} influence (${human.purchasedCards.length} cards, ${human.patrons.length} patrons)`, - `AI: ${aiInfluence} influence (${ai.purchasedCards.length} cards, ${ai.patrons.length} patrons)`, - '', - `Tiebreak: fewest cards wins`, - ]; - - const text = this.scene.add - .text(GAME_W / 2, GAME_H / 2 - 55, lines.join('\n'), { - fontSize: '20px', color: '#ffffff', fontFamily: FONT_FAMILY, - align: 'center', lineSpacing: 6, - }) - .setOrigin(0.5) - .setDepth(11); - this.overlayManager.add(text); - - const playBtn = createOverlayButton(this.scene, GAME_W / 2, GAME_H / 2 + 110, '[ Play Again ]'); - playBtn.on('pointerdown', () => { - try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } - this.dismiss(); - onRestart(); + const result = createGameOverOverlay(this.scene, { + title: winnerText, + titleColor: winnerColor, + summaryText: `You: ${humanInfluence} influence (${human.purchasedCards.length} cards, ${human.patrons.length} patrons)\n` + + `AI: ${aiInfluence} influence (${ai.purchasedCards.length} cards, ${ai.patrons.length} patrons)\n\n` + + `Tiebreak: fewest cards wins`, + onPlayAgain: () => { + try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } + this.dismiss(); + onRestart(); + }, + onMenu: () => this.scene.scene.start('GameSelectorScene'), + playAgainLabel: 'Play Again', + menuLabel: 'Menu', }); - this.overlayManager.add(playBtn); + this.overlayManager.add(...result.objects); } showCardActionMenu( diff --git a/example-games/golf/AiStrategy.ts b/example-games/golf/AiStrategy.ts index d9966199..47540a49 100644 --- a/example-games/golf/AiStrategy.ts +++ b/example-games/golf/AiStrategy.ts @@ -17,7 +17,7 @@ import type { Card } from '../../src/card-system/Card'; import type { GolfMove, DrawSource } from './GolfRules'; -import { scoreAiVisibleGrid, simulateAiMoveScore } from './GolfScoring'; +import { scoreAiVisibleGrid, simulateAiMoveScore, cardPointValue } from './GolfScoring'; import type { AiVisiblePlayerState, AiVisibleSharedState, @@ -170,6 +170,7 @@ export function chooseDrawSource( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, _rng: () => number, + config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, ): DrawSource { const sources = enumerateAiDrawSources(shared); if (sources.length === 1) return sources[0]; @@ -217,6 +218,7 @@ export function chooseDrawSource( discardCard, move, visibleRanks, + config, ); if (bonus < 0) { // Discard card helps build a column with feasible potential @@ -228,19 +230,38 @@ export function chooseDrawSource( return 'stock'; } -// ── Visible rank counting (for column-feasibility weighting) ── +// ── Configurable column-awareness heuristic ────────────────── /** - * Maximum copies of any rank in a standard 52-card deck. + * Configuration for the column-awareness heuristic in GreedyStrategy. + * + * `columnWeight` controls the balance between immediate score improvement + * and column-completion potential: + * - 0: Ignore column building entirely (score-only evaluation). + * - 0.5: Equal weight to score and column completion (default, 50/50 balance). + * - 1: Maximum column-building priority. */ -const MAX_RANK_COPIES = 4; +export interface GreedyStrategyConfig { + /** + * Weight of column-completion heuristic vs raw score. + * 0 = ignore columns, 0.5 = 50/50 balance, 1 = max columns. + * @default 0.5 + */ + columnWeight: number; +} + +/** + * Default configuration for GreedyStrategy. + * Starts at a 50/50 balance between immediate score and column completion. + */ +export const DEFAULT_GREEDY_CONFIG: GreedyStrategyConfig = { + columnWeight: 0.5, +}; /** - * Weight (in score points) for the column-building feasibility bonus. - * A negative adjustment means the AI prefers moves that build toward - * column matches, scaled by remaining unknown copies of the target rank. + * Maximum copies of any rank in a standard 52-card deck. */ -const COLUMN_BONUS_WEIGHT = 2; +const MAX_RANK_COPIES = 4; /** * Count how many instances of each card rank are visible to the AI. @@ -286,21 +307,31 @@ export function countVisibleRanks( } /** - * Compute a column-building feasibility bonus for a move. + * Compute a column-building bonus for a move. * * When a swap move would place the drawn card in a column where it * matches other face-up cards, the move builds toward a column match. - * The bonus (negative, reducing the score) is proportional to how many - * unknown copies of the target rank remain in play. + * The bonus (negative, reducing the score) is proportional to: + * - The sum of point values of matching face-up cards in the column + * (higher-value cards like Queens=10 benefit more from being zeroed). + * - How many unknown copies of the target rank remain in play. + * - The configurable columnWeight (default 0.5 = 50/50 balance). * * If all 4 copies of the rank are already visible, the bonus is 0 * (pursuing the column is futile because no unknown copies remain * to complete the match). + * If the matching cards have low or negative point values (e.g., Kings=0, + * 2s=-2), the bonus is reduced or zero since zeroing that column saves + * few or no points. + * + * Information boundary: uses only AI-visible state — counts only face-up + * cards and the discard top. * * @param grid AI-visible grid * @param drawnCard The card the player drew * @param move The move to evaluate * @param visibleRanks Count of visible instances per rank + * @param config Optional strategy config (default uses 50/50 balance) * @returns A negative score adjustment (better) or 0 if no bonus applies */ export function computeColumnBonus( @@ -308,6 +339,7 @@ export function computeColumnBonus( drawnCard: Card, move: GolfMove, visibleRanks: Record, + config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, ): number { // Only swap moves can contribute to column matches if (move.kind === 'discard-and-flip') return 0; @@ -317,6 +349,7 @@ export function computeColumnBonus( const matchingRank = drawnCard.rank; let matchingCount = 1; // The drawn card itself let unknownInColumn = 0; + let cardValueSum = 0; // Sum of point values of matching face-up cards in column for (let row = 0; row < GRID_ROWS; row++) { const flatIdx = row * GRID_COLS + col; @@ -329,12 +362,18 @@ export function computeColumnBonus( (slot as Card).rank === matchingRank ) { matchingCount++; + cardValueSum += cardPointValue((slot as Card).rank); } if (!slot.faceUp) { unknownInColumn++; } } + // Add the drawn card's point value (it participates in the match) + if (matchingCount >= 2) { + cardValueSum += cardPointValue(drawnCard.rank); + } + // After the move, if we have 2+ matching and at least 1 unknown in column, // the move contributes to building a column match if (matchingCount >= 2 && unknownInColumn >= 1) { @@ -342,13 +381,18 @@ export function computeColumnBonus( const unknownCopies = Math.max(0, MAX_RANK_COPIES - visibleCount); // Bonus is proportional to unknown copies remaining - // If all 4 are visible (unknownCopies = 0), bonus = 0 (futile) - // If 0 visible (unknownCopies = 4), bonus = full weight const feasibilityRatio = unknownCopies / MAX_RANK_COPIES; - // Return a negative bonus (or 0 for +0, avoiding -0 which causes test issues) - const bonus = -feasibilityRatio * COLUMN_BONUS_WEIGHT; - return bonus || 0; + // Bonus scales with total card point value in the column: + // - High-value cards (Q=10, J=10) → larger bonus (saving more points) + // - Low-value cards (K=0) → no bonus (zeroing saves nothing) + // - Negative-value cards (2=-2) → reduced or no bonus + const weightedBonus = cardValueSum * feasibilityRatio * config.columnWeight; + + // Return a negative bonus (lower score = better) or 0 for +0 + // Math.min(0, -weightedBonus) ensures negative or zero, never positive + const bonus = -Math.max(0, weightedBonus); + return bonus || 0; // Avoid -0 } return 0; @@ -379,6 +423,7 @@ export function chooseMoveForCard( drawnCard: Card, rng: () => number, visibleRanks?: Record, + config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, ): GolfMove { const legalMoves = enumerateAiLegalMoves(grid); if (legalMoves.length === 0) { @@ -390,7 +435,7 @@ export function chooseMoveForCard( let score = simulateAiMoveScore(grid, drawnCard, move); if (visibleRanks) { - score += computeColumnBonus(grid, drawnCard, move, visibleRanks); + score += computeColumnBonus(grid, drawnCard, move, visibleRanks, config); } return { move, score }; @@ -416,6 +461,17 @@ export class AiPlayer extends AiPlayerBase { * Accepts AI-visible state projections only — cannot access * hidden game data. */ + private readonly config: GreedyStrategyConfig; + + constructor( + strategy: AiStrategy, + rng?: () => number, + config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, + ) { + super(strategy, rng); + this.config = config; + } + chooseAction( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, @@ -425,18 +481,18 @@ export class AiPlayer extends AiPlayerBase { /** * Phase 1: Choose whether to draw from stock or discard. - * Used by the scene for two-phase AI turn flow. + * Uses the configured column-weight heuristic. */ chooseDrawSource( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, ): DrawSource { - return chooseDrawSource(playerState, shared, this.rng); + return chooseDrawSource(playerState, shared, this.rng, this.config); } /** * Phase 2: Given a drawn card, choose the best move. - * Used by the scene after the actual draw for stock draws. + * Uses the configured column-weight heuristic. * * @param visibleRanks Optional. When provided, enables column-feasibility * weighting in move selection. @@ -446,6 +502,6 @@ export class AiPlayer extends AiPlayerBase { drawnCard: Card, visibleRanks?: Record, ): GolfMove { - return chooseMoveForCard(grid, drawnCard, this.rng, visibleRanks); + return chooseMoveForCard(grid, drawnCard, this.rng, visibleRanks, this.config); } } diff --git a/example-games/golf/scenes/GolfSceneHelpers.ts b/example-games/golf/scenes/GolfSceneHelpers.ts index 99c2d800..0970023a 100644 --- a/example-games/golf/scenes/GolfSceneHelpers.ts +++ b/example-games/golf/scenes/GolfSceneHelpers.ts @@ -5,7 +5,7 @@ import type { TranscriptRecorder } from '../GameTranscript'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import type { SoundManager, GameEventEmitter } from '../../../src/core-engine'; -import { GAME_W, GAME_H, OverlayManager } from '../../../src/ui'; +import { GAME_W, GAME_H, OverlayManager, createGameOverOverlay } from '../../../src/ui'; import { createActionButton } from '@ui/Renderer'; import { createGolfHudText, @@ -73,32 +73,14 @@ export class GolfOverlayHelper { reason: `${winnerName} wins (${results.scores[winnerIdx]} pts)`, }); - // Overlay -- near-invisible blocker + visible box - this.overlayManager.showOverlay({ - type: 'custom', - backgroundOptions: { depth: 10, alpha: 0.01 }, - box: { width: 520, height: 350, alpha: 0.85 }, - }); - - const winnerText = results.winnerIndex === 0 ? 'You Win!' : 'AI Wins!'; - const text = createGolfHudText( - this.scene, - GAME_W / 2, - GAME_H / 2 - 50, - `${winnerText}\n\nYou: ${results.scores[0]} pts\nAI: ${results.scores[1]} pts`, - '#ffffff', - { fontSize: '28px', originX: 0.5, align: 'center' }, - ); - this.overlayManager.add(text); - - // Play again button - const playBtn = createActionButton( - this.scene, - GAME_W / 2 - 85, - GAME_H / 2 + 85, - 170, - '[ Play Again ]', - () => { + // Shared game-over overlay + const winnerText = results.winnerIndex === 0 ? 'You Win!' : 'Game Over'; + const winnerColor = results.winnerIndex === 0 ? '#88ff88' : '#ff6666'; + const resultOverlay = createGameOverOverlay(this.scene, { + title: winnerText, + titleColor: winnerColor, + summaryText: `You: ${results.scores[0]} pts\nAI: ${results.scores[1]} pts`, + onPlayAgain: () => { this.soundManager?.play(SFX_KEYS.UI_CLICK); this.gameEvents.emit('ui-interaction', { elementId: 'play-again', @@ -106,26 +88,20 @@ export class GolfOverlayHelper { }); this.scene.scene.restart(); }, - { depth: 11 }, - ); - this.overlayManager.add(playBtn); - - // Export Transcript button - const exportBtn = createActionButton( - this.scene, - GAME_W / 2 - 90, - GAME_H / 2 + 135, - 180, - '[ Export Transcript ]', - () => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - const json = JSON.stringify(transcript, null, 2); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - triggerTranscriptDownload(json, `golf-transcript-${timestamp}.json`); - }, - { depth: 11, fontSize: '13px' }, - ); - this.overlayManager.add(exportBtn); + onMenu: () => this.scene.scene.start('GameSelectorScene'), + playAgainLabel: 'Play Again', + menuLabel: 'Menu', + extraButtons: [{ + label: 'Export Transcript', + onClick: () => { + this.soundManager?.play(SFX_KEYS.UI_CLICK); + const json = JSON.stringify(transcript, null, 2); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + triggerTranscriptDownload(json, `golf-transcript-${timestamp}.json`); + }, + }], + }); + this.overlayManager.add(...resultOverlay.objects); } /** diff --git a/example-games/gym/GymRegistry.ts b/example-games/gym/GymRegistry.ts index 511b9ecd..f6aba0bd 100644 --- a/example-games/gym/GymRegistry.ts +++ b/example-games/gym/GymRegistry.ts @@ -58,6 +58,9 @@ export const GYM_TOOLTIP_KEY = 'GymTooltipScene'; /** HUD Components demo scene key. */ export const GYM_HUD_COMPONENTS_KEY = 'GymHudComponentsScene'; +/** Layout Ownership demo scene key. */ +export const GYM_LAYOUT_OWNERSHIP_KEY = 'GymLayoutOwnershipScene'; + // ── Registry ────────────────────────────────────────────── /** @@ -127,6 +130,12 @@ export const GYM_SCENE_CATALOGUE: GymSceneEntry[] = [ description: 'Parses and validates SLL JSON, maps zones/anchors to pixels across viewport+DPR profiles, and visualizes layout overlays interactively.', }, + { + sceneKey: GYM_LAYOUT_OWNERSHIP_KEY, + title: 'Layout Ownership', + description: + 'Demonstrates the VisibilityOwnershipController: register objects to shell/scene/shared/ungrouped groups and toggle visibility by mode.', + }, { sceneKey: GYM_TOOLTIP_KEY, title: 'Tooltip Manager', diff --git a/example-games/gym/index.ts b/example-games/gym/index.ts index 0555b272..01f0a449 100644 --- a/example-games/gym/index.ts +++ b/example-games/gym/index.ts @@ -23,6 +23,7 @@ export { GymGraphicsLightingSpikeScene } from './scenes/GymGraphicsLightingSpike export { GymSllScene } from './scenes/GymSllScene'; export { GymTooltipScene } from './scenes/GymTooltipScene'; export { GymHudComponentsScene } from './scenes/GymHudComponentsScene'; +export { GymLayoutOwnershipScene } from './scenes/GymLayoutOwnershipScene'; export { GYM_ROUTER_KEY, @@ -38,6 +39,7 @@ export { GYM_SLL_KEY, GYM_TOOLTIP_KEY, GYM_HUD_COMPONENTS_KEY, + GYM_LAYOUT_OWNERSHIP_KEY, GYM_SCENE_CATALOGUE, } from './GymRegistry'; export type { GymSceneEntry } from './GymRegistry'; \ No newline at end of file diff --git a/example-games/gym/layouts/gym-audio-feedback.layout.json b/example-games/gym/layouts/gym-audio-feedback.layout.json new file mode 100644 index 00000000..5433a1b9 --- /dev/null +++ b/example-games/gym/layouts/gym-audio-feedback.layout.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "id": "gym-audio-feedback-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "controls2", + "status", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "controls2": { + "rect": { + "x": 0.5, + "y": 0.11944 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.11944 } + } + }, + "status": { + "rect": { + "x": 0.5, + "y": 0.18889 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.18889 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.28611 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.28611 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-deck-rng.layout.json b/example-games/gym/layouts/gym-deck-rng.layout.json new file mode 100644 index 00000000..f8aa1082 --- /dev/null +++ b/example-games/gym/layouts/gym-deck-rng.layout.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "id": "gym-deck-rng-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "cardDisplay" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "cardDisplay": { + "rect": { + "x": 0.5, + "y": 0.375 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.375 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-hud-components.layout.json b/example-games/gym/layouts/gym-hud-components.layout.json new file mode 100644 index 00000000..9b8561b0 --- /dev/null +++ b/example-games/gym/layouts/gym-hud-components.layout.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "id": "gym-hud-components-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "instructions", + "controls", + "controls2", + "status", + "depth", + "log" + ], + "zones": { + "instructions": { + "rect": { + "x": 0.5, + "y": 0.07778 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.07778 } + } + }, + "controls": { + "rect": { + "x": 0.5, + "y": 0.11944 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.11944 } + } + }, + "controls2": { + "rect": { + "x": 0.5, + "y": 0.16111 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.16111 } + } + }, + "status": { + "rect": { + "x": 0.5, + "y": 0.21111 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.21111 } + } + }, + "depth": { + "rect": { + "x": 0.05, + "y": 0.25 + }, + "anchors": { + "left": { "x": 0.05, "y": 0.25 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.325 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.325 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-lighting-spike.layout.json b/example-games/gym/layouts/gym-lighting-spike.layout.json new file mode 100644 index 00000000..c08d9f94 --- /dev/null +++ b/example-games/gym/layouts/gym-lighting-spike.layout.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "id": "gym-lighting-spike-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "content", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "content": { + "rect": { + "x": 0.5, + "y": 0.375 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.375 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.25 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.25 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-overlay-ui.layout.json b/example-games/gym/layouts/gym-overlay-ui.layout.json new file mode 100644 index 00000000..fd43926b --- /dev/null +++ b/example-games/gym/layouts/gym-overlay-ui.layout.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "id": "gym-overlay-ui-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "intensity", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "intensity": { + "rect": { + "x": 0.5, + "y": 0.13889 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.13889 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.22222 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.22222 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-save-load.layout.json b/example-games/gym/layouts/gym-save-load.layout.json new file mode 100644 index 00000000..973c20df --- /dev/null +++ b/example-games/gym/layouts/gym-save-load.layout.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "id": "gym-save-load-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "controls2", + "state", + "backend", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "controls2": { + "rect": { + "x": 0.5, + "y": 0.11944 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.11944 } + } + }, + "state": { + "rect": { + "x": 0.5, + "y": 0.25 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.25 } + } + }, + "backend": { + "rect": { + "x": 0.5, + "y": 0.29167 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.29167 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.31944 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.31944 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-shader-spike.layout.json b/example-games/gym/layouts/gym-shader-spike.layout.json new file mode 100644 index 00000000..2d4d5f1d --- /dev/null +++ b/example-games/gym/layouts/gym-shader-spike.layout.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "id": "gym-shader-spike-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "status", + "content", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "status": { + "rect": { + "x": 0.5, + "y": 0.125 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.125 } + } + }, + "content": { + "rect": { + "x": 0.5, + "y": 0.27778 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.27778 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.32222 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.32222 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-tooltip.layout.json b/example-games/gym/layouts/gym-tooltip.layout.json new file mode 100644 index 00000000..c08545c7 --- /dev/null +++ b/example-games/gym/layouts/gym-tooltip.layout.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "id": "gym-tooltip-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "header", + "label", + "content", + "log" + ], + "zones": { + "header": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "label": { + "rect": { + "x": 0.5, + "y": 0.13889 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.13889 } + } + }, + "content": { + "rect": { + "x": 0.5, + "y": 0.27778 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.27778 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.52778 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.52778 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-transcript.layout.json b/example-games/gym/layouts/gym-transcript.layout.json new file mode 100644 index 00000000..06b64074 --- /dev/null +++ b/example-games/gym/layouts/gym-transcript.layout.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "id": "gym-transcript-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.25 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.25 } + } + } + } +} diff --git a/example-games/gym/layouts/gym-undo-redo.layout.json b/example-games/gym/layouts/gym-undo-redo.layout.json new file mode 100644 index 00000000..977890f9 --- /dev/null +++ b/example-games/gym/layouts/gym-undo-redo.layout.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "id": "gym-undo-redo-layout", + "baseViewport": { + "width": 1280, + "height": 720 + }, + "requiredZones": [ + "controls", + "counter", + "status", + "history", + "log" + ], + "zones": { + "controls": { + "rect": { + "x": 0.5, + "y": 0.08333 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.08333 } + } + }, + "counter": { + "rect": { + "x": 0.5, + "y": 0.15278 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.15278 } + } + }, + "status": { + "rect": { + "x": 0.5, + "y": 0.20833 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.20833 } + } + }, + "history": { + "rect": { + "x": 0.5, + "y": 0.25 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.25 } + } + }, + "log": { + "rect": { + "x": 0.5, + "y": 0.31944 + }, + "anchors": { + "center": { "x": 0.5, "y": 0.31944 } + } + } + } +} diff --git a/example-games/gym/scenes/GymAudioFeedbackScene.ts b/example-games/gym/scenes/GymAudioFeedbackScene.ts index 13d166fe..e1f3a077 100644 --- a/example-games/gym/scenes/GymAudioFeedbackScene.ts +++ b/example-games/gym/scenes/GymAudioFeedbackScene.ts @@ -26,6 +26,28 @@ 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'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymAudioFeedbackLayoutJson from '../layouts/gym-audio-feedback.layout.json'; + +// Parse the shared Audio Feedback scene layout once at module load. +const AUDIO_FEEDBACK_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymAudioFeedbackLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveAudioAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!AUDIO_FEEDBACK_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(AUDIO_FEEDBACK_LAYOUT, zone, anchor, viewport, 1); +} /** A stub SoundPlayer that records play calls instead of producing audio. */ class StubSoundPlayer implements SoundPlayer { @@ -81,8 +103,22 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Demonstrates audio mapping, mute toggling, feedback configuration, pop text feedback, and particle celebration effects.' }, - { heading: 'Controls', body: '[ Toggle Mute ]: Mute/unmute audio.\n[ Volume - ] / [ Volume + ]: Adjust global volume.\n[ Draw Card ] / [ Discard Card ]: Emit events with pop text.\n[ Pop Text ]: Trigger a pop text animation.\n[ Celebrate ]: Trigger a particle burst effect.\n[ Invalid Key ]: Attempt to play an unregistered key (safely ignored).' } + { + heading: 'Features', + body: 'Demonstrates audio event mapping and feedback configuration using the SoundManager, along with pop text animations (popTextOrIcon) and particle celebration effects. The SoundManager connects game events to sound keys, providing volume control, mute toggling, and graceful handling of missing or unregistered sound keys. In a real card game, sounds play for card draws, discards, wins, and errors, while pop text provides visual feedback alongside audio.' + }, + { + heading: 'Controls', + body: '[ Toggle Mute ]: Toggle audio mute on/off. Status shows current mute state and call count.\n[ Volume - ] / [ Volume + ]: Decrease or increase global volume in 0.1 steps (range 0.0-1.0).\n[ Draw Card ]: Emit a "card-drawn" event, which triggers the "sfx-test-ding" sound and a pop text.\n[ Discard Card ]: Emit a "card-discarded" event, triggering "sfx-test-buzz" and pop text.\n[ Pop Text ]: Trigger a standalone pop text animation at a random position near centre.\n[ Pop Undo ]: Emit an "undo" event, triggering "sfx-test-buzz" and pop text.\n[ Pop Redo ]: Emit a "redo" event, triggering "sfx-test-ding" and pop text.\n[ Celebrate ]: Trigger a particle burst effect (or pop text fallback if particles unavailable, or if reduced-motion is on).\n[ Invalid Key ]: Attempt to play an unregistered sound key ("sfx-nonexistent-key") — safely ignored, demonstrating graceful error handling.' + }, + { + heading: 'Usage Example', + body: 'In a game of Golf, a satisfying "ding" plays when a card is drawn, and a different "buzz" plays when a card is discarded. The player can mute audio during meetings or adjust volume for different environments. After winning a round, a particle celebration bursts across the screen. If the player has reduced-motion enabled, the celebration shows a simple party emoji pop text instead.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Draw Card ] → event log shows sound call "sfx-test-ding", pop text appears\n2. Press [ Discard Card ] → event log shows "sfx-test-buzz"\n3. Press [ Toggle Mute ] → status shows Muted: true, no sound on subsequent events\n4. Press [ Draw Card ] → event fires but no sound (muted) — only pop text appears\n5. Press [ Volume - ] twice → volume drops to 0.3\n6. Press [ Volume + ] three times → volume returns to 0.6\n7. Press [ Invalid Key ] → event log shows safe handling of unregistered key\n8. Press [ Pop Text ] → pop text animation appears\n9. Press [ Celebrate ] → particle burst or emoji fallback appears\n10. Press [ Toggle Mute ] to unmute, verify sound returns' + } ]); // Initialize sound manager @@ -119,7 +155,11 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.soundManager.setVolume(this.volume); const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveAudioAnchor('controls', 'center'); + const controls2Anchor = resolveAudioAnchor('controls2', 'center'); + const statusAnchor = resolveAudioAnchor('status', 'center'); + const logAnchor = resolveAudioAnchor('log', 'center'); + const y = controlsAnchor.y; this.addButton(cx - 450, y, '[ Toggle Mute ]', () => this.toggleMute()); this.addButton(cx - 280, y, '[ Volume - ]', () => this.adjustVolume(-0.1)); @@ -128,17 +168,15 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.addButton(cx + 150, y, '[ Discard Card ]', () => this.emitEvent('card-discarded')); this.addButton(cx + 330, y, '[ Invalid Key ]', () => this.playInvalid()); - y += 26; - this.addButton(cx - 280, y, '[ Pop Text ]', () => this.triggerPopText()); - this.addButton(cx - 100, y, '[ Pop Undo ]', () => this.emitEvent('undo')); - this.addButton(cx + 60, y, '[ Pop Redo ]', () => this.emitEvent('redo')); - this.addButton(cx + 220, y, '[ Celebrate ]', () => this.triggerCelebration()); + const y2 = controls2Anchor.y; + this.addButton(cx - 280, y2, '[ Pop Text ]', () => this.triggerPopText()); + this.addButton(cx - 100, y2, '[ Pop Undo ]', () => this.emitEvent('undo')); + this.addButton(cx + 60, y2, '[ Pop Redo ]', () => this.emitEvent('redo')); + this.addButton(cx + 220, y2, '[ Celebrate ]', () => this.triggerCelebration()); - y += 50; - this.statusText = createHudText(this, cx, y, this.statusString(), '#ffffff', { fontSize: '16px' }).setOrigin(0.5); + this.statusText = createHudText(this, cx, statusAnchor.y, this.statusString(), '#ffffff', { fontSize: '16px' }).setOrigin(0.5); - y += 30; - this.eventLogResult = createEventLog(this, y + 40, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Sound Call Log ──', maxLines: 14, lineHeight: 17, diff --git a/example-games/gym/scenes/GymDeckRngScene.ts b/example-games/gym/scenes/GymDeckRngScene.ts index 5c88ed40..727a2b3c 100644 --- a/example-games/gym/scenes/GymDeckRngScene.ts +++ b/example-games/gym/scenes/GymDeckRngScene.ts @@ -21,6 +21,32 @@ import { preloadCardAssets, ensureCardTextureFallbacks } from '../../../src/ui/C import { createHudText } from '../../../src/ui/Renderer'; import { createDeckGrid } from '../../../src/ui/GymSceneUtils'; import type { DeckGridResult } from '../../../src/ui/GymSceneUtils'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymDeckRngLayoutJson from '../layouts/gym-deck-rng.layout.json'; + +// Parse the shared Deck RNG scene layout once at module load. +const DECK_RNG_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymDeckRngLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +/** + * Resolve an anchor from the Deck RNG SLL layout. + * Falls back to the default viewport if no layout is available. + */ +function resolveDeckRngAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!DECK_RNG_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(DECK_RNG_LAYOUT, zone, anchor, viewport, 1); +} /** Default seed for deterministic demonstrations. */ const DEFAULT_SEED = 42; @@ -66,19 +92,27 @@ export class GymDeckRngScene extends GymSceneBase { this.initHelp([ { - heading: 'Overview', - body: 'Displays all 52 cards face-up in a compact grid, shuffled with the default seed (42) on load.' + heading: 'Features', + body: 'Demonstrates deterministic seeded RNG for card shuffling using createSeededRng() and shuffleArray(). The same seed always produces the same card order, which is essential for reproducible testing, replay systems, and multiplayer consistency. In a game like Golf or Beleaguered Castle, seeded RNG ensures that a player can replay a specific deal for debugging or fair competition.' }, { heading: 'Controls', - body: '[ -1 ] / [ +1 ]: Adjust seed and re-shuffle the deck.\n[ Reset Seed ]: Restore default seed (42) and re-shuffle.\n[ Shuffle ]: Re-shuffle using a random seed.\n\nTip: Using the same seed always produces the same card order.' + body: '[ -1 ] / [ +1 ]: Decrease or increase the seed value and immediately re-shuffle. Use to explore how different seeds produce different card orders while maintaining determinism.\n[ Reset Seed ]: Restore the default seed (42) and re-shuffle. Useful to return to a known state after experimenting.\n[ Shuffle ]: Generate a random seed and re-shuffle. Demonstrates that any seed works with the deterministic system.\n[ < Prev ] / [ Next > ]: Navigate to the previous or next Gym scene.' + }, + { + heading: 'Usage Example', + body: 'In a debugging scenario, a developer notices that the 5th card dealt in a Golf game always comes from a specific position in the deck. By setting the seed to the same value used during the game session, the developer can reproduce the exact same deck order and inspect the deal sequence to verify correctness.' + }, + { + heading: 'Test Plan', + body: '1. Press [ -1 ] twice → seed decreases by 2, grid re-shuffles\n2. Press [ +1 ] → seed increases by 1, grid re-shuffles differently\n3. Press [ Reset Seed ] → seed returns to 42, grid returns to initial order\n4. Press [ Shuffle ] → random seed, grid re-shuffles\n5. Verify all 52 cards are displayed face-up in the compact grid\n6. Verify each re-shuffle produces a visibly different card order' } ]); // ── Controls (positioned via SLL controls zone) ──────────── - const controlsAnchor = this.getGymAnchor('controls', 'left'); - const cx = controlsAnchor?.x ?? GAME_W / 2; - let y = controlsAnchor?.y ?? 60; + const controlsAnchor = resolveDeckRngAnchor('controls', 'center'); + const cx = controlsAnchor.x; + const y = controlsAnchor.y; this.addLabel(cx, y, 'Seed:'); this.seedText = createHudText(this, cx + 50, y, String(this.seed), '#ffffff', { fontSize: '16px' }); @@ -128,9 +162,9 @@ export class GymDeckRngScene extends GymSceneBase { this.deckGridResult = null; } - const cardDisplay = this.getGymAnchor('cardDisplay', 'center'); - const centerX = cardDisplay?.x ?? GAME_W / 2; - const centerY = (cardDisplay?.y ?? 270) + 100; + const cardDisplayAnchor = resolveDeckRngAnchor('cardDisplay', 'center'); + const centerX = cardDisplayAnchor.x; + const centerY = cardDisplayAnchor.y + 100; this.deckGridResult = createDeckGrid(this, this.deck, { cols: GRID_COLUMNS, diff --git a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts index 0be0e413..7ed55fff 100644 --- a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts @@ -19,6 +19,28 @@ 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'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymLightingSpikeLayoutJson from '../layouts/gym-lighting-spike.layout.json'; + +// Parse the shared Lighting Spike scene layout once at module load. +const LIGHTING_SPIKE_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymLightingSpikeLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveLightingAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!LIGHTING_SPIKE_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(LIGHTING_SPIKE_LAYOUT, zone, anchor, viewport, 1); +} export const GYM_GRAPHICS_LIGHTING_SPIKE_KEY = 'GymGraphicsLightingSpikeScene'; @@ -54,17 +76,35 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Feasibility spike for Phaser lighting pipeline. Tests point light on sample sprites and evaluates WebGL requirements.' }, - { heading: 'Controls', body: '[ Toggle Light ]: Turn the point light on/off.\n[ Move Light ]: Move the point light position.\n\nFindings: Lighting requires WebGL. Headless/canvas environments will show a fallback message.' }, + { + heading: 'Features', + body: 'Feasibility spike evaluating Phaser 4\'s lighting pipeline for card-glow and shadow effects. Tests point light rendering on sample sprites with WebGL requirements, and provides graceful fallbacks for headless/canvas environments. In a real card game, point lights could highlight selected cards, create glow effects around playable zones, or add atmospheric shadows to the game board.' + }, + { + heading: 'Controls', + body: '[ Toggle Light ]: Turn the point light on (intensity 1.0) or off (intensity 0.0). When lighting is unavailable, logs a message explaining why.\n[ Move Light ]: Move the point light to a random position within the scene. Demonstrates dynamic light repositioning.\nFindings panel: Records whether WebGL is available, lighting capabilities, and recommendations for production use (feature flag recommended).' + }, + { + heading: 'Usage Example', + body: 'A developer is evaluating whether to add card glow effects to highlight playable cards in a Solitaire game. This spike tests whether Phaser 4\'s LightsManager can render point lights that follow the pointer over interactive cards, creating a subtle glow that draws attention. The findings help decide whether to invest in lighting features or use simpler alternatives like sprite tinting.' + }, + { + heading: 'Test Plan', + body: '1. Scene loads → event log records WebGL availability and lighting status\n2. If lighting available, press [ Toggle Light ] → light intensity toggles between 1.0 and 0.0\n3. Press [ Move Light ] → light moves to a new random position\n4. Verify fallback sprites appear when lighting is unavailable (canvas/headless)\n5. Check findings section records limitations and recommendations' + } ]); const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveLightingAnchor('controls', 'center'); + const contentAnchor = resolveLightingAnchor('content', 'center'); + const logAnchor = resolveLightingAnchor('log', 'center'); + const y = controlsAnchor.y; this.addButton(cx - 100, y, '[ Toggle Light ]', () => this.toggleLight()); this.addButton(cx + 100, y, '[ Move Light ]', () => this.moveLight()); - y += 30; + const spriteY = contentAnchor.y; + const lightY = spriteY - 20; // Attempt to enable lighting try { @@ -79,18 +119,18 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { // Create sprites for the lit scene and enable lighting on them (Phaser 4 API) try { - const spriteA = this.add.image(cx - 150, y + 120, 'lighting-sprite-a'); + const spriteA = this.add.image(cx - 150, spriteY, 'lighting-sprite-a'); spriteA.setLighting(true); } catch (_e) { /* lighting component not available */ } try { - const spriteB = this.add.image(cx + 150, y + 120, 'lighting-sprite-b'); + const spriteB = this.add.image(cx + 150, spriteY, 'lighting-sprite-b'); spriteB.setLighting(true); } catch (_e) { /* lighting component not available */ } // Try to add a Light via the LightsManager try { this.lights.enable(); - this.light = this.lights.addLight(cx, y + 100, 300, 0xffffff, 1.0); + this.light = this.lights.addLight(cx, lightY, 300, 0xffffff, 1.0); this.logEvent('Light added successfully via LightsManager.'); } catch (e) { this.logEvent(`Light add error: ${(e as Error).message}`); @@ -107,16 +147,15 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { if (!this.lightingAvailable) { // Show fallback sprites without lighting - this.add.image(cx - 150, y + 120, 'lighting-sprite-a'); - this.add.image(cx + 150, y + 120, 'lighting-sprite-b'); - createHudText(this, cx, y + 120, 'Lighting unavailable\n(showing fallback sprites)', '#ff8844', { + this.add.image(cx - 150, spriteY, 'lighting-sprite-a'); + this.add.image(cx + 150, spriteY, 'lighting-sprite-b'); + createHudText(this, cx, spriteY, 'Lighting unavailable\n(showing fallback sprites)', '#ff8844', { fontSize: '12px', align: 'center', }).setOrigin(0.5); } - y += 260; - this.eventLogResult = createEventLog(this, y + 20, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Findings & Event Log ──', maxLines: 14, lineHeight: 16, diff --git a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts index 94cdb5c3..354c4681 100644 --- a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts @@ -18,6 +18,28 @@ 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'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymShaderSpikeLayoutJson from '../layouts/gym-shader-spike.layout.json'; + +// Parse the shared Shader Spike scene layout once at module load. +const SHADER_SPIKE_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymShaderSpikeLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveShaderAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!SHADER_SPIKE_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(SHADER_SPIKE_LAYOUT, zone, anchor, viewport, 1); +} /** The scene key must match the registration in GymRegistry. */ export const GYM_GRAPHICS_SHADER_SPIKE_KEY = 'GymGraphicsShaderSpikeScene'; @@ -103,33 +125,48 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Demonstrates sprite tinting, blend modes, and simple shader feasibility. This is a spike scene for evaluating features.' }, - { heading: 'Controls', body: '[ Next Tint ]: Cycle through tint colors.\n[ Next Blend ]: Cycle through blend modes.\n[ Reset Tint ]: Remove tint (white).\n[ Attempt Shader ]: Try to compile and run a minimal fragment shader.\n\nNote: Shaders are WebGL-only and may not work in all environments. Headless/CI builds will fall back gracefully.' }, - { heading: 'Findings', body: 'Blend modes work in WebGL renderer only. Fragment shaders require WebGL pipeline support. Headless fallback: shader attempt logs success/failure without crashing.' }, + { + heading: 'Features', + body: 'Spike scene evaluating sprite tinting, blend modes (NORMAL, ADD, MULTIPLY, SCREEN), and WebGL shader feasibility. In a real card game, tinting highlights valid plays (green tint for playable cards), blend modes create visual layering effects for card overlaps or ghosted previews, and custom shaders could produce animated card borders, foil effects, or dynamic backgrounds.' + }, + { + heading: 'Controls', + body: '[ Next Tint ]: Cycle through tint colours — None, Red, Green, Blue, Gold, Purple. Tint is applied to all three sample sprites simultaneously.\n[ Next Blend ]: Cycle through blend modes — NORMAL, ADD, MULTIPLY, SCREEN. Blend mode applies to all sprites.\n[ Reset Tint ]: Remove all tinting from sprites (reset to white/none).\n[ Attempt Shader ]: Try to detect WebGL support and compile a minimal fragment shader. Logs the result — whether shaders are feasible in this environment.' + }, + { + heading: 'Usage Example', + body: 'A developer building a card game wants to add visual feedback when a card can be played: green tint for valid targets, red tint for invalid ones. This spike verifies that Phaser\'s setTint() works reliably. The blend mode test checks whether ADD mode can create a "glowing" effect when two cards overlap. The shader spike evaluates whether more advanced effects like animated foil borders are feasible for future development.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Next Tint ] six times → cycles through all 6 tint colours, status line updates\n2. Press [ Next Blend ] four times → cycles through all 4 blend modes, status line updates\n3. Press [ Reset Tint ] → all sprites return to white/none\n4. Press [ Attempt Shader ] → event log records whether shader compilation succeeded or a fallback was used\n5. Verify status line shows current blend mode and tint colour correctly' + } ]); const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveShaderAnchor('controls', 'center'); + const statusAnchor = resolveShaderAnchor('status', 'center'); + const contentAnchor = resolveShaderAnchor('content', 'center'); + const logAnchor = resolveShaderAnchor('log', 'center'); + const y = controlsAnchor.y; this.addButton(cx - 400, y, '[ Next Tint ]', () => this.cycleTint()); this.addButton(cx - 240, y, '[ Next Blend ]', () => this.cycleBlendMode()); this.addButton(cx - 60, y, '[ Reset Tint ]', () => this.resetTint()); this.addButton(cx + 120, y, '[ Attempt Shader ]', () => this.attemptShader()); - y += 30; - this.statusLineText = createHudText(this, cx, y, 'Blend: NORMAL | Tint: None', '#88ff88', { fontSize: '12px' }).setOrigin(0.5); + this.statusLineText = createHudText(this, cx, statusAnchor.y, 'Blend: NORMAL | Tint: None', '#88ff88', { fontSize: '12px' }).setOrigin(0.5); - y += 30; - // Create sample sprites + // Create sample sprites at content anchor Y const spriteX = [cx - 180, cx, cx + 180]; + const spriteY = contentAnchor.y; for (let i = 0; i < 3; i++) { const key = ['spike-sprite-a', 'spike-sprite-b', 'spike-sprite-c'][i]; - const sprite = this.add.image(spriteX[i], y + 80, key); + const sprite = this.add.image(spriteX[i], spriteY, key); this.sprites.push(sprite); } - y += 200; - this.eventLogResult = createEventLog(this, y + 20, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Event Log ──', maxLines: 12, lineHeight: 17, diff --git a/example-games/gym/scenes/GymHandPileScene.ts b/example-games/gym/scenes/GymHandPileScene.ts index 86b095b9..fd07ed27 100644 --- a/example-games/gym/scenes/GymHandPileScene.ts +++ b/example-games/gym/scenes/GymHandPileScene.ts @@ -195,8 +195,22 @@ export class GymHandPileScene extends GymSceneBase { }); this.initHelp([ - { heading: 'Overview', body: 'Demonstrates hand/pile card movement with animations: deal, place, discard, move, flip, shake (illegal), and drop-zone highlights. Uses HandView and PileView components.' }, - { heading: 'Controls', body: '[ Draw to Hand ]: Deal a card (with arc animation).\n[ Discard Selected ]: Discard the selected card (with fade animation).\n[ Recall from Discard ]: Move top of discard back to hand.\n[ Flip Selected ]: Flip the selected card (two-phase animation).\n[ Move Selected ]: Tween selected card to display area (move demo).\n[ Cancel Move ]: Cancel an active move animation.\n[ Show Valid Moves ]: Highlight valid drop zones.\n[ Show Illegal ]: Trigger an illegal-move shake demo.\n[ Reset ]: Shuffle a new deck and deal starting hand.\n[ Select Next ]: Cycle selection in your hand.\n[ Enable Drag ]: Turn on drag-and-drop. Drag a card from your hand to the discard pile.\n[ Disable Drag ]: Turn off drag-and-drop restoring normal click-to-select behavior.\nArc slider (right of hand): Adjust hand curvature live (0 = straight).' } + { + heading: 'Features', + body: 'Demonstrates HandView and PileView reusable UI components for card movement, selection, and animation. These components provide draggable hands, arc layouts, pile management, and a rich set of card animations (deal, discard, flip, move tween, illegal-move shake). In a real game like Golf or Lost Cities, HandView renders the player hand and PileView shows draw/discard piles with click-to-interact support.' + }, + { + heading: 'Controls', + body: '[ Draw ]: Deal a card from the deck to the hand with an arc animation. Demonstrates animateAddCard().\n[ Discard ]: Discard the selected card to the discard pile with a fade animation.\n[ Recall ]: Move the top card of the discard pile back to the hand.\n[ Flip ]: Flip the selected card (two-phase scale animation).\n[ Move ]: Tween the selected card to a display area. Demonstrates moveGameObject().\n[ Cancel Move ]: Cancel an active move tween and return the card to the hand.\n[ Show Valid ]: Highlight deck and discard zones as valid drop targets using HighlightManager.\n[ Show Illegal ]: Trigger an illegal-move shake animation on the selected card.\n[ Select Next ]: Cycle forward through cards in the hand.\n[ Sort Hand ]: Sort hand by suit then rank.\n[ Shuffle Hand ]: Randomly shuffle the hand.\n[ Reset ]: Shuffle a fresh deck and deal a new starting hand.\n[ Enable Drag ] / [ Disable Drag ]: Toggle drag-and-drop mode. When enabled, drag a card from hand to the discard pile.\nArc slider: Adjust hand curvature live (0 = straight, 200 = maximum arc).\nSpacing slider: Adjust gap between cards in the hand.\nRotation slider: Adjust maximum rotation angle for cards at the edges of an arc layout.\n[ Toggle Layout ]: Switch between horizontal row and vertical cascade layout.' + }, + { + heading: 'Usage Example', + body: 'In a game of Golf, the player needs to draw from a deck, discard unwanted cards, and flip face-down cards. The animated draw and discard demonstrated here show how cards arc-visually move between piles, while the flip animation reveals hidden cards. The illegal-move shake provides instant feedback when the player tries an invalid action like drawing from an empty pile.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Draw ] → card animates from deck to hand, event log confirms\n2. Press [ Select Next ] twice → second card selected, log shows selection\n3. Press [ Discard ] → selected card fades to discard pile\n4. Press [ Recall ] → card returns from discard to hand\n5. Press [ Flip ] → selected card flips face-down then face-up\n6. Press [ Show Valid ] → green highlights appear on deck and discard zones\n7. Press [ Show Illegal ] → selected card shakes if one is selected\n8. Press [ Enable Drag ] → drag a card from hand to discard pile, verify log shows acceptance\n9. Adjust Arc slider → hand curvature changes live\n10. Press [ Toggle Layout ] → layout switches between horizontal and vertical cascade\n11. Press [ Reset ] → new hand dealt, all state cleared' + } ]); const cx = GAME_W / 2; @@ -547,14 +561,15 @@ export class GymHandPileScene extends GymSceneBase { this.logEvent('No cards to sort'); return; } - // Sort by suit then rank (ascending) - this.hand.sort((a, b) => { - if (a.suit !== b.suit) return a.suit.localeCompare(b.suit); - return rankValue(a.rank) - rankValue(b.rank); - }); - this.selectedIdx = -1; - this.handView.setCards(this.hand); - this.handView.setSelected(null); + // Delegate sort animation to HandView — cards and sprites are + // reordered internally; no manual sort + setCards needed. + this.handView.sortCards( + (a, b) => { + if (a.suit !== b.suit) return a.suit.localeCompare(b.suit); + return rankValue(a.rank) - rankValue(b.rank); + }, + { animate: !this.reducedMotion }, + ); this.logEvent('Hand sorted by suit then rank'); } diff --git a/example-games/gym/scenes/GymHudComponentsScene.ts b/example-games/gym/scenes/GymHudComponentsScene.ts index b18ca0f8..6b2e3468 100644 --- a/example-games/gym/scenes/GymHudComponentsScene.ts +++ b/example-games/gym/scenes/GymHudComponentsScene.ts @@ -22,6 +22,32 @@ import { createHudText } from '../../../src/ui/Renderer'; import { createEventLog } from '../../../src/ui/GymSceneUtils'; import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; import type Phaser from 'phaser'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymHudComponentsLayoutJson from '../layouts/gym-hud-components.layout.json'; + +// Parse the shared HUD Components scene layout once at module load. +const HUD_COMPONENTS_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymHudComponentsLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +/** + * Resolve an anchor from the HUD Components SLL layout. + * Falls back to the default viewport if no layout is available. + */ +function resolveHudAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!HUD_COMPONENTS_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(HUD_COMPONENTS_LAYOUT, zone, anchor, viewport, 1); +} // ── Mock SoundManager for SettingsPanel demo ───────────────── @@ -48,27 +74,20 @@ class MockSoundManager { const HELP_SECTIONS: HelpSection[] = [ { - heading: 'HelpPanel', - body: 'A slide-in left sidebar that displays help content. Accepts an array of HelpSection objects with heading and body/render. Supports keyboard toggle (default: "/" with Shift = "?").', + heading: 'Features', + body: 'Demonstrates the shared HelpPanel and SettingsPanel slide-out UI components, including their toggle buttons (? and ⚙). These panels provide reusable help content and player configuration (sound, reduced motion, keybindings) that any card game can integrate. The depth layering convention ensures panels always render above gameplay content, with input blockers preventing clicks from passing through to the scene underneath.', }, { - heading: 'SettingsPanel', - body: 'A slide-in right sidebar with sound controls (mute toggle, volume slider), reduced-motion toggle, end-turn keybind config, and optional difficulty selector. Requires a SoundManager instance.', + heading: 'Controls', + body: '[ Open HelpPanel ]: Programmatically open the help slide-out panel via the open() method.\n[ Close HelpPanel ]: Programmatically close the help panel via the close() method.\n[ Toggle HelpPanel ]: Toggle the help panel open/closed via the toggle() method.\n[ Open Settings ]: Open the settings panel (right sidebar).\n[ Close Settings ]: Close the settings panel.\n[ Toggle Settings ]: Toggle the settings panel open/closed.\n? button (bottom-left): Toggle help panel via the circular toggle button.\n⚙ button (bottom-left): Toggle settings panel via the circular toggle button.\nStatus lines (centre): Show open/closed state of each panel, updating live as panels are toggled.' }, { - heading: 'Depth Layering', - body: 'Panel components use the following depth convention:\n' + - ' Input blocker: 900\n' + - ' Panel background: 901\n' + - ' Panel content: 902\n' + - ' Close button: 903\n' + - ' Help button (?): 1101\n' + - ' Settings button (⚙): 1102\n' + - 'All gameplay content is at depth 0-999, so panels always render above it.', + heading: 'Usage Example', + body: 'In a real card game, the help panel provides rule explanations triggered by a ? button. The settings panel lets players adjust volume, mute audio, or enable reduced motion for accessibility. The depth layering ensures these panels never clip behind game content, and the input blocker prevents errant clicks on the game board while a panel is open.' }, { - heading: 'HelpButton & SettingsButton', - body: 'Circular toggle buttons rendered at depths 1101 and 1102 respectively. They automatically toggle their associated panel and handle cleanup on scene shutdown.', + heading: 'Test Plan', + body: '1. Press [ Open HelpPanel ] → help panel slides in from left, status shows open\n2. Press [ Close HelpPanel ] → help panel slides out, status shows closed\n3. Press [ Toggle HelpPanel ] twice → panel opens then closes\n4. Press [ Open Settings ] → settings panel slides in from right, status shows open\n5. Press [ Toggle Settings ] → settings toggles closed\n6. Verify status lines update correctly after each action\n7. Press the ? button → help panel toggles open/closed\n8. Press the ⚙ button → settings panel toggles open/closed\n9. Verify no depth layering issues (panels always on top)' }, ]; @@ -104,10 +123,16 @@ export class GymHudComponentsScene extends GymSceneBase { // ── Instructions ──────────────────────────────────── const cx = GAME_W / 2; - let y = 56; + + const instructionsAnchor = resolveHudAnchor('instructions', 'center'); + const controlsAnchor = resolveHudAnchor('controls', 'center'); + const controls2Anchor = resolveHudAnchor('controls2', 'center'); + const statusAnchor = resolveHudAnchor('status', 'center'); + const depthAnchor = resolveHudAnchor('depth', 'left'); + const logAnchor = resolveHudAnchor('log', 'center'); const instructions = createHudText( - this, cx, y, + this, cx, instructionsAnchor.y, 'Use the buttons below or the ? and ⚙ toggle controls to interact with the shared HUD components.', '#88aa88', { fontSize: '13px' }, @@ -116,42 +141,38 @@ export class GymHudComponentsScene extends GymSceneBase { // ── Interactive controls ───────────────────────────── - y += 30; - - this.addButton(cx - 250, y, '[ Open HelpPanel ]', () => { + this.addButton(cx - 250, controlsAnchor.y, '[ Open HelpPanel ]', () => { this.helpPanel!.open(); this._helpOpen = true; this.updateStatusLines(); this.logEvent('HelpPanel: open() called'); }); - this.addButton(cx - 110, y, '[ Close HelpPanel ]', () => { + this.addButton(cx - 110, controlsAnchor.y, '[ Close HelpPanel ]', () => { this.helpPanel!.close(); this._helpOpen = false; this.updateStatusLines(); this.logEvent('HelpPanel: close() called'); }); - this.addButton(cx + 30, y, '[ Toggle HelpPanel ]', () => { + this.addButton(cx + 30, controlsAnchor.y, '[ Toggle HelpPanel ]', () => { this.helpPanel!.toggle(); this._helpOpen = !this._helpOpen; this.updateStatusLines(); this.logEvent(`HelpPanel: toggle() → ${this._helpOpen ? 'open' : 'closed'}`); }); - y += 30; - - this.addButton(cx - 130, y, '[ Open Settings ]', () => { + this.addButton(cx - 130, controls2Anchor.y, '[ Open Settings ]', () => { this.settingsPanel.open(); this._settingsOpen = true; this.updateStatusLines(); this.logEvent('SettingsPanel: open() called'); }); - this.addButton(cx + 10, y, '[ Close Settings ]', () => { + this.addButton(cx + 10, controls2Anchor.y, '[ Close Settings ]', () => { this.settingsPanel.close(); this._settingsOpen = false; this.updateStatusLines(); this.logEvent('SettingsPanel: close() called'); }); - this.addButton(cx + 150, y, '[ Toggle Settings ]', () => { + this.addButton(cx + 150, controls2Anchor.y, '[ Toggle Settings ]', () => { this.settingsPanel.toggle(); this._settingsOpen = !this._settingsOpen; this.updateStatusLines(); @@ -160,19 +181,17 @@ export class GymHudComponentsScene extends GymSceneBase { // ── Panel state indicators ────────────────────────── - y += 36; this.helpStatusText = createHudText( - this, 460, y, 'HelpPanel: closed', '#88ff88', { fontSize: '14px' }, + this, 460, statusAnchor.y, 'HelpPanel: closed', '#88ff88', { fontSize: '14px' }, ); this.settingsStatusText = createHudText( - this, 440, y, 'SettingsPanel: closed', '#ffcc44', { fontSize: '14px' }, + this, 440, statusAnchor.y, 'SettingsPanel: closed', '#ffcc44', { fontSize: '14px' }, ); // ── Depth layering info ───────────────────────────── - y += 28; createHudText( - this, 60, y, + this, 60, depthAnchor.y, 'Depth: blocker=900, bg=901, content=902, close=903, ? btn=1101, ⚙ btn=1102', '#88aa88', { fontSize: '12px' }, @@ -184,8 +203,7 @@ export class GymHudComponentsScene extends GymSceneBase { // ── Event log ─────────────────────────────────────── - y += 44; - this.eventLogResult = createEventLog(this, y + 10, { + this.eventLogResult = createEventLog(this, logAnchor.y + 10, { headerText: '── Event Log ──', maxLines: 12, lineHeight: 16, diff --git a/example-games/gym/scenes/GymLayoutOwnershipScene.ts b/example-games/gym/scenes/GymLayoutOwnershipScene.ts new file mode 100644 index 00000000..5ddb0c7d --- /dev/null +++ b/example-games/gym/scenes/GymLayoutOwnershipScene.ts @@ -0,0 +1,472 @@ +/** + * GymLayoutOwnershipScene -- Demonstrates the Layout Ownership runtime. + * + * This scene registers Phaser GameObjects to different ownership groups + * (shell, scene, shared, ungrouped) and toggles visibility based on the + * active layout mode (shell-only, scene-only, composed). It also shows + * diagnostic warnings for ungrouped targets and demonstrates dynamic + * group rule updates. + * + * Controls: + * - [ Mode: Shell ] / [ Scene ] / [ Composed ] – switch layout modes + * - [ Shell Chrome ] / [ Scene Chrome ] / [ Shared Chrome ] – toggle individual group rules + * - [ + Ungrouped ] – register an ungrouped target (triggers a diagnostic) + * - [ – Ungrouped ] – remove the last ungrouped target + * - [ Clear ] – unregister all targets + * + * Visual feedback: + * - Registered objects change visible/hidden state automatically. + * - A live status line shows which groups are currently active. + * - Unregistered (hidden) objects show a dim overlay label. + * + * @module example-games/gym/scenes/GymLayoutOwnershipScene + */ + +import Phaser from 'phaser'; +import { GymSceneBase } from './GymSceneBase'; +import { GYM_LAYOUT_OWNERSHIP_KEY } from '../GymRegistry'; +import { + VisibilityOwnershipController, + type VisibilityMode, + type VisibilityTarget, + type VisibilityOwnershipIssue, +} from '../../../src/core-engine/VisibilityOwnership'; + +// ── Layout constants ─────────────────────────────────────── + +const CARD_W = 260; +const CARD_H = 60; +const CARD_GAP = 12; +const GRID_X = 20; +const GRID_Y = 160; +const CONTROLS_Y = 130; +const STATUS_Y = 640; +const MODE_COLOR_SHELL = '#ff8866'; +const MODE_COLOR_SCENE = '#88bbff'; +const MODE_COLOR_COMPOSED = '#88dd88'; +const BG_COLOR = '#0a1420'; +const CARD_STROKE = 0x4488aa; + +// ── Scene ─────────────────────────────────────────────────── + +interface ModeButton { + text: Phaser.GameObjects.Text; + mode: VisibilityMode; +} + +interface GroupToggle { + label: Phaser.GameObjects.Text; + groupName: string; + displayName: string; + active: boolean; +} + +export class GymLayoutOwnershipScene extends GymSceneBase { + private controller!: VisibilityOwnershipController; + private registeredTargets: Phaser.GameObjects.Text[] = []; + private bgRects: Phaser.GameObjects.Rectangle[] = []; + private modeButtons: ModeButton[] = []; + private groupToggles: GroupToggle[] = []; + private statusText!: Phaser.GameObjects.Text; + private issues: VisibilityOwnershipIssue[] = []; + private issueList: Phaser.GameObjects.Text[] = []; + + constructor() { + super({ key: GYM_LAYOUT_OWNERSHIP_KEY }); + } + + create(): void { + this.cameras.main.setBackgroundColor(BG_COLOR); + this.initHeader('Layout Ownership Runtime'); + this.addDivider(); + this.initReducedMotion(); + + this.initHelp([ + { + heading: 'Features', + body: 'Demonstrates the VisibilityOwnershipController runtime for managing which UI elements are visible based on the active layout mode. Objects are registered to ownership groups (shell, scene, shared, ungrouped), and their visibility automatically changes when the mode switches between shell-only, scene-only, and composed. Diagnostic warnings are emitted for unregistered or ungrouped targets. In a real card game, this controls whether shell chrome (menu bar, help button), scene content (cards, board), or both are visible depending on the game state.' + }, + { + heading: 'Controls', + body: '[ Mode: Shell ]: Switch to shell-only mode — only objects in the "shell" and "shared" groups are visible. Scene-specific objects hide.\n[ Mode: Scene ]: Switch to scene-only mode — only "scene" and "shared" objects are visible. Shell chrome hides.\n[ Mode: Composed ]: Both shell and scene objects are visible simultaneously.\n[ Shell Chrome ] / [ Scene Chrome ] / [ Shared Chrome ]: Toggle individual group rules ON/OFF, dynamically adding or removing visibility rules for that group.\n[ + Ungrouped ]: Register a new unregistered object (triggers a diagnostic warning for objects without ownership groups).\n[ - Ungrouped ]: Remove the last unregistered object.\n[ Clear All ]: Destroy all demo objects and reset the controller.' + }, + { + heading: 'Usage Example', + body: 'A card game has shell chrome (score bar, menu button, settings icon) that should always be visible during gameplay. When the player opens a modal (e.g., game-over summary), the shell chrome might remain visible while scene-specific cards dim. Using VisibilityOwnershipController, the game switches between "scene-only" mode (showing only cards and board) and "composed" mode (showing both shell chrome and scene), with the controller automatically toggling visibility for all registered objects.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Mode: Scene ] → shell objects (Shell Title, Shell Menu) hide; scene objects remain visible\n2. Press [ Mode: Shell ] → shell objects reappear; scene objects hide\n3. Press [ Mode: Composed ] → both shell and scene objects visible\n4. Press [ Shell Chrome ] → shell group toggles OFF, shell objects hide in current mode\n5. Press [ Shell Chrome ] again → shell group toggles ON, shell objects reappear\n6. Press [ + Ungrouped ] three times → three ungrouped cards appear, diagnostic warnings show\n7. Press [ - Ungrouped ] twice → two ungrouped cards removed\n8. Press [ Clear All ] → all demo objects destroyed, controller reset\n9. Verify that switching modes never leaves orphan objects visible' + } + ]); + + this.createController(); + this.createDemoObjects(); + this.createControls(); + + this.events.once('shutdown', () => this.cleanup()); + } + + // ── Controller setup ───────────────────────────────────── + + private createController(): void { + this.controller = new VisibilityOwnershipController({ + groupRules: { + shell: { + 'shell-only': true, + 'composed': true, + }, + scene: { + 'scene-only': true, + 'composed': true, + }, + shared: { + 'shell-only': true, + 'scene-only': true, + 'composed': true, + }, + ungrouped: { + 'shell-only': false, + 'scene-only': false, + 'composed': false, + }, + }, + defaultGroupName: 'ungrouped', + reportIssue: (issue: VisibilityOwnershipIssue) => this.issues.push(issue), + }); + } + + // ── Demo objects ───────────────────────────────────────── + + private createDemoObjects(): void { + const objects: Array<{ + label: string; + group: string; + x: number; + y: number; + }> = [ + { label: 'Shell Title', group: 'shell', x: GRID_X, y: GRID_Y }, + { label: 'Shell Menu', group: 'shell', x: GRID_X + CARD_W + CARD_GAP, y: GRID_Y }, + { label: 'Scene Card 1', group: 'scene', x: GRID_X, y: GRID_Y + CARD_H + CARD_GAP }, + { label: 'Scene Card 2', group: 'scene', x: GRID_X + CARD_W + CARD_GAP, y: GRID_Y + CARD_H + CARD_GAP }, + { label: 'Shared Action', group: 'shared', x: GRID_X, y: GRID_Y + 2 * (CARD_H + CARD_GAP) }, + { label: 'Shared Help', group: 'shared', x: GRID_X + CARD_W + CARD_GAP, y: GRID_Y + 2 * (CARD_H + CARD_GAP) }, + ]; + + for (const obj of objects) { + const card = this.createOwnedCard(obj.label, obj.x, obj.y); + this.controller.register(card, obj.group); + this.registeredTargets.push(card); + } + } + + private createOwnedCard( + label: string, + x: number, + y: number, + ): Phaser.GameObjects.Text { + // Card background (drawn behind the text at the same position) + const bg = this.add + .rectangle(x + CARD_W / 2, y + CARD_H / 2, CARD_W, CARD_H, 0x1a3a4a, 0.8) + .setStrokeStyle(2, CARD_STROKE, 1) + .setDepth(0); + // Store reference for cleanup + this.bgRects.push(bg); + + // Label text — directly on the scene display list, not inside a Container + const text = this.add + .text(x + CARD_W / 2, y + CARD_H / 2, label, { + fontSize: '14px', + color: '#ffffff', + fontFamily: 'monospace', + fontStyle: 'bold', + }) + .setOrigin(0.5) + .setDepth(1); + + text.setVisible(true); + + return text; + } + + // ── Controls ───────────────────────────────────────────── + + private createControls(): void { + // Mode buttons row + const modes: Array<{ mode: VisibilityMode; label: string }> = [ + { mode: 'shell-only', label: '[ Mode: Shell ]' }, + { mode: 'scene-only', label: '[ Mode: Scene ]' }, + { mode: 'composed', label: '[ Mode: Composed ]' }, + ]; + + const modeStartX = 20; + for (let i = 0; i < modes.length; i++) { + const btn = this.createModeButton( + modeStartX + i * (CARD_W + CARD_GAP), + CONTROLS_Y, + modes[i].mode, + modes[i].label, + ); + this.modeButtons.push(btn); + } + + // Group toggle buttons + const groups: Array<{ group: string; label: string }> = [ + { group: 'shell', label: '[ Shell Chrome ]' }, + { group: 'scene', label: '[ Scene Chrome ]' }, + { group: 'shared', label: '[ Shared Chrome ]' }, + ]; + + const groupStartX = 20; + for (let i = 0; i < groups.length; i++) { + const toggle = this.createGroupToggle( + groupStartX + i * (CARD_W + CARD_GAP), + CONTROLS_Y + 30, + groups[i].group, + groups[i].label, + ); + this.groupToggles.push(toggle); + } + + // Ungrouped controls + const ungroupedBtns = [ + { label: '[ + Ungrouped ]', action: () => this.addUngrouped() }, + { label: '[ – Ungrouped ]', action: () => this.removeUngrouped() }, + ]; + + for (let i = 0; i < ungroupedBtns.length; i++) { + const { label, action } = ungroupedBtns[i]; + this.createClickableButton( + groupStartX + i * (130 + 8), + CONTROLS_Y + 60, + label, + action, + ); + } + + // Clear button + this.createClickableButton( + 300, + CONTROLS_Y + 60, + '[ Clear All ]', + () => { + this.registeredTargets.forEach((c) => { + try { c.destroy(); } catch (_) { /* ignore */ } + }); + this.registeredTargets = []; + this.bgRects.forEach((bg) => { + try { bg.destroy(); } catch (_) { /* ignore */ } + }); + this.bgRects = []; + this.issues = []; + this.issueList.forEach((t) => t.destroy()); + this.issueList = []; + this.controller.clear(); + }, + ); + + // Status line + this.statusText = this.add + .text(20, STATUS_Y, 'Mode: composed | Active groups: shell, scene, shared', { + fontSize: '12px', + color: '#aaccdd', + fontFamily: 'monospace', + }) + .setDepth(50); + + // Issue area (below status) + for (let i = 0; i < 3; i++) { + const issueText = this.add + .text(20, STATUS_Y + 18 + i * 14, '', { + fontSize: '11px', + color: '#ffaa44', + fontFamily: 'monospace', + }) + .setDepth(50); + this.issueList.push(issueText); + } + } + + private createModeButton( + x: number, + y: number, + mode: VisibilityMode, + label: string, + ): ModeButton { + const color = + mode === 'shell-only' + ? MODE_COLOR_SHELL + : mode === 'scene-only' + ? MODE_COLOR_SCENE + : MODE_COLOR_COMPOSED; + + const text = this.createClickableButton(x, y, label, () => { + this.controller.setMode(mode); + this.updateModeButtons(); + this.updateStatus(); + this.updateIssues(); + }, { color }); + + return { text, mode }; + } + + private createGroupToggle( + x: number, + y: number, + groupName: string, + label: string, + ): GroupToggle { + // Extract the display name from the bracket label (e.g. ' Shell Chrome ' from '[ Shell Chrome ]') + const displayName = label.slice(1, label.length - 1).trim(); + // Start active + const text = this.createClickableButton( + x, + y, + `[ ${displayName}: ON ]`, + () => { + this.toggleGroup(groupName); + }, + { color: '#88dd88' }, + ); + + return { label: text, groupName, displayName, active: true }; + } + + private createClickableButton( + x: number, + y: number, + text: string, + callback: () => void, + opts: { color?: string; hoverColor?: string } = {}, + ): Phaser.GameObjects.Text { + const color = opts.color ?? '#88ff88'; + const hoverColor = opts.hoverColor ?? '#bbffbb'; + + const btn = this.add + .text(x, y, text, { + fontSize: '12px', + color, + fontFamily: 'monospace', + }) + .setInteractive({ useHandCursor: true }) + .setDepth(55); + + btn.on('pointerdown', callback); + btn.on('pointerover', () => btn.setColor(hoverColor)); + btn.on('pointerout', () => btn.setColor(color)); + + return btn; + } + + // ── Interaction logic ──────────────────────────────────── + + private toggleGroup(groupName: string): void { + const toggle = this.groupToggles.find((t) => t.groupName === groupName); + if (!toggle) return; + + toggle.active = !toggle.active; + toggle.label.setText( + `[ ${toggle.displayName}: ${toggle.active ? 'ON' : 'OFF'} ]`, + ); + toggle.label.setColor(toggle.active ? '#88dd88' : '#ff6644'); + + const newRules: Record = {}; + + if (toggle.active) { + // Restore default rules for this group + newRules['shell-only'] = groupName !== 'scene'; + newRules['scene-only'] = groupName !== 'shell'; + newRules['composed'] = true; + } + + this.controller.setGroupRules(groupName, newRules); + this.updateStatus(); + } + + private addUngrouped(): void { + const idx = this.registeredTargets.length + 1; + const card = this.createOwnedCard(`Ungrouped #${idx}`, 20 + idx * (CARD_W + 8), 500); + this.controller.register(card); // no group → ungrouped + this.registeredTargets.push(card); + this.updateIssues(); + } + + private removeUngrouped(): void { + // Find and remove the last target registered to the 'ungrouped' default group + for (let i = this.registeredTargets.length - 1; i >= 0; i--) { + const target = this.registeredTargets[i]; + // Skip targets that have group info — we only track group on registration here, + // so we check if this was registered via addUngrouped() (created by that method) + const isUngrouped = target.text.startsWith('Ungrouped #'); + if (isUngrouped) { + this.registeredTargets.splice(i, 1); + try { target.destroy(); } catch (_) { /* ignore */ } + this.updateIssues(); + return; + } + } + } + + private cleanup(): void { + this.issues = []; + this.issueList.forEach((t) => t.destroy()); + this.issueList = []; + // Destroy all registered text targets and their associated backgrounds + for (const card of this.registeredTargets) { + try { card.destroy(); } catch (_) { /* ignore */ } + } + this.registeredTargets = []; + for (const bg of this.bgRects) { + try { bg.destroy(); } catch (_) { /* ignore */ } + } + this.bgRects = []; + this.controller.clear(); + } + + // ── UI updates ─────────────────────────────────────────── + + private updateModeButtons(): void { + const currentMode = this.controller.getMode(); + for (const mb of this.modeButtons) { + const isActive = mb.mode === currentMode; + const color = + mb.mode === 'shell-only' + ? isActive ? MODE_COLOR_SHELL : '#665544' + : mb.mode === 'scene-only' + ? isActive ? MODE_COLOR_SCENE : '#445566' + : isActive ? MODE_COLOR_COMPOSED : '#446644'; + mb.text.setColor(color); + mb.text.setText( + `${isActive ? '▶ ' : ''}${mb.mode === 'shell-only' ? 'Shell' : mb.mode === 'scene-only' ? 'Scene' : 'Composed'}`, + ); + } + } + + private updateStatus(): void { + const currentMode = this.controller.getMode(); + const activeGroups = this.groupToggles + .filter((t) => t.active) + .map((t) => t.groupName) + .join(', '); + this.statusText.setText( + `Mode: ${currentMode} | Active groups: ${activeGroups || '(none)'}`, + ); + + // Update mode button colors + this.updateModeButtons(); + } + + private updateIssues(): void { + // Only show the last few issues to avoid clutter + const recentIssues = this.issues.slice(-this.issueList.length); + for (let i = 0; i < this.issueList.length; i++) { + const issue = recentIssues[i]; + if (issue) { + this.issueList[i].setText(`⚠ ${issue.message}`); + this.issueList[i].setColor('#ffaa44'); + } else { + this.issueList[i].setText(''); + } + } + } +} diff --git a/example-games/gym/scenes/GymOverlayUiScene.ts b/example-games/gym/scenes/GymOverlayUiScene.ts index 1c9fec6b..ac89a9e7 100644 --- a/example-games/gym/scenes/GymOverlayUiScene.ts +++ b/example-games/gym/scenes/GymOverlayUiScene.ts @@ -20,6 +20,32 @@ 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'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymOverlayUiLayoutJson from '../layouts/gym-overlay-ui.layout.json'; + +// Parse the shared Overlay UI scene layout once at module load. +const OVERLAY_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymOverlayUiLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +/** + * Resolve an anchor from the Overlay UI SLL layout. + * Falls back to the default viewport if no layout is available. + */ +function resolveOverlayAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!OVERLAY_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(OVERLAY_LAYOUT, zone, anchor, viewport, 1); +} export class GymOverlayUiScene extends GymSceneBase { private overlayObjects: Phaser.GameObjects.GameObject[] | null = null; @@ -55,24 +81,39 @@ export class GymOverlayUiScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Explores overlay lifecycle, live UI configuration, and GeometryMask clipping for scrollable content.' }, - { heading: 'Controls', body: '[ Show Overlay ]: Open a dismissible overlay with masked scrollable content.\n[ Dismiss Overlay ]: Close the overlay if open.\n[ Intensity - ] / [ Intensity + ]: Adjust feedback intensity which influences overlay appearance.' } + { + heading: 'Features', + body: 'Demonstrates overlay creation and dismissal using createOverlayBackground() and dismissOverlay(), along with live UI configuration via feedback intensity controls. Also showcases GeometryMask clipping for scrollable content regions within overlays. In a real card game, overlays are used for confirmation dialogs ("Are you sure you want to quit?"), rule reminders, or modal messages that temporarily block interaction with the game board.' + }, + { + heading: 'Controls', + body: '[ Show Overlay ]: Open a dismissible overlay with masked scrollable content area. Click the overlay background to dismiss.\n[ Dismiss Overlay ]: Programmatically close the overlay if it is open.\n[ Intensity - ] / [ Intensity + ]: Decrease or increase feedback intensity by 0.2 steps (range 0-1). Affects overlay brightness and alpha in real time. Also adjustable from inside the overlay via [-] and [+] buttons.\nClose button inside overlay: Click the dismiss link or overlay background to close.' + }, + { + heading: 'Usage Example', + body: 'In a game of Golf, after a player completes a round, an overlay appears showing the final score, statistics, and a confirmation to start a new game. The overlay uses a semi-transparent background to keep the game board visible underneath, and the intensity controls let the player dim or brighten the overlay for comfort. The GeometryMask clips a scrollable rules summary to a fixed-size region.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Show Overlay ] → overlay appears with semi-transparent background\n2. Verify overlay contains scrollable masked content area with clipped text\n3. Press [ Intensity - ] three times → intensity drops from 1.0 to 0.4, overlay dims\n4. Press [ Intensity + ] twice → intensity returns to 0.8\n5. Click the overlay background → overlay dismisses, event log confirms\n6. Open overlay again, click [ Dismiss Overlay ] button inside overlay → overlay closes\n7. Press outside overlay interaction guard → verify no accidental dismissal' + } ]); - const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveOverlayAnchor('controls', 'center'); + const cx = controlsAnchor.x; + let y = controlsAnchor.y; this.addButton(cx - 300, y, '[ Show Overlay ]', () => this.openOverlay()); this.addButton(cx - 120, y, '[ Dismiss Overlay ]', () => this.closeOverlay()); this.addButton(cx + 80, y, '[ Intensity - ]', () => this.adjustIntensity(-0.2)); this.addButton(cx + 260, y, '[ Intensity + ]', () => this.adjustIntensity(0.2)); - y += 40; - this.intensityText = createHudText(this, cx, y, 'Feedback Intensity: 1.0', '#88ff88', { fontSize: '16px' }); + const intensityAnchor = resolveOverlayAnchor('intensity', 'center'); + this.intensityText = createHudText(this, cx, intensityAnchor.y, 'Feedback Intensity: 1.0', '#88ff88', { fontSize: '16px' }); this.intensityText.setOrigin(0.5); - y += 30; - this.eventLogResult = createEventLog(this, y + 20, { + const logAnchor = resolveOverlayAnchor('log', 'center'); + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Event Log ──', maxLines: 14, lineHeight: 17, diff --git a/example-games/gym/scenes/GymSaveLoadScene.ts b/example-games/gym/scenes/GymSaveLoadScene.ts index 8032a0e4..4b8387c6 100644 --- a/example-games/gym/scenes/GymSaveLoadScene.ts +++ b/example-games/gym/scenes/GymSaveLoadScene.ts @@ -27,6 +27,28 @@ import { createCard, shuffleArray, createStandardDeck, rankValue } from '../../. import type { Card, Rank, Suit } from '../../../src/card-system'; import { ensureCardTextureFallbacks, preloadCardAssets } from '../../../src/ui/CardTextureHelpers'; import { HandView } from '../../../src/ui/HandView'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymSaveLoadLayoutJson from '../layouts/gym-save-load.layout.json'; + +// Parse the shared Save/Load scene layout once at module load. +const SAVE_LOAD_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymSaveLoadLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveSaveLoadAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!SAVE_LOAD_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(SAVE_LOAD_LAYOUT, zone, anchor, viewport, 1); +} // ── Card score: A=1, 2=2, ..., J=11, Q=12, K=13 ──────────── function cardScore(rank: Rank): number { @@ -125,8 +147,22 @@ 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, 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.' }, + { + heading: 'Features', + body: 'Demonstrates the SaveLoadStore API for persisting and restoring game state, including serialization/deserialization with schema versioning, malformed payload handling, and full-screen RenderTexture screenshots. In a real card game, this enables save/resume functionality — a player can save their game mid-round, close the browser, and return later to pick up exactly where they left off, including a visual snapshot of the board.' + }, + { + heading: 'Controls', + body: '[ Add Card ]: Deal a random card from the source deck to the hand. Increases hand size and score.\n[ Save State ]: Persist the current hand of cards and screenshot (if taken) via the SaveLoadStore.\n[ Load State ]: Restore the last saved hand and screenshot. Cards are displayed face-up and any previous screenshot thumbnail is recreated.\n[ Load Malformed ]: Simulate a corrupted save payload to verify error handling and graceful fallback.\n[ Clear Save ]: Remove all persisted save data for this scene.\n[ Take Screenshot ]: Capture a full-screen RenderTexture screenshot displayed as a thumbnail below the controls.\n[ Clear Screenshot ]: Remove the screenshot thumbnail display.' + }, + { + heading: 'Usage Example', + body: 'A player in the middle of a Golf game needs to step away. They press [ Save State ], which persists their current hand of cards and a screenshot of the board. Returning later, they press [ Load State ] to restore the exact hand and visual state, letting them continue the game without losing progress. The malformed loading test ensures corrupt save files don\'t crash the game.' + }, + { + heading: 'Test Plan', + body: '1. Press [ Add Card ] three times → hand grows, score updates accordingly\n2. Press [ Take Screenshot ] → RenderTexture thumbnail appears below controls\n3. Press [ Save State ] → event log confirms save with schema version and slot ID\n4. Press [ Add Card ] twice more → hand changes from saved state\n5. Press [ Load State ] → hand returns to 8 cards (5 initial + 3 added), screenshot recreated\n6. Press [ Clear Save ] → persisted data removed\n7. Press [ Load Malformed ] → event log confirms error handling caught the bad payload\n8. Verify state text shows correct hand size and score after each operation' + } ]); // Generate fallback card textures if the real SVGs did not load @@ -160,7 +196,12 @@ export class GymSaveLoadScene extends GymSceneBase { // ── Buttons ─────────────────────────────────────────────── const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveSaveLoadAnchor('controls', 'center'); + const controls2Anchor = resolveSaveLoadAnchor('controls2', 'center'); + const stateAnchor = resolveSaveLoadAnchor('state', 'center'); + const backendAnchor = resolveSaveLoadAnchor('backend', 'center'); + const logAnchor = resolveSaveLoadAnchor('log', 'center'); + const y = controlsAnchor.y; this.addButton(cx - 400, y, '[ Add Card ]', () => this.addCard()); this.addButton(cx - 240, y, '[ Save State ]', () => this.saveState()); @@ -168,20 +209,18 @@ export class GymSaveLoadScene extends GymSceneBase { 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 Screenshot ]', () => this.takeScreenshot()); - this.addButton(cx - 100, y, '[ Clear Screenshot ]', () => this.clearScreenshot()); + const y2 = controls2Anchor.y; + this.addButton(cx - 300, y2, '[ Take Screenshot ]', () => this.takeScreenshot()); + this.addButton(cx - 100, y2, '[ Clear Screenshot ]', () => this.clearScreenshot()); // ── State text ──────────────────────────────────────────── - y += 40; try { - this.stateText = createHudText(this, cx, y, this.stateString(), '#ffffff', { fontSize: '18px' }).setOrigin(0.5); + this.stateText = createHudText(this, cx, stateAnchor.y, this.stateString(), '#ffffff', { fontSize: '18px' }).setOrigin(0.5); } catch (e) { - this.stateText = this.addLabel(cx, y, this.stateString(), { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5); + this.stateText = this.addLabel(cx, stateAnchor.y, this.stateString(), { fontSize: '18px', color: '#ffffff' }).setOrigin(0.5); } - y += 30; - this.backendText = createHudText(this, cx, y, 'Storage: checking...', '#888888', { fontSize: '12px' }); + this.backendText = createHudText(this, cx, backendAnchor.y, 'Storage: checking...', '#888888', { fontSize: '12px' }); this.backendText.setOrigin(0.5); const backendName = await this.store.getBackendName(); @@ -192,9 +231,8 @@ export class GymSaveLoadScene extends GymSceneBase { } // ── Event log (reduced lines to leave room for screenshot) ─ - y += 20; if (this.sys && this.sys.isActive && this.sys.isActive()) { - this.eventLogResult = createEventLog(this, y + 20, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Event Log ──', maxLines: 8, lineHeight: 17, diff --git a/example-games/gym/scenes/GymSllScene.ts b/example-games/gym/scenes/GymSllScene.ts index eee4dc58..dea157ec 100644 --- a/example-games/gym/scenes/GymSllScene.ts +++ b/example-games/gym/scenes/GymSllScene.ts @@ -184,20 +184,21 @@ export class GymSllScene extends GymSceneBase { this.initHelp([ { - heading: 'Overview', - body: - 'This scene demonstrates SLL directly and in composed form. It validates and parses layout JSON, maps zones/anchors to pixels, and positions UI using composeResolvedLayouts + normalizedToPixels.', + heading: 'Features', + body: 'Demonstrates the Screen Layout Language (SLL) system for declarative UI positioning. Includes layout validation/parsing (validateScreenLayoutDocument, parseScreenLayoutDocument), pixel resolution (normalizedToPixels), composition (composeResolvedLayouts for merging shell + scene layouts), and the VisibilityOwnershipController for showing/hiding objects based on layout mode (shell-only, scene-only, composed). In a real card game, SLL lets designers define responsive layouts that adapt to different screen sizes and orientations without writing position code.' }, { heading: 'Controls', - body: - '[ Profile ] cycles through layout examples: composed shell + scene, shell-only, scene-only, and pixel override. [ Toggle Shell ] hides or restores the shared shell chrome without changing the selected layout. [ Overlay ] toggles element position markers and legend.', + body: '[ Profile ]: Cycle through layout examples — Composed Shell + Scene, Shell-only, Scene-only, and Pixel Override. Each profile repositions all demo objects according to the layout JSON.\n[ Overlay: OFF/ON ]: Toggle element position markers and legend overlay showing pixel coordinates of each placed element.\n[ Toggle Shell: ON/OFF ]: Show or hide the shared shell chrome (title, help button, profile button) without changing the selected layout. Demonstrates VisibilityOwnershipController group rules.\n[ Toggle Fill ]: Toggle the content panel fill colour between normal and highlighted state.' }, { - heading: 'Notes', - body: - 'The shell-only example uses the shared shell layout by itself. The composed sample uses a shared shell layout plus a scene layout. The overlay shows merged zones and anchors so collision handling and namespacing are easy to inspect.', + heading: 'Usage Example', + body: 'A card game needs to support both landscape desktop (1280x720) and portrait tablet (720x1280) layouts. Using SLL, the designer defines zones and anchors in normalized coordinates, and the engine resolves them to pixel positions at runtime. The composed layout merges a shared shell (title bar, help button) with scene-specific content, so the shell stays consistent across all game scenes while the scene content adapts independently.' }, + { + heading: 'Test Plan', + body: '1. Press [ Profile ] to cycle through layouts — verify title, help button, action button, and content panel reposition correctly for each profile\n2. With composed profile active, press [ Toggle Shell: OFF ] → shell elements (title, profile button, overlay button) hide\n3. Press [ Toggle Shell: ON ] → shell elements reappear\n4. Press [ Overlay: OFF/ON ] → marker dots and legend panel appear/disappear\n5. Press [ Toggle Fill ] → content panel colour changes between states\n6. Cycle back to each profile and verify element positions update correctly\n7. Verify no overlapping or off-screen placement across all profiles' + } ]); this.bootstrapLayouts(); diff --git a/example-games/gym/scenes/GymTooltipScene.ts b/example-games/gym/scenes/GymTooltipScene.ts index c5b242f4..f204b69c 100644 --- a/example-games/gym/scenes/GymTooltipScene.ts +++ b/example-games/gym/scenes/GymTooltipScene.ts @@ -7,6 +7,10 @@ * - Live tooltip positioning relative to interactive objects * - Toggle between modes at runtime * + * Layout is managed declaratively via the Screen Layout Language (SLL). + * Zone anchors define primary positions; secondary positions are derived + * relative to those anchors. + * * @module example-games/gym/scenes/GymTooltipScene */ @@ -14,6 +18,33 @@ import { GymSceneBase } from './GymSceneBase'; import { GYM_TOOLTIP_KEY } from '../GymRegistry'; import { GAME_W, GAME_H, FONT_FAMILY, TooltipManager } from '../../../src/ui'; import { createHudText } from '../../../src/ui/Renderer'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymTooltipLayoutJson from '../layouts/gym-tooltip.layout.json'; + +// Parse the shared Tooltip scene layout once at module load. +const TOOLTIP_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymTooltipLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +/** + * Resolve a primary anchor position from the SLL layout. + * Falls back to the default viewport if no layout is available. + */ +function resolveAnchor( + zone: string, + anchor: string, + viewport?: { width: number; height: number }, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!TOOLTIP_LAYOUT) { + return { x: 0, y: 0 }; + } + const vp = viewport ?? DEFAULT_VIEWPORT; + return anchorPoint(TOOLTIP_LAYOUT, zone, anchor, vp, 1); +} export class GymTooltipScene extends GymSceneBase { private domTooltipManager!: TooltipManager; @@ -39,38 +70,47 @@ export class GymTooltipScene extends GymSceneBase { this.initHelp([ { - heading: 'Overview', - body: 'Demonstrates the shared TooltipManager in both DOM-overlay mode and Phaser GameObject mode. Hover over demo cards to see tooltips.', + heading: 'Features', + body: 'Demonstrates the shared TooltipManager component in two rendering modes: DOM overlay (HTML div) and Phaser GameObject (in-scene containers). Tooltips provide contextual information when hovering over interactive elements, such as card ability descriptions, rule explanations, or score previews. In a real card game, tooltips let players quickly understand card effects without cluttering the main UI.' }, { heading: 'Controls', - body: '[ DOM Mode ] / [ Phaser Mode ]: Switch tooltip rendering mode.\n[ Show Demo Tooltip ]: Force-show a sample tooltip.\n[ Hide Tooltip ]: Hide any visible tooltip.\nHover over the coloured cards below to trigger contextual tooltips.', + body: '[ DOM Mode ]: Switch tooltip rendering to DOM overlay mode (HTML div on top of canvas).\n[ Phaser Mode ]: Switch tooltip rendering to Phaser GameObject mode (in-scene containers with custom styling).\n[ Show Demo ]: Force-show a demo tooltip at the centre of the screen.\n[ Hide ]: Hide any currently visible tooltip.\nHover over coloured cards (Red, Blue, Green) below the controls to see contextual tooltips describing each card\'s ability. Move the pointer within a card to follow the tooltip position.' + }, + { + heading: 'Usage Example', + body: 'In a card game like Lost Cities, hovering over a card in your hand shows its value, colour, and any special abilities in a tooltip. DOM mode works well for simple text tooltips above the canvas, while Phaser mode allows fully styled in-game tooltips with borders, backgrounds, and animations that integrate with the game world.' }, + { + heading: 'Test Plan', + body: '1. Press [ DOM Mode ] → mode label updates to "Mode: DOM overlay"\n2. Hover over the red card → tooltip appears showing "Red Card" description\n3. Move pointer within the card → tooltip follows the pointer position\n4. Move pointer away from card → tooltip disappears\n5. Press [ Phaser Mode ] → mode label updates\n6. Hover over the blue card → Phaser GameObject tooltip appears with border styling\n7. Press [ Show Demo ] → demo tooltip appears at centre\n8. Press [ Hide ] → tooltip disappears' + } ]); - const cx = GAME_W / 2; - let y = 60; + const headerAnchor = resolveAnchor('header', 'center'); + const labelAnchor = resolveAnchor('label', 'center'); + const contentAnchor = resolveAnchor('content', 'center'); + const logAnchor = resolveAnchor('log', 'center'); - // Mode toggle buttons - this.addButton(cx - 180, y, '[ DOM Mode ]', () => this.setMode(true)); - this.addButton(cx + 20, y, '[ Phaser Mode ]', () => this.setMode(false)); - this.addButton(cx + 200, y, '[ Show Demo ]', () => this.showDemoTooltip()); - this.addButton(cx + 380, y, '[ Hide ]', () => this.hideTooltip()); + // Mode toggle buttons — centred horizontally on header anchor, offset ±180/20/200/380 + this.addButton(headerAnchor.x - 180, headerAnchor.y, '[ DOM Mode ]', () => this.setMode(true)); + this.addButton(headerAnchor.x + 20, headerAnchor.y, '[ Phaser Mode ]', () => this.setMode(false)); + this.addButton(headerAnchor.x + 200, headerAnchor.y, '[ Show Demo ]', () => this.showDemoTooltip()); + this.addButton(headerAnchor.x + 380, headerAnchor.y, '[ Hide ]', () => this.hideTooltip()); - y += 40; - const modeLabel = createHudText(this, cx, y, 'Mode: DOM overlay', '#88ccff', { fontSize: '16px' }); + // Mode label — at label anchor y + const modeLabel = createHudText(this, labelAnchor.x, labelAnchor.y, 'Mode: DOM overlay', '#88ccff', { fontSize: '16px' }); modeLabel.setOrigin(0.5); modeLabel.setName('modeLabel'); - y += 50; - createHudText(this, cx, y, '── Hover over the cards below ──', '#6699aa', { fontSize: '14px' }).setOrigin(0.5); + // Hover prompt — 50 px below label + createHudText(this, labelAnchor.x, labelAnchor.y + 50, '── Hover over the cards below ──', '#6699aa', { fontSize: '14px' }).setOrigin(0.5); - // Create interactive demo cards - y += 50; - this.createDemoCards(y); + // Create interactive demo cards — at content anchor y + this.createDemoCards(contentAnchor.y); - y += 180; - createHudText(this, cx, y, '── Event Log ──', '#6699aa', { fontSize: '12px' }).setOrigin(0.5); + // Event log header — at log anchor y + createHudText(this, logAnchor.x, logAnchor.y, '── Event Log ──', '#6699aa', { fontSize: '12px' }).setOrigin(0.5); // Create tooltip managers this.domTooltipManager = new TooltipManager(this); @@ -184,7 +224,9 @@ export class GymTooltipScene extends GymSceneBase { if (this.eventLog.length > 10) this.eventLog.shift(); for (const t of this.logTexts) t.destroy(); this.logTexts = []; - const baseY = 450; + const logAnchor = resolveAnchor('log', 'center'); + // Event log entries start 70 px below the log header anchor + const baseY = logAnchor.y + 70; for (let i = 0; i < this.eventLog.length; i++) { const txt = createHudText(this, GAME_W / 2, baseY + i * 17, this.eventLog[i], '#aabbcc', { fontSize: '11px' }).setOrigin(0.5); this.logTexts.push(txt); diff --git a/example-games/gym/scenes/GymTranscriptScene.ts b/example-games/gym/scenes/GymTranscriptScene.ts index 439abd1b..70c4b6f0 100644 --- a/example-games/gym/scenes/GymTranscriptScene.ts +++ b/example-games/gym/scenes/GymTranscriptScene.ts @@ -21,6 +21,28 @@ import { popTextOrIcon } from '../../../src/ui/popTextOrIcon'; import { GAME_W } from '../../../src/ui/constants'; import { createEventLog } from '../../../src/ui/GymSceneUtils'; import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymTranscriptLayoutJson from '../layouts/gym-transcript.layout.json'; + +// Parse the shared Transcript scene layout once at module load. +const TRANSCRIPT_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymTranscriptLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveTranscriptAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!TRANSCRIPT_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(TRANSCRIPT_LAYOUT, zone, anchor, viewport, 1); +} /** Simple event shape for this demo. */ interface DemoTranscriptEvent { @@ -83,12 +105,28 @@ export class GymTranscriptScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Shows transcript recording and deterministic event ordering. Use a fixed seed to produce stable transcripts for testing and debugging.' }, - { heading: 'Controls', body: '[ New Session ]: Start a fresh transcript session.\n[ Record Event ]: Append a new event to the transcript.\n[ Finalize ]: Mark the transcript finished.\n[ Playback ]: Sequentially replay recorded events.\n[ Show Transcript ]: Log transcript metadata and events.' } + { + heading: 'Features', + body: 'Demonstrates transcript recording using TranscriptRecorderBase for capturing and replaying game events in sequence. Transcripts provide a deterministic, replayable record of game actions — essential for debugging, automated testing, spectator replays, and fair-play auditing. In a real card game, a transcript records every draw, discard, shuffle, and score change so the full game session can be replayed step by step.' + }, + { + heading: 'Controls', + body: '[ New Session ]: Start a fresh transcript session with a fixed seed (42) for deterministic event ordering.\n[ Record Event ]: Append a randomly chosen event type (draw, discard, shuffle, score) to the transcript with an incrementing turn number.\n[ Finalize ]: Mark the transcript as complete and record the end timestamp.\n[ Playback ]: Sequentially replay all recorded events with a 600ms delay between each.\n[ Show Transcript ]: Log the full transcript metadata (version, game type, event count) and all recorded events with turn numbers.' + }, + { + heading: 'Usage Example', + body: 'A developer is debugging a scoring bug in a Golf game. They record a transcript of a complete round, then replay it step by step to verify that each score change corresponds to the correct game action. The deterministic seed ensures the same card draw order can be reproduced for consistent debugging.' + }, + { + heading: 'Test Plan', + body: '1. Press [ New Session ] → new transcript created, log confirms seed\n2. Press [ Record Event ] five times → five events recorded with various types\n3. Press [ Show Transcript ] → log shows version, game type, and all five events with turn numbers\n4. Press [ Finalize ] → transcript finalized with end timestamp\n5. Press [ Playback ] → events replay sequentially with 600ms delays\n6. Press [ New Session ] again → fresh session started, old transcript discarded\n7. Verify logs accumulate without exceeding the visible limit' + } ]); const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveTranscriptAnchor('controls', 'center'); + const logAnchor = resolveTranscriptAnchor('log', 'center'); + const y = controlsAnchor.y; this.addButton(cx - 400, y, '[ New Session ]', () => this.newSession()); this.addButton(cx - 240, y, '[ Record Event ]', () => this.recordEvent()); @@ -96,8 +134,7 @@ export class GymTranscriptScene extends GymSceneBase { this.addButton(cx + 80, y, '[ Playback ]', () => this.playTranscript()); this.addButton(cx + 200, y, '[ Show Transcript ]', () => this.showTranscript()); - y += 40; - this.eventLogResult = createEventLog(this, y + 20, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Event Log ──', maxLines: 16, lineHeight: 16, diff --git a/example-games/gym/scenes/GymUndoRedoScene.ts b/example-games/gym/scenes/GymUndoRedoScene.ts index dab9bdc0..456bcf47 100644 --- a/example-games/gym/scenes/GymUndoRedoScene.ts +++ b/example-games/gym/scenes/GymUndoRedoScene.ts @@ -20,6 +20,28 @@ import { GAME_W } from '../../../src/ui/constants'; import { createHudText, createStandardUndoRedoButtons } from '../../../src/ui/Renderer'; import { createEventLog } from '../../../src/ui/GymSceneUtils'; import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; +import { anchorPoint } from '../../../src/ui/screen-layout'; +import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; +import gymUndoRedoLayoutJson from '../layouts/gym-undo-redo.layout.json'; + +// Parse the shared Undo/Redo scene layout once at module load. +const UNDO_REDO_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { + const parsed = parseScreenLayoutDocument(gymUndoRedoLayoutJson); + return parsed.valid ? parsed.layout : null; +})(); + +const DEFAULT_VIEWPORT = { width: 1280, height: 720 }; + +function resolveUndoRedoAnchor( + zone: string, + anchor: string, + viewport = DEFAULT_VIEWPORT, +): import('../../../src/ui/screen-layout-schema').PixelPoint { + if (!UNDO_REDO_LAYOUT) { + return { x: GAME_W / 2, y: 60 }; + } + return anchorPoint(UNDO_REDO_LAYOUT, zone, anchor, viewport, 1); +} /** A simple command that increments/decrements a counter. */ class IncrementCommand implements Command { @@ -63,12 +85,31 @@ export class GymUndoRedoScene extends GymSceneBase { this.initReducedMotion(); this.initHelp([ - { heading: 'Overview', body: 'Demonstrates reversible actions and stack semantics using the UndoRedoManager. Useful to verify undo/redo boundaries and compound commands.' }, - { heading: 'Controls', body: '[ +1 ], [ +5 ], [ -3 ]: Execute simple increment/decrement actions.\n[ Compound (+2,+3) ]: Execute a grouped command.\nUndo / Redo (action buttons): Step backward/forward through action history.\n[ Clear History ]: Reset undo/redo stacks.' } + { + heading: 'Features', + body: 'Demonstrates the UndoRedoManager for reversible actions with stack semantics, including compound commands (grouped undo/redo) and boundary conditions. In a real card game, undo/redo lets a player reverse a mistaken move — for example, undoing a discard and returning the card to hand, or undoing a series of actions that were grouped as a single turn. Commands are pushed onto a stack, and new actions after an undo invalidate the redo stack.' + }, + { + heading: 'Controls', + body: '[ +1 ]: Execute an increment action that adds 1 to the counter. Recorded as a single undoable step.\n[ +5 ]: Execute an increment of 5.\n[ -3 ]: Execute a decrement of 3.\n[ Compound (+2,+3) ]: Execute two increment actions grouped as a single compound command, so undo reverses both at once.\nUndo / Redo (action buttons): Step backward or forward through action history. Disabled (dimmed) when no actions are available.\n[ Clear History ]: Reset all undo/redo stacks, clearing the action history.\nStatus lines: Show whether undo and redo are currently available, plus the full command history list.' + }, + { + heading: 'Usage Example', + body: 'A player in Golf mistakenly discards a valuable card. Pressing Undo reverses the discard, returning the card to hand. If the player then draws a new card, the redo stack is invalidated — they cannot redo the discarded action. Compound commands group an entire turn\'s actions (e.g., draw + discard + score) into a single undo step, letting the player reverse the whole turn at once.' + }, + { + heading: 'Test Plan', + body: '1. Press [ +1 ] four times → counter reaches 4, history shows "+1, +1, +1, +1"\n2. Press Undo → counter drops to 3, history shows "+1, +1, +1"\n3. Press Redo → counter returns to 4\n4. Press [ Compound (+2,+3) ] → counter jumps to 9, history shows a single "compound(+2,+3)" entry\n5. Press Undo → counter drops back to 4, both +2 and +3 undone at once\n6. Press Undo three more times → counter returns to 0\n7. Verify Undo and Redo buttons are dimmed when no actions available\n8. Press [ Clear History ] → history empties, counter stays at 0' + } ]); const cx = GAME_W / 2; - let y = 60; + const controlsAnchor = resolveUndoRedoAnchor('controls', 'center'); + const counterAnchor = resolveUndoRedoAnchor('counter', 'center'); + const statusAnchor = resolveUndoRedoAnchor('status', 'center'); + const historyAnchor = resolveUndoRedoAnchor('history', 'center'); + const logAnchor = resolveUndoRedoAnchor('log', 'center'); + const y = controlsAnchor.y; // Action buttons this.addButton(cx - 400, y, '[ +1 ]', () => this.executeAction(1)); @@ -84,22 +125,16 @@ export class GymUndoRedoScene extends GymSceneBase { this.addButton(cx + 40, y, '[ Clear History ]', () => this.clearHistory()); - y += 50; - // State display - this.counterText = createHudText(this, cx, y, 'Counter: 0', '#ffffff', { fontSize: '28px' }).setOrigin(0.5); - - y += 40; + this.counterText = createHudText(this, cx, counterAnchor.y, 'Counter: 0', '#ffffff', { fontSize: '28px' }).setOrigin(0.5); - this.undoAvailText = createHudText(this, cx - 120, y, 'Can Undo: no', '#888888', { fontSize: '14px' }); - this.redoAvailText = createHudText(this, cx + 80, y, 'Can Redo: no', '#888888', { fontSize: '14px' }); + this.undoAvailText = createHudText(this, cx - 120, statusAnchor.y, 'Can Undo: no', '#888888', { fontSize: '14px' }); + this.redoAvailText = createHudText(this, cx + 80, statusAnchor.y, 'Can Redo: no', '#888888', { fontSize: '14px' }); - y += 30; - this.historyText = createHudText(this, cx, y, 'History: (empty)', '#669966', { fontSize: '12px' }); + this.historyText = createHudText(this, cx, historyAnchor.y, 'History: (empty)', '#669966', { fontSize: '12px' }); this.historyText.setOrigin(0.5); - y += 20; - this.eventLogResult = createEventLog(this, y + 20, { + this.eventLogResult = createEventLog(this, logAnchor.y + 20, { headerText: '── Event Log ──', maxLines: 12, lineHeight: 17, diff --git a/example-games/lost-cities/LostCitiesTextureHelpers.ts b/example-games/lost-cities/LostCitiesTextureHelpers.ts index e7d4435c..803aebb0 100644 --- a/example-games/lost-cities/LostCitiesTextureHelpers.ts +++ b/example-games/lost-cities/LostCitiesTextureHelpers.ts @@ -2,7 +2,7 @@ * LostCitiesTextureHelpers — Inline SVG texture helpers for Lost Cities. * * Provides lazy rasterisation of card SVG assets via SvgHelpers, - * following the migration pattern established by The Mind + * following the SVG migration pattern established by earlier games * (CG-0MP12H40Q003Y7OU). * * Migration notes (CG-0MOZN33JW004XILY): diff --git a/example-games/lost-cities/scenes/LostCitiesRenderer.ts b/example-games/lost-cities/scenes/LostCitiesRenderer.ts index 51f54394..925df04c 100644 --- a/example-games/lost-cities/scenes/LostCitiesRenderer.ts +++ b/example-games/lost-cities/scenes/LostCitiesRenderer.ts @@ -697,7 +697,7 @@ export class LostCitiesRenderer { baseTop: number, ): void => { // Wire the pile model via adapter for future unified texture resolution. - // Manual setTexture is used currently (The Mind pattern); the adapter + // Manual setTexture is used currently (legacy pattern); the adapter // enables a later migration to PileView.update() with cardTextureFn. pv.setPile(new LcArrayPileAdapter(cards)); @@ -773,8 +773,13 @@ export class LostCitiesRenderer { if (!discardView) continue; if (pile.length === 0) { - discardView.setPile(new DiscardPileAdapter([])); - discardView.update(); + // Hide the sprite and count text — discard hit areas (createDiscardZones) + // handle all interaction, so the ghosted card-back is unnecessary visual + // clutter. Using PileView.update() for empty piles would show a + // full-size card-back (95×130) instead of the compact discard size + // (57×78), appearing oversized and shifting layout when cards arrive. + discardView.getSprite().setVisible(false); + discardView.getCountText().setVisible(false); continue; } diff --git a/example-games/main-street/MainStreetAdjacency.ts b/example-games/main-street/MainStreetAdjacency.ts index 630a9502..9b78cf41 100644 --- a/example-games/main-street/MainStreetAdjacency.ts +++ b/example-games/main-street/MainStreetAdjacency.ts @@ -10,7 +10,7 @@ */ import type { BusinessCard, CommunitySpaceCard, SynergyType } from './MainStreetCards'; -import { GRID_SIZE, SYNERGY_BONUS_PER_NEIGHBOR, isPawnShopCard } from './MainStreetCards'; +import { GRID_SIZE } from './MainStreetCards'; import type { MainStreetState } from './MainStreetState'; import { addLog, syncResourceBankToLedger } from './MainStreetState'; import { applyReputationMultiplier } from './MainStreetDifficulty'; @@ -61,34 +61,53 @@ export function neighbors(index: number, range: number = 1): number[] { } /** - * Computes the synergy bonus for a single business at a given slot. + * Resolves the effective per-neighbor coin synergy contribution for a card. + * Returns the card's `synergyCoinBonus` if set, otherwise 1 (the default). + */ +function effectiveSynergyCoinBonus(card: BusinessCard | CommunitySpaceCard): number { + return card.synergyCoinBonus ?? 1; +} + +/** + * Resolves the effective per-neighbor reputation synergy contribution for a card. + * Returns the card's `synergyRepBonus` if set, otherwise 0 (the default). + */ +function effectiveSynergyRepBonus(card: BusinessCard | CommunitySpaceCard): number { + return card.synergyRepBonus ?? 0; +} + +/** + * Computes the synergy coin bonus for a single business at a given slot. + * + * A business earns coins for each neighboring slot that contains a business + * sharing at least one SynergyType. The contribution from each neighbor is + * the neighbor's `synergyCoinBonus` (default 1) multiplied by `bonusPerNeighbor` + * (the difficulty preset multiplier). * - * A business earns +bonusPerNeighbor coins for each neighboring - * slot that contains a business sharing at least one SynergyType. * The range considered is 1 + business.synergyRangeBonus (from upgrades). * - * Pawn Shop cards are excluded entirely from synergy — they neither - * receive nor contribute synergy bonuses. This special case will be - * removed once synergy bonuses are generalized to per-card values - * (see CG-0MQRA9QTA0012PNZ). + * Cards with zero synergyCoinBonus naturally don't contribute synergy + * to their neighbors, acting as synergy-neutral cards. * * @param grid The street grid. * @param index The slot index of the business. - * @param bonusPerNeighbor Coins per matching neighbor (defaults to SYNERGY_BONUS_PER_NEIGHBOR for backward compat). - * @returns The synergy bonus in coins. + * @param bonusPerNeighbor Global multiplier on per-card coin synergy (defaults to 1). + * @returns The synergy coin bonus. */ export function computeSynergyBonus( grid: (BusinessCard | CommunitySpaceCard | null)[], index: number, - bonusPerNeighbor: number = SYNERGY_BONUS_PER_NEIGHBOR, + bonusPerNeighbor: number = 1, ): number { const business = grid[index]; if (!business) return 0; - // Pawn Shop cards neither receive nor contribute synergy bonuses. - // This special case will be removed once synergy bonuses are generalized - // to per-card values (see CG-0MQRA9QTA0012PNZ). - if (isPawnShopCard(business)) return 0; + // A card with zero synergy coin AND zero synergy reputation does not + // participate in the synergy system at all: it neither contributes to + // nor receives synergy from neighbors. + if (effectiveSynergyCoinBonus(business) === 0 && effectiveSynergyRepBonus(business) === 0) { + return 0; + } const range = 1 + business.synergyRangeBonus; const neighborIndices = neighbors(index, range); @@ -98,15 +117,59 @@ export function computeSynergyBonus( const neighbor = grid[ni]; if (!neighbor) continue; - // Pawn Shop cards do not contribute to synergy bonuses - if (isPawnShopCard(neighbor)) continue; + // Check if any synergy type is shared + const hasSharedSynergy = business.synergyTypes.some( + (st: SynergyType) => neighbor.synergyTypes.includes(st), + ); + if (hasSharedSynergy) { + // Use the neighbor's per-card synergy coin bonus, multiplied by the global modifier + bonus += effectiveSynergyCoinBonus(neighbor) * bonusPerNeighbor; + } + } + + return bonus; +} + +/** + * Computes the synergy reputation bonus for a single business at a given slot. + * + * A business earns reputation for each neighboring slot that contains a business + * sharing at least one SynergyType. The contribution from each neighbor is + * the neighbor's `synergyRepBonus` (default 0). + * + * The range considered is 1 + business.synergyRangeBonus (from upgrades). + * + * @param grid The street grid. + * @param index The slot index of the business. + * @returns The synergy reputation bonus. + */ +export function computeSynergyRepBonus( + grid: (BusinessCard | CommunitySpaceCard | null)[], + index: number, +): number { + const business = grid[index]; + if (!business) return 0; + + // A card with zero synergy coin AND zero synergy reputation does not + // participate in the synergy system at all. + if (effectiveSynergyCoinBonus(business) === 0 && effectiveSynergyRepBonus(business) === 0) { + return 0; + } + + const range = 1 + business.synergyRangeBonus; + const neighborIndices = neighbors(index, range); + + let bonus = 0; + for (const ni of neighborIndices) { + const neighbor = grid[ni]; + if (!neighbor) continue; // Check if any synergy type is shared const hasSharedSynergy = business.synergyTypes.some( (st: SynergyType) => neighbor.synergyTypes.includes(st), ); if (hasSharedSynergy) { - bonus += bonusPerNeighbor; + bonus += effectiveSynergyRepBonus(neighbor); } } @@ -118,17 +181,17 @@ export function computeSynergyBonus( * * totalIncome = baseIncome + incomeBonus (from upgrades) + synergyBonus * - * Pawn Shop cards receive no synergy bonus (see computeSynergyBonus). + * @see computeSynergyBonus for details on per-card synergy values. * * @param grid The street grid. * @param index The slot index of the business. - * @param bonusPerNeighbor Coins per matching neighbor (defaults to SYNERGY_BONUS_PER_NEIGHBOR). + * @param bonusPerNeighbor Global multiplier on per-card coin synergy (defaults to 1). * @returns The total income in coins for this business. */ export function computeBusinessIncome( grid: (BusinessCard | CommunitySpaceCard | null)[], index: number, - bonusPerNeighbor: number = SYNERGY_BONUS_PER_NEIGHBOR, + bonusPerNeighbor: number = 1, ): number { const business = grid[index]; if (!business) return 0; @@ -142,8 +205,7 @@ export function computeBusinessIncome( * Computes the total synergy bonus contributed by hand cards to tableau businesses. * * Each hand card contributes Math.floor(card.baseIncome / 3) to each tableau - * business that shares at least one synergy type. Pawn Shop cards do not - * receive hand card synergy even when the types match. + * business that shares at least one synergy type. * * @param grid The street grid (tableau businesses). * @param hand Cards held in the player's hand. @@ -168,9 +230,10 @@ export function computeHandCardSynergyBonus( const business = grid[i]; if (!business) continue; - // Pawn Shop cards do not receive synergy from any source - if (isPawnShopCard(business)) continue; - + // A card with zero synergy values does not participate in synergy + if (effectiveSynergyCoinBonus(business) === 0 && effectiveSynergyRepBonus(business) === 0) { + continue; + } // Check if any of the hand card's synergy types match the business's types const hasMatch = handCard.synergyTypes.some( (st: SynergyType) => business.synergyTypes.includes(st), @@ -191,13 +254,13 @@ export function computeHandCardSynergyBonus( * Returns both the total and a per-slot breakdown for UI display. * * @param grid The street grid. - * @param bonusPerNeighbor Coins per matching neighbor (defaults to SYNERGY_BONUS_PER_NEIGHBOR). + * @param bonusPerNeighbor Global multiplier on per-card coin synergy (defaults to 1). * @param hand Optional: hand cards to include for synergy bonuses. * @returns Object with `total` income and `breakdown` per slot. */ export function computeIncome( grid: (BusinessCard | CommunitySpaceCard | null)[], - bonusPerNeighbor: number = SYNERGY_BONUS_PER_NEIGHBOR, + bonusPerNeighbor: number = 1, hand?: BusinessCard[], ): IncomeResult { const breakdown: SlotIncome[] = []; @@ -240,9 +303,7 @@ export function computeIncome( let perSlotHandSynergy = 0; for (const handCard of hand) { if (!handCard.synergyTypes || handCard.synergyTypes.length === 0) continue; - if (isPawnShopCard(business)) continue; - - const hasMatch = handCard.synergyTypes.some( + const hasMatch = handCard.synergyTypes.some( (st: SynergyType) => business.synergyTypes.includes(st), ); if (hasMatch) { @@ -269,6 +330,7 @@ export function computeIncome( * Each business/community-space card may contribute: * - Its base `reputationPerTurn` (from the card definition) * - Its accumulated `reputationBonus` (from applied upgrades) + * - Synergy reputation from matching neighbors via `synergyRepBonus` * * @param grid The street grid. * @returns Total reputation per turn. @@ -277,10 +339,13 @@ export function computeReputationPerTurn( grid: (BusinessCard | CommunitySpaceCard | null)[], ): number { let total = 0; - for (const slot of grid) { + for (let i = 0; i < grid.length; i++) { + const slot = grid[i]; if (!slot) continue; total += slot.reputationPerTurn ?? 0; total += slot.reputationBonus; + // Add synergy reputation from matching neighbors + total += computeSynergyRepBonus(grid, i); } return total; } @@ -330,9 +395,10 @@ export function applyIncome(state: MainStreetState): IncomeResult { syncResourceBankToLedger(state); if (multiplied > 0) { - addLog(state, `Income: +${multiplied} coins`, 'gain'); + // CG-0MREYZO7E00729S0: show 3 decimal places for fractional coin values + addLog(state, `Income: +${multiplied.toFixed(3)} coins`, 'gain'); } else { - addLog(state, `Income: +0 coins`, 'neutral'); + addLog(state, `Income: +0.000 coins`, 'neutral'); } if (repPerTurn > 0) { addLog(state, `Reputation from cards: +${repPerTurn}`, 'gain'); @@ -363,8 +429,7 @@ export interface SynergyPair { * * A pair exists when two occupied slots share at least one SynergyType and * are within Manhattan distance range (1 + card's synergyRangeBonus). Each pair - * is reported only once (fromIndex < toIndex). Pawn Shop cards are excluded - * entirely — they neither contribute nor receive synergy connections. + * is reported only once (fromIndex < toIndex). * * Community-space cards are included in the same manner as business cards. * @@ -380,7 +445,11 @@ export function computeSynergyPairs( for (let i = 0; i < grid.length; i++) { const card = grid[i]; if (!card) continue; - if (isPawnShopCard(card)) continue; + + // A card with zero synergy values does not participate in synergy + if (effectiveSynergyCoinBonus(card) === 0 && effectiveSynergyRepBonus(card) === 0) { + continue; + } const range = 1 + card.synergyRangeBonus; const neighborIndices = neighbors(i, range); @@ -389,7 +458,14 @@ export function computeSynergyPairs( if (ni <= i) continue; // avoid duplicates and self-pairs const neighbor = grid[ni]; if (!neighbor) continue; - if (isPawnShopCard(neighbor)) continue; + + // Neither card participates in synergy (both zero-synergy) + if (effectiveSynergyCoinBonus(card) === 0 && effectiveSynergyRepBonus(card) === 0) { + continue; + } + if (effectiveSynergyCoinBonus(neighbor) === 0 && effectiveSynergyRepBonus(neighbor) === 0) { + continue; + } // Find the first shared synergy type const shared = card.synergyTypes.find( diff --git a/example-games/main-street/MainStreetCards.ts b/example-games/main-street/MainStreetCards.ts index 1ae44dbb..13b31f52 100644 --- a/example-games/main-street/MainStreetCards.ts +++ b/example-games/main-street/MainStreetCards.ts @@ -1,16 +1,24 @@ /** * Main Street: Card Type Definitions and Fixture Data * - * Defines the three card families (Business, Event, Upgrade), synergy types, - * and the full card pool for Milestones 1 and 2. + * Defines the five card families (Business, Community Space, Event, Upgrade, + * Staff), synergy types, game constants, and the full card pool for + * Milestones 1–3. * - * M1 pool: 5 Business, 5 Event, 3 Upgrade templates. - * M2 additions: +12 Business (including multi-synergy bridge cards and - * two new synergy types: Service, Entertainment), +12 Event, +14 Upgrade. + * Card template data is loaded from a single CSV file (`card-data.csv`) + * at module load time. The CSV is bundled at build time via Vite's `?raw` + * import suffix. Only the fixture data is externalised — type definitions, + * constants, and helper functions remain in this module. * * @module */ +// ── CSV import & parsing ──────────────────────────────────── + +import cardDataRaw from './card-data.csv?raw'; +import { parseCsv } from '@core-engine/CsvLoader'; +const csvRows = parseCsv(cardDataRaw); + // ── Synergy & Phase Enums ─────────────────────────────────── /** Synergy types used by Business cards for adjacency bonuses. */ @@ -57,6 +65,17 @@ export interface BusinessCard { * Fractional values are supported (e.g. 0.2 for the Clinic). */ reputationPerTurn?: number; + /** + * Coin synergy contribution per matching neighbor. + * Defaults to 1 (the standard +1 coin per matching adjacency) when undefined. + * Set to 0 to exclude this card from contributing synergy to neighbors. + */ + readonly synergyCoinBonus?: number; + /** + * Reputation synergy contribution per matching neighbor. + * Defaults to 0 (no reputation from adjacency synergy) when undefined. + */ + readonly synergyRepBonus?: number; /** * IDs of upgrade cards that have been applied to this business instance, * in application order. Used to enforce multi-level chain requirements and @@ -222,7 +241,15 @@ export const REFRESH_INVESTMENTS_COST = 2; /** Fixed coin cost to refresh the development row (discover new opportunities). */ export const REFRESH_DEVELOPMENT_COST = 2; -/** Coins earned per adjacent business sharing a synergy type. */ +/** + * @deprecated Per-card synergy bonus values replace this global constant. + * Each BusinessCard and CommunitySpaceCard now has its own `synergyCoinBonus` + * (default 1) and `synergyRepBonus` (default 0). The difficulty preset + * `synergyBonusPerNeighbor` value still acts as a multiplier on per-card + * coin synergy contributions. + * + * Kept for backward compatibility with existing test code. + */ export const SYNERGY_BONUS_PER_NEIGHBOR = 1; /** Multiplier applied to reputation in final score. */ @@ -257,6 +284,24 @@ function makeBusiness(template: Omit): CommunitySpaceCard { + return { + family: 'community-space', + level: 0, + incomeBonus: 0, + synergyRangeBonus: 0, + reputationBonus: 0, + appliedUpgrades: [], + ...template, + }; +} + +// ── Community Space Interface ─────────────────────────────── + /** * A Community Space card placed on the street grid, parallel to BusinessCard. * Community spaces share the same mechanical behavior as businesses (grid placement, @@ -289,6 +334,17 @@ export interface CommunitySpaceCard { * Fractional values are supported (e.g. 0.2). */ reputationPerTurn?: number; + /** + * Coin synergy contribution per matching neighbor. + * Defaults to 1 (the standard +1 coin per matching adjacency) when undefined. + * Set to 0 to exclude this card from contributing synergy to neighbors. + */ + readonly synergyCoinBonus?: number; + /** + * Reputation synergy contribution per matching neighbor. + * Defaults to 0 (no reputation from adjacency synergy) when undefined. + */ + readonly synergyRepBonus?: number; /** * IDs of upgrade cards that have been applied to this community space instance, * in application order. @@ -298,1016 +354,105 @@ export interface CommunitySpaceCard { appliedUpgrades?: string[]; } -/** - * Creates a fresh copy of a CommunitySpaceCard from template data. - * Mutable fields (level, incomeBonus, synergyRangeBonus, appliedUpgrades) are reset. - */ -function makeCommunitySpace(template: Omit): CommunitySpaceCard { - return { - family: 'community-space', - level: 0, - incomeBonus: 0, - synergyRangeBonus: 0, - reputationBonus: 0, - appliedUpgrades: [], - ...template, - }; -} +// ── CSV → typed template arrays ───────────────────────────── + +/** All Business card templates parsed from the CSV. */ +const BUSINESS_TEMPLATES: Omit[] = + csvRows + .filter(r => r.family === 'business') + .map(r => ({ + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + baseIncome: Number(r.baseIncome) || 0, + synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], + upgradePath: r.upgradePath || undefined, + maxLevel: Number(r.maxLevel) || 0, + reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, + synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, + synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, + description: r.description, + })); + +/** All Community Space card templates parsed from the CSV. */ +const COMMUNITY_SPACE_TEMPLATES: Omit[] = + csvRows + .filter(r => r.family === 'community-space') + .map(r => ({ + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + baseIncome: Number(r.baseIncome) || 0, + synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], + upgradePath: r.upgradePath || undefined, + maxLevel: Number(r.maxLevel) || 0, + reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, + synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, + synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, + description: r.description, + })); + +/** All Event card templates parsed from the CSV. */ +const EVENT_TEMPLATES: EventCard[] = + csvRows + .filter(r => r.family === 'event') + .map(r => { + const base: EventCard = { + family: 'event', + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + trigger: r.trigger as EventTrigger, + effect: r.effect, + target: r.target as EventTarget, + targetSynergy: (r.targetSynergy || undefined) as SynergyType | undefined, + coinDelta: Number(r.coinDelta) || 0, + reputationDelta: Number(r.reputationDelta) || 0, + }; + // Duration events carry extra fields — cast to DurationEventCard if present + if (r.duration) { + return { + ...base, + duration: Number(r.duration), + effectType: r.effectType, + multiplier: Number(r.multiplier) || 0, + } as DurationEventCard; + } + return base; + }); + +/** All Upgrade card templates parsed from the CSV. */ +const UPGRADE_TEMPLATES: UpgradeCard[] = + csvRows + .filter(r => r.family === 'upgrade') + .map(r => ({ + family: 'upgrade', + id: r.id, + name: r.name, + targetBusiness: r.targetBusiness, + cost: Number(r.cost) || 0, + incomeBonus: Number(r.incomeBonus) || 0, + synergyRangeBonus: Number(r.synergyRangeBonus) || 0, + description: r.description, + requiredLevel: r.requiredLevel ? Number(r.requiredLevel) : undefined, + reputationBonus: r.reputationBonus ? Number(r.reputationBonus) : undefined, + })); + +/** All Staff card templates parsed from the CSV. */ +export const STAFF_CARD_TEMPLATES: StaffCard[] = + csvRows + .filter(r => r.family === 'staff') + .map(r => ({ + family: 'staff', + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + ongoingCost: Number(r.ongoingCost) || 0, + handSlotsAdded: Number(r.handSlotsAdded) || 0, + description: r.description, + })); -/** Template data for all Business cards (M1 + M2 pool). */ -const BUSINESS_TEMPLATES: Omit[] = [ - { - id: 'biz-bakery', - name: 'Bakery', - cost: 6, - baseIncome: 1, - synergyTypes: ['Food'], - upgradePath: 'Bakery', - maxLevel: 2, - description: 'Provides warm pastries. Gains +1 coin for each adjacent Food business.', - }, - { - id: 'biz-diner', - name: 'Diner', - cost: 8, - baseIncome: 1, - synergyTypes: ['Food'], - upgradePath: 'Diner', - maxLevel: 2, - description: 'Serves quick meals. Gains +1 coin per adjacent Food business.', - }, - { - id: 'biz-bookshop', - name: 'Bookshop', - cost: 8, - baseIncome: 1, - synergyTypes: ['Culture'], - upgradePath: 'Bookshop', - maxLevel: 1, - description: 'Sells books. Gains +1 coin per adjacent Culture business.', - }, - { - id: 'biz-hardware', - name: 'Hardware Store', - cost: 10, - baseIncome: 1, - synergyTypes: ['Commerce'], - upgradePath: 'Hardware Store', - maxLevel: 1, - description: 'Supplies tools. Gains +1 coin per adjacent Commerce business.', - }, - // ── M2 Expanded Business Templates ────────────────────────── - // Commerce (fills the gap: M1 had only 1 Commerce business) - { - id: 'biz-pawnshop', - name: 'Pawn Shop', - cost: 6, - baseIncome: 1, - synergyTypes: ['Commerce'], - upgradePath: 'Pawn Shop', - maxLevel: 1, - description: 'Trades second-hand goods. Does not provide or receive synergy bonuses.', - }, - { - id: 'biz-boutique', - name: 'Boutique', - cost: 8, - baseIncome: 1, - synergyTypes: ['Commerce'], - upgradePath: 'Boutique', - maxLevel: 1, - description: 'Sells curated fashion. Gains +1 coin per adjacent Commerce business.', - }, - // Service (new synergy type) - { - id: 'biz-laundromat', - name: 'Laundromat', - cost: 6, - baseIncome: 1, - synergyTypes: ['Service'], - upgradePath: 'Laundromat', - maxLevel: 1, - description: 'Provides self-serve laundry. Gains +1 coin per adjacent Service business.', - }, - { - id: 'biz-barbershop', - name: 'Barbershop', - cost: 6, - baseIncome: 1, - synergyTypes: ['Service'], - upgradePath: 'Barbershop', - maxLevel: 1, - description: 'Classic cuts and conversation. Gains +1 coin per adjacent Service business.', - }, - // Entertainment (new synergy type) - { - id: 'biz-arcade', - name: 'Arcade', - cost: 8, - baseIncome: 1, - synergyTypes: ['Entertainment'], - upgradePath: 'Arcade', - maxLevel: 1, - description: 'Retro fun for all ages. Gains +1 coin per adjacent Entertainment business.', - }, - { - id: 'biz-cinema', - name: 'Cinema', - cost: 10, - baseIncome: 1, - synergyTypes: ['Entertainment'], - upgradePath: 'Cinema', - maxLevel: 2, - description: 'Shows the latest films. Gains +1 coin per adjacent Entertainment business.', - }, - // Multi-synergy bridge cards (belong to two synergy types) - { - id: 'biz-cafe', - name: 'Cafe', - cost: 6, - baseIncome: 1, - synergyTypes: ['Food', 'Culture'], - upgradePath: 'Cafe', - maxLevel: 1, - description: 'Coffee and conversation. Bridges Food and Culture synergies.', - }, - { - id: 'biz-food-truck', - name: 'Food Truck', - cost: 4, - baseIncome: 0, - synergyTypes: ['Food', 'Entertainment'], - upgradePath: 'Food Truck', - maxLevel: 1, - description: 'Street eats with flair. Bridges Food and Entertainment synergies.', - }, - { - id: 'biz-gallery', - name: 'Art Gallery', - cost: 8, - baseIncome: 1, - synergyTypes: ['Culture', 'Entertainment'], - upgradePath: 'Art Gallery', - maxLevel: 1, - description: 'Showcases local artists. Bridges Culture and Entertainment synergies.', - }, - { - id: 'biz-spa', - name: 'Day Spa', - cost: 10, - baseIncome: 1, - synergyTypes: ['Service', 'Entertainment'], - upgradePath: 'Day Spa', - maxLevel: 2, - description: 'Relaxation and pampering. Bridges Service and Entertainment synergies.', - }, - // Additional variety - { - id: 'biz-florist', - name: 'Florist', - cost: 4, - baseIncome: 0, - synergyTypes: ['Commerce', 'Culture'], - upgradePath: 'Florist', - maxLevel: 1, - description: 'Beautiful arrangements for every occasion. Bridges Commerce and Culture synergies.', - }, - { - id: 'biz-clinic', - name: 'Clinic', - cost: 10, - baseIncome: 0, - synergyTypes: ['Health'], - upgradePath: 'Clinic', - maxLevel: 1, - reputationPerTurn: 0.2, - description: 'Walk-in medical care for the community. Provides +0.2 reputation per turn. Gains +1 coin per adjacent Health business.', - }, - { - id: 'biz-private-clinic', - name: 'Private Clinic', - cost: 8, - baseIncome: 2, - synergyTypes: ['Health'], - upgradePath: 'Private Clinic', - maxLevel: 1, - description: 'A private medical practice focused on profitability. Gains +1 coin per adjacent Health business.', - }, - { - id: 'biz-pharmacy', - name: 'Pharmacy', - cost: 6, - baseIncome: 1, - synergyTypes: ['Health'], - maxLevel: 0, - description: 'Provides essential medications. Gains +1 coin per adjacent Health business.', - }, -]; - -/** Template data for all Community Space cards (reclassified Park + new community spaces). */ -const COMMUNITY_SPACE_TEMPLATES: Omit[] = [ - { - id: 'cs-park', - name: 'Park', - cost: 4, - baseIncome: 0, - synergyTypes: ['Culture'], - upgradePath: 'Park', - maxLevel: 1, - description: 'Offers leisure space. Gains +1 coin per adjacent Culture business or community space.', - }, - { - id: 'cs-library', - name: 'Library', - cost: 6, - baseIncome: 1, - synergyTypes: ['Culture'], - upgradePath: 'Library', - maxLevel: 1, - description: 'A quiet community space for reading and learning. Gains +1 coin per adjacent Culture business or community space.', - }, -]; - -/** Template data for all Event cards (M1 + M2 pool). */ -const EVENT_TEMPLATES: EventCard[] = [ - { - family: 'event', - id: 'evt-festival', - name: 'Local Festival', - trigger: 'Investment', - cost: 3, - effect: '+2 coins to all Culture businesses and +1 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Culture', - coinDelta: 2, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-rainy', - name: 'Rainy Day', - trigger: 'Incident', - cost: 0, - effect: '-1 coin to all Food businesses this turn.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: -1, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-tax', - name: 'Tax Audit', - trigger: 'Incident', - cost: 0, - effect: 'Lose 3 coins.', - target: 'All', - coinDelta: -3, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-award', - name: 'Community Award', - trigger: 'Incident', - cost: 0, - effect: 'Gain 2 reputation from community recognition.', - target: 'All', - coinDelta: 0, - reputationDelta: 2, - }, - { - family: 'event', - id: 'evt-inspection', - name: 'Health Inspection', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Food business and -1 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: -2, - reputationDelta: -1, - }, - // ── M2 Expanded Event Templates ───────────────────────────── - // Investment events (positive, purchased) - { - family: 'event', - id: 'evt-grand-opening', - name: 'Grand Opening Sale', - trigger: 'Investment', - cost: 2, - effect: '+3 coins from a Commerce promotion.', - target: 'SpecificSynergy', - targetSynergy: 'Commerce', - coinDelta: 3, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-wellness-fair', - name: 'Wellness Fair', - trigger: 'Investment', - cost: 3, - effect: '+2 coins per Service business and +1 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Service', - coinDelta: 2, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-block-party', - name: 'Block Party', - trigger: 'Investment', - cost: 4, - effect: '+2 coins per Entertainment business and +2 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Entertainment', - coinDelta: 2, - reputationDelta: 2, - }, - { - family: 'event', - id: 'evt-charity-drive', - name: 'Charity Drive', - trigger: 'Investment', - cost: 2, - effect: '+3 reputation from generous donations.', - target: 'All', - coinDelta: 0, - reputationDelta: 3, - }, - // Incident events (negative/disruptive, drawn automatically) - { - family: 'event', - id: 'evt-power-outage', - name: 'Power Outage', - trigger: 'Incident', - cost: 0, - effect: '-2 coins from lost business during the outage.', - target: 'All', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-shoplifting', - name: 'Shoplifting Spree', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Commerce business from theft losses.', - target: 'SpecificSynergy', - targetSynergy: 'Commerce', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-noise-complaint', - name: 'Noise Complaint', - trigger: 'Incident', - cost: 0, - effect: '-1 coin per Entertainment business and -1 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Entertainment', - coinDelta: -1, - reputationDelta: -1, - }, - { - family: 'event', - id: 'evt-pipe-burst', - name: 'Pipe Burst', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Service business from water damage.', - target: 'SpecificSynergy', - targetSynergy: 'Service', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-food-critic', - name: 'Food Critic Visit', - trigger: 'Incident', - cost: 0, - effect: '+1 coin per Food business and +1 reputation from a glowing review.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: 1, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-construction', - name: 'Road Construction', - trigger: 'Incident', - cost: 0, - effect: '-1 coin to all businesses from reduced foot traffic.', - target: 'All', - coinDelta: -1, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-viral-review', - name: 'Viral Review', - trigger: 'Incident', - cost: 0, - effect: '+2 coins and +1 reputation from sudden online fame.', - target: 'All', - coinDelta: 2, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-vandalism', - name: 'Vandalism', - trigger: 'Incident', - cost: 0, - effect: '-1 coin to all businesses and -1 reputation.', - target: 'All', - coinDelta: -1, - reputationDelta: -1, - }, - // ── Duration-based Event (M2 Tier 4) ──────────────────────── - { - family: 'event', - id: 'evt-flu-outbreak', - name: 'Flu Outbreak', - trigger: 'Incident', - cost: 0, - effect: 'All businesses generate 80% income for 5 turns. Duration reduced by Clinic/Medical Center.', - target: 'All', - coinDelta: 0, - reputationDelta: 0, - duration: 5, - effectType: 'income-multiplier', - multiplier: 0.8, - } as DurationEventCard, - // ── M3 Expanded Event Templates (doubled unique event count) ───── - // Investment events (positive, purchased) - { - family: 'event', - id: 'evt-harvest-festival', - name: 'Harvest Festival', - trigger: 'Investment', - cost: 3, - effect: '+2 coins to each Food business and +1 reputation from a bountiful harvest celebration.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: 2, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-health-campaign', - name: 'Health Campaign', - trigger: 'Investment', - cost: 3, - effect: '+1 coin to each Health business and +1 reputation from a wellness initiative.', - target: 'SpecificSynergy', - targetSynergy: 'Health', - coinDelta: 1, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-street-performer', - name: 'Street Performer', - trigger: 'Investment', - cost: 2, - effect: '+2 coins to each Entertainment business from a popular busker drawing crowds.', - target: 'SpecificSynergy', - targetSynergy: 'Entertainment', - coinDelta: 2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-bulk-purchase', - name: 'Bulk Purchase', - trigger: 'Investment', - cost: 3, - effect: '+1 coin to each Commerce business and +2 reputation from collective buying power.', - target: 'SpecificSynergy', - targetSynergy: 'Commerce', - coinDelta: 1, - reputationDelta: 2, - }, - { - family: 'event', - id: 'evt-book-fair', - name: 'Book Fair', - trigger: 'Investment', - cost: 3, - effect: '+1 coin to each Culture business and +2 reputation from literary events.', - target: 'SpecificSynergy', - targetSynergy: 'Culture', - coinDelta: 1, - reputationDelta: 2, - }, - { - family: 'event', - id: 'evt-volunteer-day', - name: 'Volunteer Day', - trigger: 'Investment', - cost: 2, - effect: '+1 coin to each Service business and +2 reputation from community volunteering.', - target: 'SpecificSynergy', - targetSynergy: 'Service', - coinDelta: 1, - reputationDelta: 2, - }, - { - family: 'event', - id: 'evt-community-garden', - name: 'Community Garden', - trigger: 'Investment', - cost: 2, - effect: '+1 coin and +1 reputation from a new community garden project.', - target: 'All', - coinDelta: 1, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-festival-season', - name: 'Festival Season', - trigger: 'Investment', - cost: 4, - effect: '+3 coins from increased tourist spending during festival season.', - target: 'All', - coinDelta: 3, - reputationDelta: 0, - }, - // Incident events (mixed positive/negative, drawn automatically) - { - family: 'event', - id: 'evt-protest', - name: 'Protest', - trigger: 'Incident', - cost: 0, - effect: '-2 coins from reduced foot traffic and -1 reputation from negative publicity.', - target: 'All', - coinDelta: -2, - reputationDelta: -1, - }, - { - family: 'event', - id: 'evt-supply-chain', - name: 'Supply Chain Delay', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Commerce business from delayed inventory.', - target: 'SpecificSynergy', - targetSynergy: 'Commerce', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-power-surge', - name: 'Power Surge', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Service business from equipment damage.', - target: 'SpecificSynergy', - targetSynergy: 'Service', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-strike', - name: 'Strike', - trigger: 'Incident', - cost: 0, - effect: '-2 coins from work stoppages affecting the street.', - target: 'All', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-heatwave', - name: 'Heatwave', - trigger: 'Incident', - cost: 0, - effect: '-1 coin per Food business from spoiled goods and -1 reputation.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: -1, - reputationDelta: -1, - }, - { - family: 'event', - id: 'evt-pest-infestation', - name: 'Pest Infestation', - trigger: 'Incident', - cost: 0, - effect: '-2 coins per Food business from health-related closures.', - target: 'SpecificSynergy', - targetSynergy: 'Food', - coinDelta: -2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-slow-season', - name: 'Slow Season', - trigger: 'Incident', - cost: 0, - effect: '-1 coin to all businesses from reduced customer traffic.', - target: 'All', - coinDelta: -1, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-good-press', - name: 'Good Press', - trigger: 'Incident', - cost: 0, - effect: '+1 reputation from a favourable news article about Main Street.', - target: 'All', - coinDelta: 0, - reputationDelta: 1, - }, - { - family: 'event', - id: 'evt-tourist-bus', - name: 'Tourist Bus', - trigger: 'Incident', - cost: 0, - effect: '+2 coins per Entertainment business from a tour bus dropping visitors.', - target: 'SpecificSynergy', - targetSynergy: 'Entertainment', - coinDelta: 2, - reputationDelta: 0, - }, - { - family: 'event', - id: 'evt-cultural-grant', - name: 'Cultural Grant', - trigger: 'Incident', - cost: 0, - effect: '+1 coin per Culture business and +1 reputation from a government arts grant.', - target: 'SpecificSynergy', - targetSynergy: 'Culture', - coinDelta: 1, - reputationDelta: 1, - }, -]; - -/** Template data for all Upgrade cards (M1 + M2 pool). */ -const UPGRADE_TEMPLATES: UpgradeCard[] = [ - // ── M1 Base Upgrades (requiredLevel: 0) ───────────────────── - { - family: 'upgrade', - id: 'upg-patisserie', - name: 'Upgrade to Patisserie', - targetBusiness: 'Bakery', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Turns a Bakery into a Patisserie, increasing income and synergy range.', - }, - { - family: 'upgrade', - id: 'upg-bistro', - name: 'Upgrade to Bistro', - targetBusiness: 'Diner', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Turns a Diner into a Bistro with higher foot-traffic.', - }, - { - family: 'upgrade', - id: 'upg-readers-cafe', - name: "Upgrade to Reader's Café", - targetBusiness: 'Bookshop', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 0, - reputationBonus: 0.1, - requiredLevel: 0, - description: 'Transforms the Bookshop into a Reader\'s Café, blending books with café culture for a reputation boost.', - }, - // ── M2 Expanded Upgrade Templates ─────────────────────────── - { - family: 'upgrade', - id: 'upg-garden', - name: 'Upgrade to Garden', - targetBusiness: 'Park', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Expands the Park into a Garden with extended cultural reach.', - }, - { - family: 'upgrade', - id: 'upg-home-improvement', - name: 'Upgrade to Home Improvement', - targetBusiness: 'Hardware Store', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Transforms the Hardware Store into a Home Improvement center.', - }, - { - family: 'upgrade', - id: 'upg-vintage-shop', - name: 'Upgrade to Vintage Shop', - targetBusiness: 'Pawn Shop', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Rebrands the Pawn Shop as a trendy Vintage Shop.', - }, - { - family: 'upgrade', - id: 'upg-designer-store', - name: 'Upgrade to Designer Store', - targetBusiness: 'Boutique', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Elevates the Boutique to a Designer Store with premium clientele.', - }, - { - family: 'upgrade', - id: 'upg-dry-cleaners', - name: 'Upgrade to Dry Cleaners', - targetBusiness: 'Laundromat', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Upgrades the Laundromat to a full-service Dry Cleaners.', - }, - { - family: 'upgrade', - id: 'upg-salon', - name: 'Upgrade to Salon', - targetBusiness: 'Barbershop', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Expands the Barbershop into a modern Salon.', - }, - { - family: 'upgrade', - id: 'upg-gaming-lounge', - name: 'Upgrade to Gaming Lounge', - targetBusiness: 'Arcade', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Transforms the Arcade into a state-of-the-art Gaming Lounge.', - }, - { - family: 'upgrade', - id: 'upg-imax', - name: 'Upgrade to IMAX Theater', - targetBusiness: 'Cinema', - cost: 5, - incomeBonus: 2, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Upgrades the Cinema to an IMAX Theater with premium experience.', - }, - { - family: 'upgrade', - id: 'upg-roastery', - name: 'Upgrade to Roastery', - targetBusiness: 'Cafe', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Turns the Cafe into a specialty Roastery with artisan appeal.', - }, - { - family: 'upgrade', - id: 'upg-gourmet-truck', - name: 'Upgrade to Gourmet Truck', - targetBusiness: 'Food Truck', - cost: 2, - incomeBonus: 1, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Elevates the Food Truck with gourmet offerings.', - }, - { - family: 'upgrade', - id: 'upg-museum', - name: 'Upgrade to Museum', - targetBusiness: 'Art Gallery', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Expands the Art Gallery into a full Museum.', - }, - { - family: 'upgrade', - id: 'upg-resort-spa', - name: 'Upgrade to Resort Spa', - targetBusiness: 'Day Spa', - cost: 5, - incomeBonus: 2, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Transforms the Day Spa into a luxurious Resort Spa.', - }, - { - family: 'upgrade', - id: 'upg-garden-center', - name: 'Upgrade to Garden Center', - targetBusiness: 'Florist', - cost: 3, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Expands the Florist into a full Garden Center.', - }, - { - family: 'upgrade', - id: 'upg-medical-center', - name: 'Upgrade to Medical Center', - targetBusiness: 'Clinic', - cost: 5, - incomeBonus: 0, - synergyRangeBonus: 1, - reputationBonus: 0.1, - requiredLevel: 0, - description: 'Upgrades the Clinic to a comprehensive Medical Center. Provides +0.1 reputation per turn.', - }, - { - family: 'upgrade', - id: 'upg-private-medical-center', - name: 'Upgrade to Private Medical Center', - targetBusiness: 'Private Clinic', - cost: 4, - incomeBonus: 2, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Expands the Private Clinic into a high-revenue Private Medical Center.', - }, - // ── Branching Upgrades (alternative level-0 paths) ────────── - // Bakery branches: Patisserie (above, food-artisan) vs Bread Factory (volume) - { - family: 'upgrade', - id: 'upg-bread-factory', - name: 'Upgrade to Bread Factory', - targetBusiness: 'Bakery', - cost: 3, - incomeBonus: 2, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Scales the Bakery into a high-volume Bread Factory. More income, no range boost.', - }, - // Diner branches: Bistro (above, quality) vs Fast Food (volume) - { - family: 'upgrade', - id: 'upg-fast-food', - name: 'Upgrade to Fast Food', - targetBusiness: 'Diner', - cost: 3, - incomeBonus: 2, - synergyRangeBonus: 0, - requiredLevel: 0, - description: 'Converts the Diner to a Fast Food outlet. Higher income, smaller synergy radius.', - }, - // Cinema branches: IMAX (above, premium) vs Drive-In (community) - { - family: 'upgrade', - id: 'upg-drive-in', - name: 'Upgrade to Drive-In Theater', - targetBusiness: 'Cinema', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 2, - requiredLevel: 0, - description: 'Turns the Cinema into a Drive-In Theater with a much wider community reach.', - }, - // Day Spa branches: Resort Spa (above, premium) vs Wellness Center (service-range) - { - family: 'upgrade', - id: 'upg-wellness-center', - name: 'Upgrade to Wellness Center', - targetBusiness: 'Day Spa', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 2, - requiredLevel: 0, - description: 'Expands the Day Spa into a Wellness Center with a broader service footprint.', - }, - // ── Multi-Level Upgrades (requiredLevel: 1) ────────────────── - // Level-2 upgrade for Bakery (after Patisserie or Bread Factory) - { - family: 'upgrade', - id: 'upg-grand-bakehouse', - name: 'Upgrade to Grand Bakehouse', - targetBusiness: 'Bakery', - cost: 5, - incomeBonus: 2, - synergyRangeBonus: 1, - requiredLevel: 1, - description: 'The pinnacle of baking craft — a Grand Bakehouse drawing visitors from afar.', - }, - // Level-2 upgrade for Diner (after Bistro or Fast Food) - { - family: 'upgrade', - id: 'upg-restaurant', - name: 'Upgrade to Restaurant', - targetBusiness: 'Diner', - cost: 5, - incomeBonus: 2, - synergyRangeBonus: 1, - requiredLevel: 1, - description: 'Elevates the Diner all the way to a full-service Restaurant.', - }, - // Level-2 upgrade for Cinema (after IMAX or Drive-In) - { - family: 'upgrade', - id: 'upg-multiplex', - name: 'Upgrade to Multiplex', - targetBusiness: 'Cinema', - cost: 6, - incomeBonus: 3, - synergyRangeBonus: 1, - requiredLevel: 1, - description: 'A massive Multiplex complex — the entertainment heart of Main Street.', - }, - // Level-2 upgrade for Day Spa (after Resort Spa or Wellness Center) - { - family: 'upgrade', - id: 'upg-luxury-retreat', - name: 'Upgrade to Luxury Retreat', - targetBusiness: 'Day Spa', - cost: 6, - incomeBonus: 3, - synergyRangeBonus: 1, - requiredLevel: 1, - description: 'A destination Luxury Retreat — the most prestigious business on the street.', - }, - // ── Community Space Upgrades ──────────────────────────────── - { - family: 'upgrade', - id: 'upg-community-hub', - name: 'Upgrade to Community Hub', - targetBusiness: 'Library', - cost: 4, - incomeBonus: 1, - synergyRangeBonus: 1, - requiredLevel: 0, - description: 'Expands the Library into a Community Hub with extended cultural reach.', - }, -]; - -// ── Staff Card Templates (Multi-Use Card Economy) ─────────── - -/** Template data for Staff cards. */ -export const STAFF_CARD_TEMPLATES: StaffCard[] = [ - { - family: 'staff', - id: 'staff-assistant', - name: 'Assistant', - cost: 3, - ongoingCost: 1, - handSlotsAdded: 1, - description: 'Hire an assistant to help manage your hand. Adds +1 hand slot with a small ongoing cost.', - }, - { - family: 'staff', - id: 'staff-manager', - name: 'Manager', - cost: 6, - ongoingCost: 2, - handSlotsAdded: 2, - description: 'A skilled manager keeps things organised. Adds +2 hand slots with a moderate ongoing cost.', - }, - { - family: 'staff', - id: 'staff-director', - name: 'Director', - cost: 10, - ongoingCost: 3, - handSlotsAdded: 3, - description: 'An experienced director oversees your operations. Adds +3 hand slots with a high ongoing cost.', - }, -]; +// ── Deck Building ─────────────────────────────────────────── /** * Creates the full Staff deck for a game. @@ -1325,8 +470,6 @@ export function createStaffDeck(copies: number = 1): StaffCard[] { return deck; } -// ── Deck Building ─────────────────────────────────────────── - /** * Creates the full Business deck for a game (each template repeated * `copies` times to ensure adequate supply for 20 turns). @@ -1380,14 +523,6 @@ export function createCommunitySpaceDeck( return deck; } -/** - * Creates the full Event deck for a game. - * - * @param copies Number of copies per template (default 3). - * @param unlockedCardIds Optional list of unlocked card IDs for tier filtering. - * When provided, only templates whose ID is in this list - * are included. When omitted, the full pool is used. - */ /** * Creates the full Event deck for a game. * @@ -1400,6 +535,7 @@ export function createCommunitySpaceDeck( * @param copies Number of copies per template (default 3). * @param unlockedCardIds Optional list of unlocked card IDs for tier filtering. * @param positiveIncidentMultiplier Multiplier applied to positive Incident templates (>=1). + * @param rng Seeded random function used for deterministic fractional distribution. */ export function createEventDeck( copies: number = 3, @@ -1527,24 +663,7 @@ export function cardLabel(card: AnyCard): string { } } -/** - * Determines if a card is a Pawn Shop card (biz-pawnshop). - * - * Pawn Shop cards neither receive nor contribute synergy bonuses. - * This holds true even after upgrading to Vintage Shop — the card's - * base synergy restriction remains. - * - * This special case should be removed once synergy bonuses are generalized - * to per-card values (see CG-0MQRA9QTA0012PNZ). - * - * @param card A card object with an `id` field. - * @returns true if the card's base template ID is `biz-pawnshop`. - */ -export function isPawnShopCard(card: { id: string } | null | undefined): boolean { - if (!card) return false; - const baseId = card.id.replace(/-\d+$/, ''); - return baseId === 'biz-pawnshop'; -} + // --------------------------------------------------------------------------- // Card template ID → display-name lookup @@ -1552,7 +671,7 @@ export function isPawnShopCard(card: { id: string } | null | undefined): boolean /** * Read-only map from card template ID (e.g. `'biz-cafe'`) to its display name - * (e.g. `'Cafe'`). Built once at module load from the private template arrays. + * (e.g. `'Cafe'`). Built once at module load from the CSV-derived template arrays. * * This is used by the meta-progression UI to show which cards a newly unlocked * tier adds to the player's card pool. @@ -1565,3 +684,24 @@ export const CARD_TEMPLATE_NAMES: ReadonlyMap = (() => { for (const t of UPGRADE_TEMPLATES) m.set(t.id, t.name); return m; })(); + +// --------------------------------------------------------------------------- +// Card template ID → tier mapping (from CSV tier column) +// --------------------------------------------------------------------------- + +/** + * Read-only map from card template ID (e.g. `'biz-cafe'`) to its tier number + * (as a numeric string, e.g. `'1'` through `'5'`). + * + * Built once at module load from the CSV `tier` column. + * Cards without a tier assignment (e.g. staff cards) are omitted from this map. + */ +export const CARD_TIER_MAP: ReadonlyMap = (() => { + const m = new Map(); + for (const row of csvRows) { + if (row.tier && row.tier.trim() !== '') { + m.set(row.id, row.tier.trim()); + } + } + return m; +})(); diff --git a/example-games/main-street/MainStreetDifficulty.ts b/example-games/main-street/MainStreetDifficulty.ts index 6c750120..0552510d 100644 --- a/example-games/main-street/MainStreetDifficulty.ts +++ b/example-games/main-street/MainStreetDifficulty.ts @@ -238,12 +238,17 @@ export function reputationCoinMultiplier( * * Only positive coin deltas are scaled -- negative deltas (penalties) * pass through unchanged so that reputation does not amplify losses. - * The result is rounded down (floored) to keep coin amounts integral. + * + * CG-0MRER3RE300418SG: Removed Math.floor to preserve fractional income + * values. The coins field is a JavaScript number (double) and can hold + * fractional values. All coin comparisons (<, >=, etc.) work correctly + * with fractional values. UI display rounds to 2 decimal places in the + * HUD tooltip. * * @param rawCoinDelta The base coin amount (positive = gain, negative = penalty). * @param reputation Current player reputation. * @param config Game config with multiplier tuning constants. - * @returns The adjusted coin delta. + * @returns The adjusted coin delta (may be fractional). */ export function applyReputationMultiplier( rawCoinDelta: number, @@ -251,5 +256,5 @@ export function applyReputationMultiplier( config: Pick, ): number { if (rawCoinDelta <= 0) return rawCoinDelta; - return Math.floor(rawCoinDelta * reputationCoinMultiplier(reputation, config)); + return rawCoinDelta * reputationCoinMultiplier(reputation, config); } diff --git a/example-games/main-street/MainStreetEngine.ts b/example-games/main-street/MainStreetEngine.ts index 6f16c6c4..7402acef 100644 --- a/example-games/main-street/MainStreetEngine.ts +++ b/example-games/main-street/MainStreetEngine.ts @@ -201,7 +201,7 @@ export function executeAction( */ function describeEventEffects(coinChange: number, repChange: number): string { const parts: string[] = []; - if (coinChange !== 0) parts.push(`${coinChange > 0 ? '+' : ''}${coinChange} coins`); + if (coinChange !== 0) parts.push(`${coinChange > 0 ? '+' : ''}${coinChange.toFixed(3)} coins`); if (repChange !== 0) parts.push(`${repChange > 0 ? '+' : ''}${repChange} rep`); return parts.length > 0 ? parts.join(', ') : 'no effect'; } @@ -446,7 +446,7 @@ export function checkImmediateLoss(state: MainStreetState): boolean { state.gameResult = 'loss'; state.endReason = 'bankruptcy'; updateScore(state); - addLog(state, `Game Over: Bankruptcy (coins: ${state.resourceBank.coins})`, 'loss'); + addLog(state, `Game Over: Bankruptcy (coins: ${state.resourceBank.coins.toFixed(3)})`, 'loss'); return true; } diff --git a/example-games/main-street/MainStreetTiers.ts b/example-games/main-street/MainStreetTiers.ts index 8e029c95..29bd6a6d 100644 --- a/example-games/main-street/MainStreetTiers.ts +++ b/example-games/main-street/MainStreetTiers.ts @@ -4,7 +4,14 @@ * Defines the 5-tier meta-progression system. Each tier maps to a set of * unlock thresholds (reputation or challenge-based) and card assignments. * - * This is the authoritative source for tier thresholds and card assignments. + * **Card-to-tier assignments** are read from the `tier` column in + * `card-data.csv` via `CARD_TIER_MAP` (exported from `MainStreetCards.ts`). + * This keeps per-card tier data colocated with card templates and editable + * without TypeScript. + * + * **Tier structure** (thresholds, challenge conditions, ordering) remains + * in TypeScript and is defined below in `TIER_DEFINITIONS`. + * * See docs/main-street/prd-milestone-2.md Section 2 and 4.3.5. * * @module @@ -12,6 +19,7 @@ import type { MainStreetState } from './MainStreetState'; import type { ChallengeCategory } from './MainStreetChallenges'; +import { CARD_TIER_MAP } from './MainStreetCards'; // ── Tier Types ────────────────────────────────────────────── @@ -37,122 +45,27 @@ export interface TierDefinition { cumulativeCardIds: string[]; } -// ── Tier 1 Card IDs (M1 Baseline) ────────────────────────── - -const TIER_1_CARD_IDS: string[] = [ - // M1 baseline (13) - // Business (5) - 'biz-bakery', - 'biz-diner', - 'biz-bookshop', - 'cs-park', - 'biz-hardware', - // Event (5) - 'evt-festival', - 'evt-rainy', - 'evt-tax', - 'evt-award', - 'evt-inspection', - // Upgrade (3) - 'upg-patisserie', - 'upg-bistro', - 'upg-readers-cafe', - - // Early expanded sample (~10% of expanded set => 5 cards) - 'biz-pawnshop', - 'biz-laundromat', - 'evt-grand-opening', - 'upg-garden', - 'upg-vintage-shop', - - // Community space cards (new community spaces) - 'cs-library', - 'upg-community-hub', -]; - -// ── Tier 2 Card IDs (Rising Street) ──────────────────────── - -const TIER_2_NEW_CARD_IDS: string[] = [ - 'biz-boutique', - 'biz-cafe', - 'biz-arcade', - 'evt-wellness-fair', - 'evt-block-party', - 'upg-bread-factory', - 'upg-designer-store', - 'upg-drive-in', - 'upg-dry-cleaners', - 'upg-fast-food', -]; - -// ── Tier 3 Card IDs (Neighborhood) ───────────────────────── - -const TIER_3_NEW_CARD_IDS: string[] = [ - 'biz-barbershop', - 'biz-cinema', - 'biz-food-truck', - 'evt-charity-drive', - 'evt-construction', - 'evt-food-critic', - 'upg-gaming-lounge', - 'upg-imax', - 'upg-garden-center', - 'upg-gourmet-truck', -]; - -// ── Tier 4 Card IDs (District) ───────────────────────────── - -const TIER_4_NEW_CARD_IDS: string[] = [ - 'biz-gallery', - 'biz-florist', - 'biz-clinic', - 'biz-private-clinic', - 'biz-pharmacy', - 'evt-noise-complaint', - 'evt-pipe-burst', - 'evt-power-outage', - 'evt-flu-outbreak', - 'upg-grand-bakehouse', - 'upg-home-improvement', - 'upg-medical-center', - 'upg-museum', - 'upg-private-medical-center', -]; - -// ── Tier 5 Card IDs (Landmark) ───────────────────────────── - -const TIER_5_NEW_CARD_IDS: string[] = [ - 'biz-spa', - 'evt-shoplifting', - 'evt-vandalism', - 'evt-viral-review', - // M3 doubled event pool (18 new unique event templates) - 'evt-harvest-festival', - 'evt-health-campaign', - 'evt-street-performer', - 'evt-bulk-purchase', - 'evt-book-fair', - 'evt-volunteer-day', - 'evt-community-garden', - 'evt-festival-season', - 'evt-protest', - 'evt-supply-chain', - 'evt-power-surge', - 'evt-strike', - 'evt-heatwave', - 'evt-pest-infestation', - 'evt-slow-season', - 'evt-good-press', - 'evt-tourist-bus', - 'evt-cultural-grant', - 'upg-luxury-retreat', - 'upg-multiplex', - 'upg-resort-spa', - 'upg-restaurant', - 'upg-roastery', - 'upg-salon', - 'upg-wellness-center', -]; +// ── Tier Card IDs (from CSV) ─────────────────────────────── + +// Build tier card ID arrays from the CSV-derived CARD_TIER_MAP. +// This externalises the per-card tier assignment into card-data.csv +// while keeping TIER_DEFINITIONS structure (thresholds, challenges) in TS. + +const TIER_1_CARD_IDS: string[] = []; +const TIER_2_NEW_CARD_IDS: string[] = []; +const TIER_3_NEW_CARD_IDS: string[] = []; +const TIER_4_NEW_CARD_IDS: string[] = []; +const TIER_5_NEW_CARD_IDS: string[] = []; + +for (const [cardId, tier] of CARD_TIER_MAP) { + switch (tier) { + case '1': TIER_1_CARD_IDS.push(cardId); break; + case '2': TIER_2_NEW_CARD_IDS.push(cardId); break; + case '3': TIER_3_NEW_CARD_IDS.push(cardId); break; + case '4': TIER_4_NEW_CARD_IDS.push(cardId); break; + case '5': TIER_5_NEW_CARD_IDS.push(cardId); break; + } +} // ── Challenge Condition Helpers ───────────────────────────── diff --git a/example-games/main-street/README.md b/example-games/main-street/README.md index 96e79cb4..033e3a84 100644 --- a/example-games/main-street/README.md +++ b/example-games/main-street/README.md @@ -37,6 +37,117 @@ npx vitest run tests/main-street/MainStreetScene.browser.test.ts --project brows npx vitest run tests/e2e/replay-main-street.e2e.test.ts --project unit ``` +## Card Data CSV + +All card template data is defined in a single CSV file: + +- **File:** `example-games/main-street/card-data.csv` + +### How it works + +The CSV is loaded at build time via Vite's `?raw` import suffix and parsed by +`@core-engine/CsvLoader` (`parseCsv`). The import and parsing happen in +`MainStreetCards.ts` at module load time: + +```typescript +import cardDataRaw from './card-data.csv?raw'; +import { parseCsv } from '@core-engine/CsvLoader'; +const csvRows = parseCsv(cardDataRaw); +``` + +The parsed rows are then mapped into typed card template arrays (`BusinessCard`, +`CommunitySpaceCard`, `EventCard`/`DurationEventCard`, `UpgradeCard`, `StaffCard`) +with the appropriate field coercions (e.g. string → number for cost, pipe-separated +strings → `SynergyType[]` for synergy types). + +### CSV column reference + +The first row is the header. Columns common to all card families: + +| Column | Type | Description | +|--------|------|-------------| +| `family` | string | Card family: `business`, `community-space`, `event`, `upgrade`, `staff` | +| `id` | string | Unique card template ID (e.g. `biz-bakery`, `evt-festival`) | +| `name` | string | Display name shown in-game | +| `description` | string | Flavour / effect description | +| `tier` | string | Progression tier: `1`–`5`. Empty (or absent) for always-available cards (e.g. staff). Tier assignments are consumed by `MainStreetTiers.ts` to build `TIER_DEFINITIONS` arrays. | + +#### Family-specific columns + +**Business / Community Space** (`business`, `community-space`): + +| Column | Type | Description | +|--------|------|-------------| +| `cost` | number | Coin cost to acquire | +| `baseIncome` | number | Base income per turn | +| `synergyTypes` | string | Pipe-separated synergy types: `Food | Culture | Commerce | Service | Entertainment | Health` | +| `upgradePath` | string | Upgrade family name (e.g. `Bakery`) or empty if unupgradeable | +| `maxLevel` | number | Maximum upgrade level (0 = unupgradeable) | +| `reputationPerTurn` | number | Reputation generated per turn (e.g. `0.2` for Clinic) | +| `synergyCoinBonus` | number | Coin synergy per matching neighbor (defaults to `1`; set `0` to exclude) | +| `synergyRepBonus` | number | Reputation synergy per matching neighbor (defaults to `0`) | + +**Event** (`event`): + +| Column | Type | Description | +|--------|------|-------------| +| `cost` | number | Purchase cost; `0` for Incident events (drawn automatically) | +| `trigger` | string | `Investment` (player-chosen) or `Incident` (automatic) | +| `target` | string | `All`, `SpecificSynergy`, or `RandomBusiness` | +| `targetSynergy` | string | Synergy type when `target` is `SpecificSynergy` | +| `coinDelta` | number | Coin change when the event resolves | +| `reputationDelta` | number | Reputation change when the event resolves | +| `effect` | string | Human-readable effect description | + +Duration events (e.g. Flu Outbreak) also use: + +| Column | Type | Description | +|--------|------|-------------| +| `duration` | number | Number of turns the effect lasts | +| `effectType` | string | Discriminator (e.g. `income-multiplier`) | +| `multiplier` | number | Scalar applied each turn (e.g. `0.8` for 80% income) | + +**Upgrade** (`upgrade`): + +| Column | Type | Description | +|--------|------|-------------| +| `cost` | number | Coin cost to apply the upgrade | +| `targetBusiness` | string | Name of the business this upgrade applies to | +| `incomeBonus` | number | Additional income per turn | +| `synergyRangeBonus` | number | Additional synergy range | +| `requiredLevel` | number | Minimum business level required (0 = base business) | +| `reputationBonus` | number | Additional reputation per turn | + +**Staff** (`staff`): + +| Column | Type | Description | +|--------|------|-------------| +| `cost` | number | Coin cost to acquire | +| `ongoingCost` | number | Per-turn coin cost after hiring | +| `handSlotsAdded` | number | Additional hand slots provided | + +### Editing the CSV + +To add, remove, or modify cards, edit `card-data.csv` directly. The CSV is +re-parsed automatically during development (Vite HMR) when `MainStreetCards.ts` +is re-evaluated. After editing, verify with: + +```bash +npm test +``` + +### CSV conventions + +- Columns not applicable to a given card family are left empty. +- Multiple synergy types use pipe (`|`) as a separator. +- Multi-level upgrade chains are supported: set `requiredLevel` to the + business level needed before the upgrade can be applied. +- Branching upgrades are supported: multiple `upgrade` rows may share the + same `targetBusiness` and `requiredLevel`, giving the player a choice. +- Positive Incident events (events where `coinDelta + reputationDelta > 0`) + receive more copies in the deck based on the `positiveIncidentMultiplier` + parameter passed to `createEventDeck()`. + ## Follow-up work The tutorial overlay system (`MainStreetTutorialHints.ts`) currently uses `zoneToAnchor()` with diff --git a/example-games/main-street/card-data.csv b/example-games/main-street/card-data.csv new file mode 100644 index 00000000..ea7926b4 --- /dev/null +++ b/example-games/main-street/card-data.csv @@ -0,0 +1,87 @@ +family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,ongoingCost,handSlotsAdded +business,biz-bakery,Bakery,6,1,Food,Bakery,2,,,,Provides warm pastries. Gains +1 coin for each adjacent Food business.,1,,,,,,,,,,,,,,,, +business,biz-diner,Diner,8,1,Food,Diner,2,,,,Serves quick meals. Gains +1 coin per adjacent Food business.,1,,,,,,,,,,,,,,,, +business,biz-bookshop,Bookshop,8,1,Culture,Bookshop,1,,,,Sells books. Gains +1 coin per adjacent Culture business.,1,,,,,,,,,,,,,,,, +business,biz-hardware,Hardware Store,10,1,Commerce,Hardware Store,1,,,,Supplies tools. Gains +1 coin per adjacent Commerce business.,1,,,,,,,,,,,,,,,, +business,biz-pawnshop,Pawn Shop,6,1,Commerce,Pawn Shop,1,,0,0,Trades second-hand goods. Does not provide or receive synergy bonuses.,1,,,,,,,,,,,,,,,, +business,biz-boutique,Boutique,8,1,Commerce,Boutique,1,,,,Sells curated fashion. Gains +1 coin per adjacent Commerce business.,2,,,,,,,,,,,,,,,, +business,biz-laundromat,Laundromat,3,0.5,Service,Laundromat,1,,,,Provides self-serve laundry. Gains +1 coin per adjacent Service business.,1,,,,,,,,,,,,,,,, +business,biz-barbershop,Barbershop,6,1,Service,Barbershop,1,,,,Classic cuts and conversation. Gains +1 coin per adjacent Service business.,3,,,,,,,,,,,,,,,, +business,biz-arcade,Arcade,8,1,Entertainment,Arcade,1,,,,Retro fun for all ages. Gains +1 coin per adjacent Entertainment business.,2,,,,,,,,,,,,,,,, +business,biz-cinema,Cinema,10,1,Entertainment,Cinema,2,,,,Shows the latest films. Gains +1 coin per adjacent Entertainment business.,3,,,,,,,,,,,,,,,, +business,biz-cafe,Cafe,6,1,Food|Culture,Cafe,1,,,,Coffee and conversation. Bridges Food and Culture synergies.,2,,,,,,,,,,,,,,,, +business,biz-food-truck,Food Truck,4,0,Food|Entertainment,Food Truck,1,,,,Street eats with flair. Bridges Food and Entertainment synergies.,3,,,,,,,,,,,,,,,, +business,biz-gallery,Art Gallery,8,1,Culture|Entertainment,Art Gallery,1,,,,Showcases local artists. Bridges Culture and Entertainment synergies.,4,,,,,,,,,,,,,,,, +business,biz-spa,Day Spa,10,1,Service|Entertainment,Day Spa,2,,,,Relaxation and pampering. Bridges Service and Entertainment synergies.,5,,,,,,,,,,,,,,,, +business,biz-florist,Florist,4,0,Commerce|Culture,Florist,1,,,,Beautiful arrangements for every occasion. Bridges Commerce and Culture synergies.,4,,,,,,,,,,,,,,,, +business,biz-clinic,Clinic,10,0,Health,Clinic,1,0.2,,,Walk-in medical care for the community. Provides +0.2 reputation per turn. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +business,biz-private-clinic,Private Clinic,8,2,Health,Private Clinic,1,,,,A private medical practice focused on profitability. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +business,biz-pharmacy,Pharmacy,6,1,Health,,0,,,,Provides essential medications. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +community-space,cs-park,Park,4,0,Culture,Park,1,,,,Offers leisure space. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, +community-space,cs-library,Library,6,1,Culture,Library,1,,,,A quiet community space for reading and learning. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, +event,evt-festival,Local Festival,3,,,,,,,,,1,Investment,+2 coins to all Culture businesses and +1 reputation.,SpecificSynergy,Culture,2,1,,,,,,,,,, +event,evt-rainy,Rainy Day,0,,,,,,,,,1,Incident,-1 coin to all Food businesses this turn.,SpecificSynergy,Food,-1,0,,,,,,,,,, +event,evt-tax,Tax Audit,0,,,,,,,,,1,Incident,Lose 3 coins.,All,,-3,0,,,,,,,,,, +event,evt-award,Community Award,0,,,,,,,,,1,Incident,Gain 2 reputation from community recognition.,All,,0,2,,,,,,,,,, +event,evt-inspection,Health Inspection,0,,,,,,,,,1,Incident,-2 coins per Food business and -1 reputation.,SpecificSynergy,Food,-2,-1,,,,,,,,,, +event,evt-grand-opening,Grand Opening Sale,2,,,,,,,,,1,Investment,+3 coins from a Commerce promotion.,SpecificSynergy,Commerce,3,0,,,,,,,,,, +event,evt-wellness-fair,Wellness Fair,3,,,,,,,,,2,Investment,+2 coins per Service business and +1 reputation.,SpecificSynergy,Service,2,1,,,,,,,,,, +event,evt-block-party,Block Party,4,,,,,,,,,2,Investment,+2 coins per Entertainment business and +2 reputation.,SpecificSynergy,Entertainment,2,2,,,,,,,,,, +event,evt-charity-drive,Charity Drive,2,,,,,,,,,3,Investment,+3 reputation from generous donations.,All,,0,3,,,,,,,,,, +event,evt-power-outage,Power Outage,0,,,,,,,,,4,Incident,-2 coins from lost business during the outage.,All,,-2,0,,,,,,,,,, +event,evt-shoplifting,Shoplifting Spree,0,,,,,,,,,5,Incident,-2 coins per Commerce business from theft losses.,SpecificSynergy,Commerce,-2,0,,,,,,,,,, +event,evt-noise-complaint,Noise Complaint,0,,,,,,,,,4,Incident,-1 coin per Entertainment business and -1 reputation.,SpecificSynergy,Entertainment,-1,-1,,,,,,,,,, +event,evt-pipe-burst,Pipe Burst,0,,,,,,,,,4,Incident,-2 coins per Service business from water damage.,SpecificSynergy,Service,-2,0,,,,,,,,,, +event,evt-food-critic,Food Critic Visit,0,,,,,,,,,3,Incident,+1 coin per Food business and +1 reputation from a glowing review.,SpecificSynergy,Food,1,1,,,,,,,,,, +event,evt-construction,Road Construction,0,,,,,,,,,3,Incident,-1 coin to all businesses from reduced foot traffic.,All,,-1,0,,,,,,,,,, +event,evt-viral-review,Viral Review,0,,,,,,,,,5,Incident,+2 coins and +1 reputation from sudden online fame.,All,,2,1,,,,,,,,,, +event,evt-vandalism,Vandalism,0,,,,,,,,,5,Incident,-1 coin to all businesses and -1 reputation.,All,,-1,-1,,,,,,,,,, +event,evt-flu-outbreak,Flu Outbreak,0,,,,,,,,,4,Incident,All businesses generate 80% income for 5 turns. Duration reduced by Clinic/Medical Center.,All,,0,0,5,income-multiplier,0.8,,,,,,, +event,evt-harvest-festival,Harvest Festival,3,,,,,,,,,5,Investment,+2 coins to each Food business and +1 reputation from a bountiful harvest celebration.,SpecificSynergy,Food,2,1,,,,,,,,,, +event,evt-health-campaign,Health Campaign,3,,,,,,,,,5,Investment,+1 coin to each Health business and +1 reputation from a wellness initiative.,SpecificSynergy,Health,1,1,,,,,,,,,, +event,evt-street-performer,Street Performer,2,,,,,,,,,5,Investment,+2 coins to each Entertainment business from a popular busker drawing crowds.,SpecificSynergy,Entertainment,2,0,,,,,,,,,, +event,evt-bulk-purchase,Bulk Purchase,3,,,,,,,,,5,Investment,+1 coin to each Commerce business and +2 reputation from collective buying power.,SpecificSynergy,Commerce,1,2,,,,,,,,,, +event,evt-book-fair,Book Fair,3,,,,,,,,,5,Investment,+1 coin to each Culture business and +2 reputation from literary events.,SpecificSynergy,Culture,1,2,,,,,,,,,, +event,evt-volunteer-day,Volunteer Day,2,,,,,,,,,5,Investment,+1 coin to each Service business and +2 reputation from community volunteering.,SpecificSynergy,Service,1,2,,,,,,,,,, +event,evt-community-garden,Community Garden,2,,,,,,,,,5,Investment,+1 coin and +1 reputation from a new community garden project.,All,,1,1,,,,,,,,,, +event,evt-festival-season,Festival Season,4,,,,,,,,,5,Investment,+3 coins from increased tourist spending during festival season.,All,,3,0,,,,,,,,,, +event,evt-protest,Protest,0,,,,,,,,,5,Incident,-2 coins from reduced foot traffic and -1 reputation from negative publicity.,All,,-2,-1,,,,,,,,,, +event,evt-supply-chain,Supply Chain Delay,0,,,,,,,,,5,Incident,-2 coins per Commerce business from delayed inventory.,SpecificSynergy,Commerce,-2,0,,,,,,,,,, +event,evt-power-surge,Power Surge,0,,,,,,,,,5,Incident,-2 coins per Service business from equipment damage.,SpecificSynergy,Service,-2,0,,,,,,,,,, +event,evt-strike,Strike,0,,,,,,,,,5,Incident,-2 coins from work stoppages affecting the street.,All,,-2,0,,,,,,,,,, +event,evt-heatwave,Heatwave,0,,,,,,,,,5,Incident,-1 coin per Food business from spoiled goods and -1 reputation.,SpecificSynergy,Food,-1,-1,,,,,,,,,, +event,evt-pest-infestation,Pest Infestation,0,,,,,,,,,5,Incident,-2 coins per Food business from health-related closures.,SpecificSynergy,Food,-2,0,,,,,,,,,, +event,evt-slow-season,Slow Season,0,,,,,,,,,5,Incident,-1 coin to all businesses from reduced customer traffic.,All,,-1,0,,,,,,,,,, +event,evt-good-press,Good Press,0,,,,,,,,,5,Incident,+1 reputation from a favourable news article about Main Street.,All,,0,1,,,,,,,,,, +event,evt-tourist-bus,Tourist Bus,0,,,,,,,,,5,Incident,+2 coins per Entertainment business from a tour bus dropping visitors.,SpecificSynergy,Entertainment,2,0,,,,,,,,,, +event,evt-cultural-grant,Cultural Grant,0,,,,,,,,,5,Incident,+1 coin per Culture business and +1 reputation from a government arts grant.,SpecificSynergy,Culture,1,1,,,,,,,,,, +upgrade,upg-patisserie,Upgrade to Patisserie,4,,,,,,,,"Turns a Bakery into a Patisserie, increasing income and synergy range.",1,,,,,,,,,,Bakery,1,1,0,,, +upgrade,upg-bistro,Upgrade to Bistro,4,,,,,,,,Turns a Diner into a Bistro with higher foot-traffic.,1,,,,,,,,,,Diner,1,1,0,,, +upgrade,upg-readers-cafe,Upgrade to Reader's Café,3,,,,,,,,"Transforms the Bookshop into a Reader's Café, blending books with café culture for a reputation boost.",1,,,,,,,,,,Bookshop,1,0,0,0.1,, +upgrade,upg-garden,Upgrade to Garden,3,,,,,,,,Expands the Park into a Garden with extended cultural reach.,1,,,,,,,,,,Park,1,1,0,,, +upgrade,upg-home-improvement,Upgrade to Home Improvement,4,,,,,,,,Transforms the Hardware Store into a Home Improvement center.,4,,,,,,,,,,Hardware Store,1,1,0,,, +upgrade,upg-vintage-shop,Upgrade to Vintage Shop,3,,,,,,,,Rebrands the Pawn Shop as a trendy Vintage Shop.,1,,,,,,,,,,Pawn Shop,1,0,0,,, +upgrade,upg-designer-store,Upgrade to Designer Store,4,,,,,,,,Elevates the Boutique to a Designer Store with premium clientele.,2,,,,,,,,,,Boutique,1,1,0,,, +upgrade,upg-dry-cleaners,Upgrade to Dry Cleaners,3,,,,,,,,Upgrades the Laundromat to a full-service Dry Cleaners.,2,,,,,,,,,,Laundromat,1,0,0,,, +upgrade,upg-salon,Upgrade to Salon,3,,,,,,,,Expands the Barbershop into a modern Salon.,5,,,,,,,,,,Barbershop,1,1,0,,, +upgrade,upg-gaming-lounge,Upgrade to Gaming Lounge,4,,,,,,,,Transforms the Arcade into a state-of-the-art Gaming Lounge.,3,,,,,,,,,,Arcade,1,1,0,,, +upgrade,upg-imax,Upgrade to IMAX Theater,5,,,,,,,,Upgrades the Cinema to an IMAX Theater with premium experience.,3,,,,,,,,,,Cinema,2,1,0,,, +upgrade,upg-roastery,Upgrade to Roastery,3,,,,,,,,Turns the Cafe into a specialty Roastery with artisan appeal.,5,,,,,,,,,,Cafe,1,1,0,,, +upgrade,upg-gourmet-truck,Upgrade to Gourmet Truck,2,,,,,,,,Elevates the Food Truck with gourmet offerings.,3,,,,,,,,,,Food Truck,1,0,0,,, +upgrade,upg-museum,Upgrade to Museum,4,,,,,,,,Expands the Art Gallery into a full Museum.,4,,,,,,,,,,Art Gallery,1,1,0,,, +upgrade,upg-resort-spa,Upgrade to Resort Spa,5,,,,,,,,Transforms the Day Spa into a luxurious Resort Spa.,5,,,,,,,,,,Day Spa,2,1,0,,, +upgrade,upg-garden-center,Upgrade to Garden Center,3,,,,,,,,Expands the Florist into a full Garden Center.,3,,,,,,,,,,Florist,1,1,0,,, +upgrade,upg-medical-center,Upgrade to Medical Center,5,,,,,,,,Upgrades the Clinic to a comprehensive Medical Center. Provides +0.1 reputation per turn.,4,,,,,,,,,,Clinic,0,1,0,0.1,, +upgrade,upg-private-medical-center,Upgrade to Private Medical Center,4,,,,,,,,Expands the Private Clinic into a high-revenue Private Medical Center.,4,,,,,,,,,,Private Clinic,2,0,0,,, +upgrade,upg-bread-factory,Upgrade to Bread Factory,3,,,,,,,,"Scales the Bakery into a high-volume Bread Factory. More income, no range boost.",2,,,,,,,,,,Bakery,2,0,0,,, +upgrade,upg-fast-food,Upgrade to Fast Food,3,,,,,,,,"Converts the Diner to a Fast Food outlet. Higher income, smaller synergy radius.",2,,,,,,,,,,Diner,2,0,0,,, +upgrade,upg-drive-in,Upgrade to Drive-In Theater,4,,,,,,,,Turns the Cinema into a Drive-In Theater with a much wider community reach.,2,,,,,,,,,,Cinema,1,2,0,,, +upgrade,upg-wellness-center,Upgrade to Wellness Center,4,,,,,,,,Expands the Day Spa into a Wellness Center with a broader service footprint.,5,,,,,,,,,,Day Spa,1,2,0,,, +upgrade,upg-grand-bakehouse,Upgrade to Grand Bakehouse,5,,,,,,,,The pinnacle of baking craft — a Grand Bakehouse drawing visitors from afar.,4,,,,,,,,,,Bakery,2,1,1,,, +upgrade,upg-restaurant,Upgrade to Restaurant,5,,,,,,,,Elevates the Diner all the way to a full-service Restaurant.,5,,,,,,,,,,Diner,2,1,1,,, +upgrade,upg-multiplex,Upgrade to Multiplex,6,,,,,,,,A massive Multiplex complex — the entertainment heart of Main Street.,5,,,,,,,,,,Cinema,3,1,1,,, +upgrade,upg-luxury-retreat,Upgrade to Luxury Retreat,6,,,,,,,,A destination Luxury Retreat — the most prestigious business on the street.,5,,,,,,,,,,Day Spa,3,1,1,,, +upgrade,upg-community-hub,Upgrade to Community Hub,4,,,,,,,,Expands the Library into a Community Hub with extended cultural reach.,1,,,,,,,,,,Library,1,1,0,,, +staff,staff-assistant,Assistant,3,,,,,,,,Hire an assistant to help manage your hand. Adds +1 hand slot with a small ongoing cost.,,,,,,,,,,,,,,,,1,1 +staff,staff-manager,Manager,6,,,,,,,,A skilled manager keeps things organised. Adds +2 hand slots with a moderate ongoing cost.,,,,,,,,,,,,,,,,2,2 +staff,staff-director,Director,10,,,,,,,,An experienced director oversees your operations. Adds +3 hand slots with a high ongoing cost.,,,,,,,,,,,,,,,,3,3 diff --git a/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts b/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts new file mode 100644 index 00000000..885cf44f --- /dev/null +++ b/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts @@ -0,0 +1,171 @@ +/** + * Main Street SVG Card Generator + * + * Generates SVG strings dynamically for BusinessCard and CommunitySpaceCard + * based on their current state (income, reputation, name/level). This replaces + * the static-SVG + Phaser-overlay approach with a single rendering path: + * card state → SVG string → rasterised texture. + * + * Event and Upgrade cards remain static SVGs — they have no dynamic visual + * state. Only Business and Community Space cards change appearance based on + * game state (upgrades, synergy bonuses, etc.). + * + * @module MainStreetCardSvgGenerator + */ + +import type { BusinessCard, CommunitySpaceCard, SynergyType } from '../MainStreetCards'; +import { synergyColor } from '../MainStreetCards'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default card dimensions (matching the static SVG templates). */ +const CARD_W = 140; +const CARD_H = 80; + +const FONT = 'Inter, Segoe UI, Arial, sans-serif'; + +/** Background fill colours per card family / synergy type. */ +const CARD_BG: Record = { + Food: '#5D4037', + Culture: '#1565C0', + Commerce: '#2E7D32', + Service: '#6A1B9A', + Entertainment: '#C62828', + Health: '#00838F', + // Fallback (community-space defaults to Culture colour) + default: '#2f2f2f', +}; + +// --------------------------------------------------------------------------- +// SVG helpers +// --------------------------------------------------------------------------- + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Return the fill colour for the header bar (light tint of the synergy colour). */ +function headerFill(synergyType: SynergyType): string { + return synergyColor(synergyType).toString(16).padStart(6, '0'); +} + +/** Build the synergy icon SVG for the bottom-left corner. */ +function synergyIconSvg(type: SynergyType, x: number, y: number): string { + const c = '#' + synergyColor(type).toString(16).padStart(6, '0'); + const label = `${type} icon`; + return ` + + ${esc(label)} + + + + `; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Generate an SVG string for a BusinessCard or CommunitySpaceCard + * reflecting its current state. + * + * The SVG includes: + * - Card background with synergy-type colour + * - Title text (name or upgraded name) + * - Per-turn income (omitted when 0) + * - Per-turn reputation (omitted when 0) + * - Cost circle (bottom-right) + * - Synergy icon (bottom-left) + * - Level badge (top-right, when level > 0) + * + * @param card - The business or community-space card. + * @param width - SVG width in pixels (default 140). + * @param height - SVG height in pixels (default 80). + * @returns A complete SVG string. + */ +export function generateBusinessCardSvg( + card: BusinessCard | CommunitySpaceCard, + width: number = CARD_W, + height: number = CARD_H, +): string { + const isUpgraded = card.level > 0; + const totalIncome = card.baseIncome + card.incomeBonus; + const totalRep = (card.reputationPerTurn ?? 0) + card.reputationBonus; + + const primarySynergy = card.synergyTypes[0]; + const bgFill = CARD_BG[primarySynergy] ?? CARD_BG.default; + + // Header bar uses the synergy colour at low opacity + const headerHex = headerFill(primarySynergy); + + // ── Dynamic text elements ──────────────────────────────── + + // Title: always present (name changes on upgrade) + const titleY = 19; + const titleText = `${esc(card.name)}`; + + // Income label: centred horizontally, middle band of card, omitted when 0 + // Uses "Income: +X/turn" format for clarity + const incomeLabel = + totalIncome > 0 + ? `Income: +${totalIncome}/turn` + : ''; + + // Reputation label: centred, below income, omitted when 0 + const repLabel = + totalRep > 0 + ? `+${fmtRep(totalRep)}/turn` + : ''; + + // Level badge: top-right, only for upgraded cards + const levelBadge = + isUpgraded + ? `Lvl ${card.level}` + : ''; + + // Synergy icons: bottom-left for each synergy type + const icons = card.synergyTypes + .map((t, i) => synergyIconSvg(t, 6 + i * 18, height - 22)) + .join('\n '); + + // Cost circle: bottom-right + const costCx = width - 16; + const costCy = height - 16; + const costCircle = ``; + const costText = `${card.cost}`; + + // ── Compose SVG ───────────────────────────────────────── + + return ` + + + + + + + + + + + ${titleText} + ${incomeLabel} + ${repLabel} + ${levelBadge} + ${costCircle} + ${costText} + ${icons} +`; +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** Format a reputation value to at most 1 decimal place, stripping trailing zeros. */ +function fmtRep(v: number): string { + if (Number.isInteger(v)) return String(v); + return v.toFixed(1); +} diff --git a/example-games/main-street/scenes/MainStreetConstants.ts b/example-games/main-street/scenes/MainStreetConstants.ts index 60d76e47..7f78a2aa 100644 --- a/example-games/main-street/scenes/MainStreetConstants.ts +++ b/example-games/main-street/scenes/MainStreetConstants.ts @@ -17,9 +17,10 @@ export const BASE_MARKET_CARD_H = 80; export const BASE_MARKET_ROW_GAP = 10; export const BASE_MARKET_CARD_GAP = 12; export const BASE_MARKET_LABEL_W = 90; -// Incident queue uses same card size as market for consistency -export const BASE_QUEUE_CARD_W = BASE_MARKET_CARD_W; -export const BASE_QUEUE_CARD_H = BASE_MARKET_CARD_H; +// Incident queue uses custom card size (120×69) for better panel fit +// while preserving the standard 7:4 aspect ratio of all card SVGs. +export const BASE_QUEUE_CARD_W = 120; +export const BASE_QUEUE_CARD_H = 69; export const BASE_QUEUE_CARD_GAP = 10; // Make street slots match market placeholder size (market slots: 140x80) export const BASE_SLOT_W = 140; diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts index 5aa17669..a6804ab9 100644 --- a/example-games/main-street/scenes/MainStreetHudTooltips.ts +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -131,10 +131,13 @@ export function buildCoinsTooltip(state: MainStreetState): string { const multiplier = reputationCoinMultiplier(state.resourceBank.reputation, state.config); const multiplierStr = Number.isFinite(multiplier) ? multiplier.toFixed(1) : '1.0'; + const preMultiplierStr = Number.isFinite(baseIncome) ? baseIncome.toFixed(3) : '0.000'; + const postMultiplierStr = Number.isFinite(multipliedIncome) ? multipliedIncome.toFixed(3) : '0.000'; + const lines = [ t(HUD_TOOLTIP_I18N_KEYS.coinsTitle), - `${t(HUD_TOOLTIP_I18N_KEYS.coinsPreMultiplierLabel)}: ${baseIncome}`, - `${t(HUD_TOOLTIP_I18N_KEYS.coinsPostMultiplierLabel)}: ${multipliedIncome} (×${multiplierStr})`, + `${t(HUD_TOOLTIP_I18N_KEYS.coinsPreMultiplierLabel)}: ${preMultiplierStr}`, + `${t(HUD_TOOLTIP_I18N_KEYS.coinsPostMultiplierLabel)}: ${postMultiplierStr} (×${multiplierStr})`, t(HUD_TOOLTIP_I18N_KEYS.coinsCalcNote), ]; @@ -202,7 +205,7 @@ export function buildScoreTooltip( t(HUD_TOOLTIP_I18N_KEYS.scoreTitle), `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}/${threshold}`, '', - `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownCoins)}: ${coins}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownCoins)}: ${coins.toFixed(3)}`, `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownReputation)} ${state.config.reputationScoreMultiplier}: ${repContribution}`, `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownChallenges)}: ${challengeContribution}`, ]; diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 6ac522c2..8f716eca 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -10,7 +10,6 @@ import { MARKET_INVESTMENT_SLOTS, REFRESH_DEVELOPMENT_COST, REFRESH_INVESTMENTS_COST, - isPawnShopCard, synergyColor, } from '../MainStreetCards'; import { @@ -266,7 +265,7 @@ export class MainStreetRenderer { // Coins - centered in strip const stripWidth = gameW * 0.5; const stripLeft = (gameW - stripWidth) / 2; - const coinText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins}`, { + const coinText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins.toFixed(3)}`, { fontSize: '16px', fontStyle: 'bold', color: '#ffcc44', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5)); s.hudContainer.add(coinText); @@ -485,14 +484,13 @@ export class MainStreetRenderer { tooltipZone.setOrigin(0.5); tooltipZone.setInteractive({ useHandCursor: true }); tooltipZone.on('pointerover', () => { - const synergyNote = isPawnShopCard(biz) ? ' (excluded from synergy)' : ''; const isCommunitySpace = (biz as any).family === 'community-space'; const label = isCommunitySpace ? 'Community Space' : 'Business'; const totalRep = (biz.reputationPerTurn ?? 0) + biz.reputationBonus; const repInfo = totalRep > 0 ? `\nReputation: +${totalRep}/turn` : ''; - const synergyBonus = isPawnShopCard(biz) ? 0 : computeSynergyBonus(s.state.streetGrid, _index, s.state.config.synergyBonusPerNeighbor); - const synergyInfo = isPawnShopCard(biz) ? '' : `\nSynergy bonus: +${synergyBonus}/turn`; - const info = `${label}: ${biz.name}\nIncome: +${biz.baseIncome + biz.incomeBonus}/turn${repInfo}\nSynergy: ${biz.synergyTypes.join('/')}${synergyInfo}${synergyNote}\nLevel: ${biz.level}`; + const synergyBonus = computeSynergyBonus(s.state.streetGrid, _index, s.state.config.synergyBonusPerNeighbor); + const synergyInfo = `\nSynergy bonus: +${synergyBonus}/turn`; + const info = `${label}: ${biz.name}\nIncome: +${biz.baseIncome + biz.incomeBonus}/turn${repInfo}\nSynergy: ${biz.synergyTypes.join('/')}${synergyInfo}\nLevel: ${biz.level}`; s.tooltipManager?.show(info, tooltipZone.x, tooltipZone.y); }); tooltipZone.on('pointerout', () => { @@ -576,7 +574,7 @@ export class MainStreetRenderer { container.add(nameText); } - // Income text (bottom-left) + // Income text (centred on card) if (spec.incomeText) { const incomeText = this.scene.add.text( spec.incomeText.x, @@ -589,11 +587,11 @@ export class MainStreetRenderer { fontFamily: FONT_FAMILY, }, ); - incomeText.setOrigin(0, 1); + incomeText.setOrigin(spec.incomeText.originX ?? 0, spec.incomeText.originY ?? 0); container.add(incomeText); } - // Reputation text (bottom-right) + // Reputation text (centred below income) if (spec.reputationText) { const repText = this.scene.add.text( spec.reputationText.x, @@ -606,7 +604,7 @@ export class MainStreetRenderer { fontFamily: FONT_FAMILY, }, ); - repText.setOrigin(1, 1); + repText.setOrigin(spec.reputationText.originX ?? 0, spec.reputationText.originY ?? 0); container.add(repText); } } @@ -934,6 +932,11 @@ export class MainStreetRenderer { container.add(targetText); } + // Apply income/reputation overlays for business and community-space cards + if (card.family === 'business' || card.family === 'community-space') { + this.applyUpgradeOverlays(container, card as BusinessCard | CommunitySpaceCard, renderW, renderH); + } + const selectionRing = s.add.rectangle(0, 0, marketCardW, marketCardH); selectionRing.setFillStyle(0x000000, 0); selectionRing.setStrokeStyle(2, 0x44ff66); @@ -984,19 +987,18 @@ export class MainStreetRenderer { let info = ''; if (card.family === 'business') { const b = card as any; - const bSynergyNote = isPawnShopCard(b) ? ' (excluded from synergy)' : ''; const bTotalRep = (b.reputationPerTurn ?? 0) + (b.reputationBonus ?? 0); const bRepInfo = bTotalRep > 0 ? `\nReputation: +${bTotalRep}/turn` : ''; - info = `Business: ${b.name}\nCost: ${b.cost}\nIncome: +${b.baseIncome + (b.incomeBonus || 0)}/turn${bRepInfo}\nSynergy: ${(b.synergyTypes || []).join('/')}${bSynergyNote}\n${b.description ?? ''}`; + info = `Business: ${b.name}\nCost: ${b.cost}\nIncome: +${b.baseIncome + (b.incomeBonus || 0)}/turn${bRepInfo}\nSynergy: ${(b.synergyTypes || []).join('/')}\n${b.description ?? ''}`; } else if (card.family === 'community-space') { const cs = card as any; - const csSynergyNote = isPawnShopCard(cs) ? ' (excluded from synergy)' : ''; const csTotalRep = (cs.reputationPerTurn ?? 0) + (cs.reputationBonus ?? 0); const csRepInfo = csTotalRep > 0 ? `\nReputation: +${csTotalRep}/turn` : ''; - info = `Community Space: ${cs.name}\nCost: ${cs.cost}\nIncome: +${cs.baseIncome + (cs.incomeBonus || 0)}/turn${csRepInfo}\nSynergy: ${(cs.synergyTypes || []).join('/')}${csSynergyNote}\n${cs.description ?? ''}`; + info = `Community Space: ${cs.name}\nCost: ${cs.cost}\nIncome: +${cs.baseIncome + (cs.incomeBonus || 0)}/turn${csRepInfo}\nSynergy: ${(cs.synergyTypes || []).join('/')}\n${cs.description ?? ''}`; } else if (card.family === 'event') { const e = card as any; - info = `Event: ${e.name}\nCost: ${e.cost}\nEffect: ${e.effect}\nCoins: ${e.coinDelta >= 0 ? '+' : ''}${e.coinDelta}, Rep: ${e.reputationDelta >= 0 ? '+' : ''}${e.reputationDelta}`; + const coinDelta = e.coinDelta >= 0 ? '+' : ''; + info = `Event: ${e.name}\nCost: ${e.cost}\nEffect: ${e.effect}\nCoins: ${coinDelta}${e.coinDelta.toFixed(3)}, Rep: ${e.reputationDelta >= 0 ? '+' : ''}${e.reputationDelta}`; } else if (card.family === 'upgrade') { const u = card as any; info = `Upgrade: ${u.name}\nCost: ${u.cost}\nApplies to: ${u.targetBusiness}\nIncome Bonus: +${u.incomeBonus}\nRequires: Lv${u.requiredLevel ?? 0}\n${u.description ?? ''}`; @@ -1035,7 +1037,8 @@ export class MainStreetRenderer { // Calculate dynamic height const activeEffectLines = activeEffects.length; const extraH = activeEffectLines > 0 ? 16 + activeEffectLines * 16 : 0; - const cardRenderH = 50; + const cardRenderW = s.layout.queueCardW; + const cardRenderH = s.layout.queueCardH; const maxCards = Math.min(2, queue.length); const cardAreaH = maxCards * (cardRenderH + 6) - 6 + 12; // cards + deck count const panelH = titleH + pad + cardAreaH + extraH + pad; @@ -1060,8 +1063,9 @@ export class MainStreetRenderer { s.incidentQueueContainer.add(titleText); // Queue cards — stacked vertically, centred in the panel + // Dimensions come from layout.queueCardW/queueCardH (currently 120×69) + // to preserve the standard 7:4 SVG aspect ratio. let cardY = queueTop + titleH + pad; - const cardRenderW = Math.max(1, Math.round(panelW - pad * 2 - 8)); for (let i = 0; i < maxCards; i++) { const card = queue[i]; @@ -1081,7 +1085,8 @@ export class MainStreetRenderer { if (dCard.duration !== undefined) { info = 'Event: ' + card.name + '\nEffect: ' + card.effect + '\nDuration: ' + dCard.duration + ' turns\n' + Math.round(dCard.multiplier * 100) + '% income modifier'; } else { - info = 'Event: ' + card.name + '\nEffect: ' + card.effect + '\nCoins: ' + (card.coinDelta >= 0 ? '+' : '') + card.coinDelta + ', Rep: ' + (card.reputationDelta >= 0 ? '+' : '') + card.reputationDelta; + const coinDelta = card.coinDelta >= 0 ? '+' : ''; + info = 'Event: ' + card.name + '\nEffect: ' + card.effect + '\nCoins: ' + coinDelta + card.coinDelta.toFixed(3) + ', Rep: ' + (card.reputationDelta >= 0 ? '+' : '') + card.reputationDelta; } s.tooltipManager?.show(info, container.x, container.y); }); @@ -1189,38 +1194,13 @@ export class MainStreetRenderer { const container = s.add.container(x, y); - // Card background - const bg = s.add.rectangle(0, 0, handCardW, handCardH, 0x3a2a1a, 0.9); - bg.setStrokeStyle(1, 0x8b7355); - container.add(bg); + // Render card via shared SVG pipeline for unified appearance + const renderW = Math.max(1, Math.round(handCardW - 4)); + const renderH = Math.max(1, Math.round(handCardH - 4)); + mainStreetRenderCardSvg(s, container, card.id, renderW, renderH); - // Card name - const nameText = s.add.text(0, -handCardH / 2 + 6, card.name, { - fontSize: '11px', - color: '#ffffff', - fontFamily: 'Arial', - }).setOrigin(0.5, 0); - container.add(nameText); - - // Synergy type indicator - if (card.synergyTypes && card.synergyTypes.length > 0) { - const synergyLabel = card.synergyTypes.join('/'); - const synergyColor = this.getSynergyDisplayColor(card.synergyTypes[0]); - const synText = s.add.text(0, 6, synergyLabel, { - fontSize: '9px', - color: synergyColor, - fontFamily: 'Arial', - }).setOrigin(0.5, 0); - container.add(synText); - } - - // Income info - const incomeText = s.add.text(0, 18, `$${card.baseIncome}/turn`, { - fontSize: '9px', - color: '#c8b88a', - fontFamily: 'Arial', - }).setOrigin(0.5, 0); - container.add(incomeText); + // Apply income/reputation overlays (uses centered "Income: +X/turn" format) + this.applyUpgradeOverlays(container, card, renderW, renderH); s.handBusinessContainer!.add(container); } @@ -1248,21 +1228,6 @@ export class MainStreetRenderer { }); } - /** - * Returns a CSS color string for the given synergy type. - */ - private getSynergyDisplayColor(type: string): string { - const colors: Record = { - 'Food': '#E67E22', - 'Culture': '#3498DB', - 'Commerce': '#27AE60', - 'Service': '#9B59B6', - 'Entertainment': '#E74C3C', - 'Health': '#1ABC9C', - }; - return colors[type] ?? '#ffffff'; - } - /** * Render held-event cards via the shared adapter using the same Phaser * texture pipeline used by market/street/incident cards. diff --git a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts index 78ce5926..89cf54c3 100644 --- a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts +++ b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts @@ -90,6 +90,7 @@ export class MainStreetSvgTextureManager { { w: s.layout.marketCardW, h: s.layout.marketCardH }, { w: s.layout.slotW, h: s.layout.slotH }, { w: s.layout.handW, h: s.layout.handH }, + { w: s.layout.queueCardW, h: s.layout.queueCardH }, ]; for (const size of sizes) { diff --git a/example-games/main-street/scenes/UpgradeOverlaySpec.ts b/example-games/main-street/scenes/UpgradeOverlaySpec.ts index 7d2a7ed8..740a688f 100644 --- a/example-games/main-street/scenes/UpgradeOverlaySpec.ts +++ b/example-games/main-street/scenes/UpgradeOverlaySpec.ts @@ -39,6 +39,10 @@ export interface OverlayTextSpec { fontSize?: string; color?: string; fontStyle?: string; + /** Horizontal origin offset (0=left, 0.5=center, 1=right). Default 0. */ + originX?: number; + /** Vertical origin offset (0=top, 0.5=center, 1=bottom). Default 0. */ + originY?: number; } /** Describes a border/glow overlay for upgraded cards. */ @@ -51,7 +55,7 @@ export interface OverlayBorderSpec { export interface UpgradeOverlaySpec { /** Level badge text (e.g. "Lvl 2"), null for base cards. */ levelBadge: OverlayTextSpec | null; - /** Per-turn income text (e.g. "+3/turn"), null when total income is 0. */ + /** Per-turn income text (e.g. "Income: +3/turn"), null when total income is 0. */ incomeText: OverlayTextSpec | null; /** Per-turn reputation text (e.g. "+0.2/turn"), null when total reputation is 0. */ reputationText: OverlayTextSpec | null; @@ -89,30 +93,36 @@ export function buildUpgradeOverlaySpec( const totalReputation = (biz.reputationPerTurn ?? 0) + biz.reputationBonus; // Level badge: top-right corner, only for upgraded cards + // Container origin is at card centre, so subtract width/2 and height/2 + // to convert from absolute-card to container-local coordinates. const levelBadge: OverlayTextSpec | null = isUpgraded ? { text: `Lvl ${biz.level}`, - x: Math.round(width - 4), - y: 4, + x: Math.round(width / 2 - 4), + y: Math.round(4 - height / 2), fontSize: '10px', color: '#ffdd44', fontStyle: 'bold', } : null; - // Income text: bottom-left, shown for any card with income > 0 + // Income text: centred horizontally in the middle band of the card, shown for any card with income > 0 + // Uses "Income: +X/turn" format for clarity const incomeText: OverlayTextSpec | null = totalIncome > 0 ? { - text: `+${totalIncome}/turn`, - x: 8, - y: Math.round(height - 8), + text: `Income: +${totalIncome}/turn`, + x: Math.round(width / 2), + y: Math.round(height * 0.38), fontSize: '11px', color: '#44ff44', fontStyle: 'bold', + originX: 0.5, + originY: 0.5, } : null; - // Reputation text: bottom-right, shown for any card with reputation > 0 + // Reputation text: centred below income, shown for any card with reputation > 0 + // Format to at most 1 decimal place, stripping trailing zeros (e.g. 0.2, 0.3, 1.0 -> 1) const repFormatted = totalReputation > 0 ? (Number.isInteger(totalReputation) ? `${totalReputation}` : totalReputation.toFixed(1)) @@ -120,20 +130,24 @@ export function buildUpgradeOverlaySpec( const reputationText: OverlayTextSpec | null = totalReputation > 0 ? { text: `+${repFormatted}/turn`, - x: Math.round(width - 8), - y: Math.round(height - 8), + x: Math.round(width / 2), + y: Math.round(height * 0.5), fontSize: '11px', color: '#88bbff', fontStyle: 'bold', + originX: 0.5, + originY: 0.5, } : null; - // Name overlay: top center, only for upgraded cards to highlight the new name + // Name overlay: top centre, only for upgraded cards to highlight the new name + // Container origin is at card centre, so x=0 is horizontal centre + // and y = -height/2 + 16 places the top edge near the card's top. const nameText: OverlayTextSpec | null = isUpgraded ? { text: biz.name, - x: Math.round(width / 2), - y: 16, + x: 0, + y: Math.round(-height / 2 + 16), fontSize: '10px', color: '#ffffff', fontStyle: 'bold', diff --git a/example-games/sushi-go/scenes/SushiGoConstants.ts b/example-games/sushi-go/scenes/SushiGoConstants.ts index a8d173ac..d6332909 100644 --- a/example-games/sushi-go/scenes/SushiGoConstants.ts +++ b/example-games/sushi-go/scenes/SushiGoConstants.ts @@ -95,8 +95,15 @@ export const INSTRUCTION_FONT_SIZE = '15px'; // ── Chopsticks button ────────────────────────────────────── export const CHOPSTICKS_BUTTON_FONT_SIZE = '16px'; export const CHOPSTICKS_BUTTON_Y_OFFSET = 25; -export const CHOPSTICKS_BUTTON_PADDING_X = 12; -export const CHOPSTICKS_BUTTON_PADDING_Y = 6; +export const CHOPSTICKS_BUTTON_PADDING_X = 16; +export const CHOPSTICKS_BUTTON_PADDING_Y = 8; +export const CHOPSTICKS_BUTTON_BG = 0x1a3a2a; +export const CHOPSTICKS_BUTTON_HOVER_BG = 0x2a5a3a; +export const CHOPSTICKS_BUTTON_TEXT_COLOR = '#88ddff'; +export const CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR = '#ffffff'; +export const CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR = '#ff8888'; +export const CHOPSTICKS_BUTTON_RADIUS = 8; +export const CHOPSTICKS_BUTTON_DEPTH = 700; // ── Tooltip positioning ──────────────────────────────────── export const TOOLTIP_Y_OFFSET = 40; @@ -108,9 +115,44 @@ export const HIGHLIGHT_PADDING = 6; export const HIGHLIGHT_STROKE_WIDTH = 3; export const HIGHLIGHT_FILL_ALPHA = 0.15; +// ── Chopsticks tableau highlight (on the chopsticks card in tableau) ──── +export const CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR = 0x44aaff; +export const CHOPSTICKS_TABLEAU_ACTIVE_COLOR = 0xffaa44; +export const CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA = 0.2; +export const CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING = 8; +export const CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE = 2; + +// ── First card pick highlight (more prominent than general highlight) ── +export const HIGHLIGHT_FIRST_PICK_COLOR = 0x00ff66; +export const HIGHLIGHT_FIRST_PICK_STROKE_WIDTH = 4; +export const HIGHLIGHT_FIRST_PICK_FILL_ALPHA = 0.25; + +// ── Chopsticks cancel button ─────────────────────────────────────────── +export const CHOPSTICKS_CANCEL_FONT_SIZE = '14px'; +export const CHOPSTICKS_CANCEL_TEXT_COLOR = '#ff6666'; +export const CHOPSTICKS_CANCEL_HOVER_COLOR = '#ffffff'; +export const CHOPSTICKS_CANCEL_Y_OFFSET = 55; +export const CHOPSTICKS_CANCEL_PADDING_X = 14; +export const CHOPSTICKS_CANCEL_PADDING_Y = 6; +export const CHOPSTICKS_CANCEL_BG = 0x4a2a2a; +export const CHOPSTICKS_CANCEL_HOVER_BG = 0x6a3a3a; +export const CHOPSTICKS_CANCEL_DEPTH = 700; + +// ── Step indicator ───────────────────────────────────────────────────── +export const STEP_INDICATOR_FONT_SIZE = '15px'; +export const STEP_INDICATOR_COLOR = '#ffdd44'; +export const STEP_INDICATOR_Y_OFFSET = -40; +export const STEP_INDICATOR_DEPTH = 700; +export const STEP_INDICATOR_1_OF_2 = 'Step 1 of 2'; +export const STEP_INDICATOR_2_OF_2 = 'Step 2 of 2'; + // ── Turn animation delay ─────────────────────────────────── export const TURN_ANIMATION_DELAY = 300; +// ── Chopsticks small card highlight (hand suggestion) ────────────────── +export const CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR = 0x4488ff; +export const CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA = 0.08; + // Turn phase export type TurnPhase = | 'picking' diff --git a/example-games/sushi-go/scenes/SushiGoScene.ts b/example-games/sushi-go/scenes/SushiGoScene.ts index dc88f4e8..22356093 100644 --- a/example-games/sushi-go/scenes/SushiGoScene.ts +++ b/example-games/sushi-go/scenes/SushiGoScene.ts @@ -42,6 +42,7 @@ import helpContent from '../help-content.json'; import { SUSHI_ICON_FILES, HAND_Y, HAND_CARD_W, HAND_CARD_H, HAND_GAP, + TABLEAU_CARD_W, TABLEAU_CARD_H, PLAYER_TABLEAU_Y, AI_TABLEAU_Y, SCORE_AREA_X, PLAYER_SCORE_Y, AI_SCORE_Y, SFX_KEYS, @@ -49,9 +50,25 @@ import { TOOLTIP_BG_COLOR, TOOLTIP_BG_ALPHA, TOOLTIP_PADDING, TOOLTIP_FONT_SIZE, TOOLTIP_MAX_WIDTH, TOOLTIP_DEPTH, TOOLTIP_Y_OFFSET, TOOLTIP_CLAMP_BOUNDARY, TOOLTIP_FLIP_THRESHOLD, - HIGHLIGHT_PADDING, HIGHLIGHT_STROKE_WIDTH, HIGHLIGHT_FILL_ALPHA, + HIGHLIGHT_PADDING, CHOPSTICKS_BUTTON_Y_OFFSET, CHOPSTICKS_BUTTON_FONT_SIZE, CHOPSTICKS_BUTTON_PADDING_X, CHOPSTICKS_BUTTON_PADDING_Y, + CHOPSTICKS_BUTTON_BG, CHOPSTICKS_BUTTON_HOVER_BG, + CHOPSTICKS_BUTTON_TEXT_COLOR, CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR, + CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR, CHOPSTICKS_BUTTON_DEPTH, + CHOPSTICKS_CANCEL_BG, CHOPSTICKS_CANCEL_HOVER_BG, + CHOPSTICKS_CANCEL_TEXT_COLOR, CHOPSTICKS_CANCEL_HOVER_COLOR, + CHOPSTICKS_CANCEL_Y_OFFSET, CHOPSTICKS_CANCEL_PADDING_X, + CHOPSTICKS_CANCEL_PADDING_Y, CHOPSTICKS_CANCEL_DEPTH, + CHOPSTICKS_CANCEL_FONT_SIZE, + CHOPSTICKS_TABLEAU_ACTIVE_COLOR, CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR, + CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA, CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING, + CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE, + HIGHLIGHT_FIRST_PICK_COLOR, HIGHLIGHT_FIRST_PICK_STROKE_WIDTH, + HIGHLIGHT_FIRST_PICK_FILL_ALPHA, + STEP_INDICATOR_FONT_SIZE, STEP_INDICATOR_COLOR, + STEP_INDICATOR_Y_OFFSET, STEP_INDICATOR_DEPTH, + STEP_INDICATOR_1_OF_2, STEP_INDICATOR_2_OF_2, TURN_ANIMATION_DELAY, type TurnPhase, } from './SushiGoConstants'; @@ -74,6 +91,12 @@ export class SushiGoScene extends CardGameScene { chopsticksMode = false; chopsticksFirstPick: number | null = null; chopsticksButton: Phaser.GameObjects.Text | null = null; + chopsticksButtonBg: Phaser.GameObjects.Rectangle | null = null; + chopsticksCancelButton: Phaser.GameObjects.Text | null = null; + chopsticksCancelButtonBg: Phaser.GameObjects.Rectangle | null = null; + stepIndicator: Phaser.GameObjects.Text | null = null; + /** Reference to the first-pick highlight rectangle for cleanup. */ + firstPickHighlight: Phaser.GameObjects.Rectangle | null = null; // Transcript recording recorder: SushiGoTranscriptRecorder | null = null; @@ -161,12 +184,13 @@ export class SushiGoScene extends CardGameScene { onPhaseChange: (phase) => { if (phase === 'picking') { if (this.chopsticksMode) { - this.phaseManager.setPhaseText('picking', 'Chopsticks: click your 1st card'); + this.phaseManager.setPhaseText('picking', 'Pick 2 cards: click your 1st card'); } else { this.phaseManager.setPhaseText('picking', 'Click a card from your hand to pick it'); } this.refreshHand(); this.refreshChopsticksButton(); + this.refreshChopsticksTableauHighlight(); } }, }); @@ -175,6 +199,11 @@ export class SushiGoScene extends CardGameScene { this.chopsticksMode = false; this.chopsticksFirstPick = null; this.chopsticksButton = null; + this.chopsticksButtonBg = null; + this.chopsticksCancelButton = null; + this.chopsticksCancelButtonBg = null; + this.stepIndicator = null; + this.firstPickHighlight = null; this.overlayObjects = []; this.recorder = null; this.replayStepIndex = -1; @@ -446,22 +475,23 @@ export class SushiGoScene extends CardGameScene { this.refreshScores(); this.refreshRoundInfo(); this.refreshChopsticksButton(); + this.refreshChopsticksTableauHighlight(); } private refreshHand(): void { + // Clean up previous first-pick highlight + this.destroyFirstPickHighlight(); + const hand = this.session.players[0].hand; if (hand.length === 0) { this.handView.setCards([]); return; } - // Center the hand horizontally — baseX is the leftmost card X in HandView - const handSize = hand.length; - const spacing = HAND_CARD_W + HAND_GAP; - const leftmostX = GAME_W / 2 - (handSize - 1) * spacing / 2; - this.handView.setBaseX(leftmostX); - - // HandView manages layout and card creation via renderCard callback + // HandView manages layout and card creation via renderCard callback. + // No need to call setBaseX — layoutCardPositions already centres the + // hand around baseX (GAME_W / 2) so the row stays centred regardless + // of hand size. this.handView.setCards(hand as any); // Apply chopsticks highlight to the first picked card (if in chopsticks mode) @@ -469,12 +499,20 @@ export class SushiGoScene extends CardGameScene { const sprite = this.handView.getSpriteAt(this.chopsticksFirstPick); if (sprite) { const container = sprite as Phaser.GameObjects.Container; - const highlight = this.add.rectangle( - 0, 0, HAND_CARD_W + HIGHLIGHT_PADDING, HAND_CARD_H + HIGHLIGHT_PADDING, + this.firstPickHighlight = this.add.rectangle( + 0, 0, + HAND_CARD_W + HIGHLIGHT_PADDING, + HAND_CARD_H + HIGHLIGHT_PADDING, + ); + this.firstPickHighlight.setStrokeStyle( + HIGHLIGHT_FIRST_PICK_STROKE_WIDTH, + HIGHLIGHT_FIRST_PICK_COLOR, ); - highlight.setStrokeStyle(HIGHLIGHT_STROKE_WIDTH, 0x00ff88); - highlight.setFillStyle(0x00ff88, HIGHLIGHT_FILL_ALPHA); - container.addAt(highlight, 0); + this.firstPickHighlight.setFillStyle( + HIGHLIGHT_FIRST_PICK_COLOR, + HIGHLIGHT_FIRST_PICK_FILL_ALPHA, + ); + container.addAt(this.firstPickHighlight, 0); } } } @@ -513,7 +551,9 @@ export class SushiGoScene extends CardGameScene { if (this.chopsticksMode) { if (this.chopsticksFirstPick === null) { this.chopsticksFirstPick = handIndex; - this.instructionText.setText('Chopsticks: click your 2nd card (Esc to cancel)'); + this.instructionText.setText('Pick 2 cards: click your 2nd card'); + this.showStepIndicator(2); + this.showCancelButton(); this.soundManager?.play(SFX_KEYS.CARD_PICK); this.refreshHand(); } else { @@ -525,6 +565,9 @@ export class SushiGoScene extends CardGameScene { this.soundManager?.play(SFX_KEYS.CARD_PICK); this.chopsticksMode = false; this.chopsticksFirstPick = null; + this.hideStepIndicator(); + this.destroyCancelButton(); + this.destroyFirstPickHighlight(); this.executeTurn(); } } else { @@ -543,10 +586,15 @@ export class SushiGoScene extends CardGameScene { } private refreshChopsticksButton(): void { + // Clean up existing button if (this.chopsticksButton) { this.chopsticksButton.destroy(); this.chopsticksButton = null; } + if (this.chopsticksButtonBg) { + this.chopsticksButtonBg.destroy(); + this.chopsticksButtonBg = null; + } const shouldShow = this.phaseManager.current === 'picking' && @@ -555,24 +603,32 @@ export class SushiGoScene extends CardGameScene { if (!shouldShow) { if (this.chopsticksMode) { - this.chopsticksMode = false; - this.chopsticksFirstPick = null; + this.cancelChopsticksMode(); } return; } - const label = this.chopsticksMode ? '[ Cancel Chopsticks ]' : '[ Use Chopsticks ]'; - const color = this.chopsticksMode ? '#ff8888' : '#88ddff'; + const label = this.chopsticksMode ? '✕ Cancel' : 'Use Chopsticks'; + const textColor = this.chopsticksMode ? CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR : CHOPSTICKS_BUTTON_TEXT_COLOR; + const bgColor = this.chopsticksMode ? CHOPSTICKS_CANCEL_BG : CHOPSTICKS_BUTTON_BG; + + const btnX = GAME_W / 2; + const btnY = HAND_Y - HAND_CARD_H / 2 - CHOPSTICKS_BUTTON_Y_OFFSET; + + // Background rectangle + this.chopsticksButtonBg = this.add.rectangle(btnX, btnY, 0, 0, bgColor) + .setDepth(CHOPSTICKS_BUTTON_DEPTH); + // Button text this.chopsticksButton = this.add - .text(GAME_W / 2, HAND_Y - HAND_CARD_H / 2 - CHOPSTICKS_BUTTON_Y_OFFSET, label, { + .text(btnX, btnY, label, { fontSize: CHOPSTICKS_BUTTON_FONT_SIZE, - color, + color: textColor, fontFamily: FONT_FAMILY, - backgroundColor: '#2a3a4a', padding: { x: CHOPSTICKS_BUTTON_PADDING_X, y: CHOPSTICKS_BUTTON_PADDING_Y }, }) .setOrigin(0.5) + .setDepth(CHOPSTICKS_BUTTON_DEPTH + 1) .setInteractive({ useHandCursor: true }) .on('pointerdown', () => { this.soundManager?.play(SFX_KEYS.UI_CLICK); @@ -583,19 +639,34 @@ export class SushiGoScene extends CardGameScene { } }) .on('pointerover', () => { - this.chopsticksButton?.setStyle({ color: '#ffffff' }); + this.chopsticksButton?.setStyle({ color: CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR }); + if (this.chopsticksButtonBg) { + this.chopsticksButtonBg.setFillStyle(CHOPSTICKS_BUTTON_HOVER_BG); + } }) .on('pointerout', () => { - this.chopsticksButton?.setStyle({ - color: this.chopsticksMode ? '#ff8888' : '#88ddff', - }); + this.chopsticksButton?.setStyle({ color: textColor }); + if (this.chopsticksButtonBg) { + this.chopsticksButtonBg.setFillStyle(bgColor); + } }); + + // Size the background to match the text + this.updateButtonBgSize(); + } + + private updateButtonBgSize(): void { + if (!this.chopsticksButton || !this.chopsticksButtonBg) return; + const w = this.chopsticksButton.width + CHOPSTICKS_BUTTON_PADDING_X * 2; + const h = this.chopsticksButton.height + CHOPSTICKS_BUTTON_PADDING_Y * 2; + this.chopsticksButtonBg.setSize(w, h); } private enterChopsticksMode(): void { this.chopsticksMode = true; this.chopsticksFirstPick = null; - this.instructionText.setText('Chopsticks: click your 1st card'); + this.instructionText.setText('Pick 2 cards: click your 1st card'); + this.showStepIndicator(1); this.refreshHand(); this.refreshChopsticksButton(); } @@ -604,10 +675,171 @@ export class SushiGoScene extends CardGameScene { this.chopsticksMode = false; this.chopsticksFirstPick = null; this.instructionText.setText('Click a card from your hand to pick it'); + this.hideStepIndicator(); + this.destroyCancelButton(); + this.destroyFirstPickHighlight(); this.refreshHand(); this.refreshChopsticksButton(); } + // ── Cancel button (shown during chopsticks mode) ──────── + + private showCancelButton(): void { + this.destroyCancelButton(); + + const btnX = GAME_W / 2; + const btnY = HAND_Y + CHOPSTICKS_CANCEL_Y_OFFSET; + + this.chopsticksCancelButtonBg = this.add.rectangle(btnX, btnY, 0, 0, CHOPSTICKS_CANCEL_BG) + .setDepth(CHOPSTICKS_CANCEL_DEPTH); + + this.chopsticksCancelButton = this.add + .text(btnX, btnY, '✕ Cancel', { + fontSize: CHOPSTICKS_CANCEL_FONT_SIZE, + color: CHOPSTICKS_CANCEL_TEXT_COLOR, + fontFamily: FONT_FAMILY, + padding: { x: CHOPSTICKS_CANCEL_PADDING_X, y: CHOPSTICKS_CANCEL_PADDING_Y }, + }) + .setOrigin(0.5) + .setDepth(CHOPSTICKS_CANCEL_DEPTH + 1) + .setInteractive({ useHandCursor: true }) + .on('pointerdown', () => { + this.soundManager?.play(SFX_KEYS.UI_CLICK); + this.cancelChopsticksMode(); + }) + .on('pointerover', () => { + this.chopsticksCancelButton?.setStyle({ color: CHOPSTICKS_CANCEL_HOVER_COLOR }); + if (this.chopsticksCancelButtonBg) { + this.chopsticksCancelButtonBg.setFillStyle(CHOPSTICKS_CANCEL_HOVER_BG); + } + }) + .on('pointerout', () => { + this.chopsticksCancelButton?.setStyle({ color: CHOPSTICKS_CANCEL_TEXT_COLOR }); + if (this.chopsticksCancelButtonBg) { + this.chopsticksCancelButtonBg.setFillStyle(CHOPSTICKS_CANCEL_BG); + } + }); + + // Size bg to text + if (this.chopsticksCancelButton && this.chopsticksCancelButtonBg) { + const w = this.chopsticksCancelButton.width + CHOPSTICKS_CANCEL_PADDING_X * 2; + const h = this.chopsticksCancelButton.height + CHOPSTICKS_CANCEL_PADDING_Y * 2; + this.chopsticksCancelButtonBg.setSize(w, h); + } + } + + private destroyCancelButton(): void { + if (this.chopsticksCancelButton) { + this.chopsticksCancelButton.destroy(); + this.chopsticksCancelButton = null; + } + if (this.chopsticksCancelButtonBg) { + this.chopsticksCancelButtonBg.destroy(); + this.chopsticksCancelButtonBg = null; + } + } + + // ── Step indicator ────────────────────────────────────── + + private showStepIndicator(step: 1 | 2): void { + this.hideStepIndicator(); + + const text = step === 1 ? STEP_INDICATOR_1_OF_2 : STEP_INDICATOR_2_OF_2; + const btnY = HAND_Y - HAND_CARD_H / 2 - CHOPSTICKS_BUTTON_Y_OFFSET + STEP_INDICATOR_Y_OFFSET; + + this.stepIndicator = this.add + .text(GAME_W / 2, btnY, text, { + fontSize: STEP_INDICATOR_FONT_SIZE, + color: STEP_INDICATOR_COLOR, + fontFamily: FONT_FAMILY, + }) + .setOrigin(0.5) + .setDepth(STEP_INDICATOR_DEPTH); + } + + private hideStepIndicator(): void { + if (this.stepIndicator) { + this.stepIndicator.destroy(); + this.stepIndicator = null; + } + } + + // ── First pick highlight cleanup ──────────────────────── + + private destroyFirstPickHighlight(): void { + if (this.firstPickHighlight) { + this.firstPickHighlight.destroy(); + this.firstPickHighlight = null; + } + } + + // ── Chopsticks tableau highlight ──────────────────────── + + /** Add a highlight to chopsticks cards in the player's tableau. */ + private refreshChopsticksTableauHighlight(): void { + // Remove existing chopsticks highlights from tableau + this.removeTableauHighlights(); + + const shouldHighlight = + this.phaseManager.current === 'picking' && + this.humanHasChopsticks() && + this.session.players[0].hand.length >= 2; + + if (!shouldHighlight) return; + + const highlightColor = this.chopsticksMode + ? CHOPSTICKS_TABLEAU_ACTIVE_COLOR + : CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR; + + // Find chopsticks card containers in the player's tableau + const children = this.playerTableauContainer.getAll(); + for (const child of children) { + if (!(child instanceof Phaser.GameObjects.Container)) continue; + const cardId = child.getData('cardId'); + if (cardId === undefined) continue; + + // Check if this card is a chopsticks card + const isChopsticks = this.session.players[0].tableau.some( + (c) => c.id === cardId && c.type === 'chopsticks', + ); + if (!isChopsticks) continue; + + // Add highlight rectangle + const highlight = this.add.rectangle( + 0, 0, + TABLEAU_CARD_W + CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING * 2, + TABLEAU_CARD_H + CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING * 2, + ); + highlight.setStrokeStyle( + CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE, + highlightColor, + ); + highlight.setFillStyle(highlightColor, CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA); + child.addAt(highlight, 0); + } + } + + /** Remove chopsticks highlight rectangles from the player tableau. */ + private removeTableauHighlights(): void { + const children = this.playerTableauContainer.getAll(); + for (const child of children) { + if (!(child instanceof Phaser.GameObjects.Container)) continue; + // Remove any highlight rectangles (identified by stroke style + fill) + // Phaser rectangles don't have a custom tag, so we search by type and + // look for rectangles that were added at index 0 (our highlights) + const toRemove: Phaser.GameObjects.Rectangle[] = []; + const grandChildren = child.getAll(); + for (const gc of grandChildren) { + if (gc instanceof Phaser.GameObjects.Rectangle) { + toRemove.push(gc); + } + } + for (const r of toRemove) { + r.destroy(); + } + } + } + // ── Turn execution ────────────────────────────────────── private executeTurn(): void { @@ -694,6 +926,23 @@ export class SushiGoScene extends CardGameScene { this.chopsticksButton.destroy(); this.chopsticksButton = null; } + if (this.chopsticksButtonBg) { + this.chopsticksButtonBg.destroy(); + this.chopsticksButtonBg = null; + } + if (this.chopsticksCancelButton) { + this.chopsticksCancelButton.destroy(); + this.chopsticksCancelButton = null; + } + if (this.chopsticksCancelButtonBg) { + this.chopsticksCancelButtonBg.destroy(); + this.chopsticksCancelButtonBg = null; + } + if (this.stepIndicator) { + this.stepIndicator.destroy(); + this.stepIndicator = null; + } + this.destroyFirstPickHighlight(); dismissOverlay(this.overlayObjects); this.overlayObjects = []; this.shutdownBase(); diff --git a/example-games/the-mind/AiStrategy.ts b/example-games/the-mind/AiStrategy.ts deleted file mode 100644 index e763d775..00000000 --- a/example-games/the-mind/AiStrategy.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * AiStrategy.ts - * - * AI timing strategy for The Mind. - * Pure logic — no Phaser dependency. Computes per-card play delays using - * a linear proportional formula with seeded jitter: - * - * delay = (cardValue / 100) × baseDuration + jitter - * - * where jitter is drawn from [−jitterRange, +jitterRange] using a seeded - * RNG for deterministic replay. Each card gets its own independent timer; - * timers are committed once at the start of each level and never reset - * (even when the pile top changes from a partner play). - * - * Scene integration (Feature 6) will schedule these computed delays using - * Phaser time events. - * - * @module - */ - -import type { MindCard } from './MindCard'; -import type { AiStrategyBase } from '../../src/ai'; -import { AiPlayer as AiPlayerBase } from '../../src/ai'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Default base duration in milliseconds. */ -export const DEFAULT_BASE_DURATION = 10000; - -/** Default jitter range (±ms). */ -export const DEFAULT_JITTER_RANGE = 800; - -/** Minimum delay in ms — prevents the AI from playing instantly. */ -export const MIN_PLAY_DELAY = 1500; - -/** - * Short delay (ms) used when the opponent's hand is empty. Once one - * player has finished, the remaining player's cards are obvious plays so - * a long wait feels unnatural. - */ -export const AI_LAST_CARD_DELAY = 400; - -/** - * Minimum delay (ms) enforced when the card to play is close in value - * to the pile top. Creates a natural hesitation for tight sequences. - */ -export const PROXIMITY_MIN_DELAY = 1000; - -/** - * Maximum value gap between the card and the pile top for the proximity - * delay to apply. Cards within this distance of the pile top will - * always wait at least {@link PROXIMITY_MIN_DELAY}. - */ -export const PROXIMITY_THRESHOLD = 5; - -/** - * Compute the effective AI play delay for a scheduled card. - * - * When the opponent's hand is empty, returns {@link AI_LAST_CARD_DELAY} - * so the remaining cards are played quickly. Otherwise falls back to the - * normal elapsed-time calculation (clamped to a 100 ms floor). - * - * If `cardValue` and `pileTopValue` are provided and the card is within - * {@link PROXIMITY_THRESHOLD} of the pile top, the delay is raised to at - * least {@link PROXIMITY_MIN_DELAY} so the AI visibly hesitates before - * playing a card close in value to the last one laid. - */ -export function computeEffectiveDelay( - committedDelay: number, - elapsedSinceLevelStart: number, - _playerHandSize: number, - opponentHandSize: number, - cardValue?: number, - pileTopValue?: number, -): number { - if (opponentHandSize === 0) { - return AI_LAST_CARD_DELAY; - } - let delay = Math.max(committedDelay - elapsedSinceLevelStart, 100); - - // Enforce proximity delay when the card is close to the pile top - if ( - cardValue !== undefined && - pileTopValue !== undefined && - pileTopValue > 0 && - cardValue - pileTopValue <= PROXIMITY_THRESHOLD - ) { - delay = Math.max(delay, PROXIMITY_MIN_DELAY); - } - - return delay; -} - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** A single card's computed delay. */ -export interface CardDelay { - /** The card this delay applies to. */ - readonly card: MindCard; - /** Computed delay in milliseconds (may be negative before clamping). */ - readonly delay: number; -} - -/** Configuration for the timing strategy. */ -export interface MindAiTimingConfig { - /** - * Base duration in milliseconds used to scale card values. - * Must be > 0 (throws otherwise). - */ - readonly baseDuration: number; - /** - * Symmetric jitter range in milliseconds. - * Jitter is drawn uniformly from [−jitterRange, +jitterRange]. - * Must be >= 0. Defaults to {@link DEFAULT_JITTER_RANGE}. - */ - readonly jitterRange: number; -} - -// --------------------------------------------------------------------------- -// Strategy interface -// --------------------------------------------------------------------------- - -/** - * AI strategy for The Mind. - * - * Extends {@link AiStrategyBase} with a method to compute per-card play - * delays for all held cards. - */ -export interface MindAiStrategy extends AiStrategyBase { - /** - * Compute play delays for every card in the provided hand. - * - * Each card's delay is independent: - * delay = (card.value / 100) × baseDuration + jitter - * - * Jitter is drawn from a seeded RNG so results are deterministic - * for a given seed. - * - * @param hand - The cards currently held (not mutated). - * @param config - Timing parameters (baseDuration, jitterRange). - * @param rng - Seeded RNG returning values in [0, 1). - * @returns An array of CardDelay entries (unsorted). - */ - computeDelays( - hand: ReadonlyArray, - config: MindAiTimingConfig, - rng: () => number, - ): CardDelay[]; -} - -// --------------------------------------------------------------------------- -// Linear proportional strategy -// --------------------------------------------------------------------------- - -/** - * The standard timing strategy. - * - * Uses a linear proportional model where higher-value cards have longer - * delays, with random jitter to add unpredictability. - */ -export const LinearTimingStrategy: MindAiStrategy = { - name: 'LinearTiming', - - computeDelays( - hand: ReadonlyArray, - config: MindAiTimingConfig, - rng: () => number, - ): CardDelay[] { - if (config.baseDuration <= 0) { - throw new Error( - `baseDuration must be positive, got ${config.baseDuration}`, - ); - } - - return hand.map((card) => { - // jitter ∈ [−jitterRange, +jitterRange] - const jitter = (rng() * 2 - 1) * config.jitterRange; - const raw = (card.value / 100) * config.baseDuration + jitter; - // Enforce minimum delay so the AI never plays instantly - const delay = Math.max(raw, MIN_PLAY_DELAY); - return { card, delay }; - }); - }, -}; - -// --------------------------------------------------------------------------- -// AI Player class -// --------------------------------------------------------------------------- - -/** - * AI player for The Mind that wraps a timing strategy and manages - * committed per-card delays across a level. - * - * Usage: - * 1. Call {@link commitLevel} at the start of each level to compute - * and lock in delays for the AI's hand. - * 2. Call {@link getCardDelays} to retrieve all committed delays - * sorted by earliest fire time. - * 3. Call {@link getNextCard} to get the card with the shortest delay. - * 4. Call {@link removeCard} when a card is played (by AI or discarded - * via penalty) — remaining timers are NOT reset. - * - * Scene integration will poll {@link getNextCard} and schedule a Phaser - * time event for its delay, cancelling/rescheduling if a penalty - * removes the card before it fires. - */ -export class MindAiPlayer extends AiPlayerBase { - private committedDelays: CardDelay[] = []; - private readonly config: MindAiTimingConfig; - - constructor( - strategy: MindAiStrategy = LinearTimingStrategy, - rng: () => number = Math.random, - config?: Partial, - ) { - super(strategy, rng); - this.config = { - baseDuration: config?.baseDuration ?? DEFAULT_BASE_DURATION, - jitterRange: config?.jitterRange ?? DEFAULT_JITTER_RANGE, - }; - } - - /** - * Commit delays for a new level. - * - * Computes independent delays for every card in the AI's hand - * and locks them in for the duration of the level. Must be called - * once at the start of each level. - * - * After computing raw delays (which include jitter), the delays are - * adjusted to enforce **monotonic ordering by card value**: a card - * with a higher value will never have a shorter delay than a card - * with a lower value. This prevents the AI from playing cards out - * of ascending order from its own hand. - * - * @param hand - The AI's dealt hand for this level. - */ - commitLevel(hand: ReadonlyArray): void { - const raw = this.strategy.computeDelays(hand, this.config, this.rng); - - // Sort by card value ascending first, so we can enforce monotonicity - raw.sort((a, b) => a.card.value - b.card.value); - - // Enforce monotonic delays: each card's delay must be >= the previous - // card's delay. If jitter caused a higher-value card to have a shorter - // delay, bump it up. This guarantees the AI always plays its lowest - // card first. - for (let i = 1; i < raw.length; i++) { - if (raw[i].delay < raw[i - 1].delay) { - raw[i] = { card: raw[i].card, delay: raw[i - 1].delay }; - } - } - - // Result is already sorted by delay ascending (since card-value order - // now equals delay order after monotonic enforcement). - this.committedDelays = raw; - } - - /** - * Get all committed card delays, sorted by earliest fire time. - * - * Returns a defensive copy so callers cannot mutate internal state. - */ - getCardDelays(): CardDelay[] { - return [...this.committedDelays]; - } - - /** - * Get the card with the shortest remaining delay (next to play). - * - * @returns The CardDelay with the smallest delay, or `undefined` - * if no cards remain. - */ - getNextCard(): CardDelay | undefined { - return this.committedDelays[0]; - } - - /** - * Remove a card from the committed delays. - * - * Called when the card is played or discarded via penalty. - * Remaining card timers are NOT reset — they continue as committed. - * - * @param cardValue - The value of the card to remove. - * @returns `true` if the card was found and removed. - */ - removeCard(cardValue: number): boolean { - const idx = this.committedDelays.findIndex( - (d) => d.card.value === cardValue, - ); - if (idx === -1) return false; - this.committedDelays.splice(idx, 1); - return true; - } - - /** - * Check whether the AI has any committed cards remaining. - */ - hasCards(): boolean { - return this.committedDelays.length > 0; - } - - /** - * Get the timing configuration. - */ - getConfig(): Readonly { - return this.config; - } -} diff --git a/example-games/the-mind/GameTranscript.ts b/example-games/the-mind/GameTranscript.ts deleted file mode 100644 index f59d95a8..00000000 --- a/example-games/the-mind/GameTranscript.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * GameTranscript.ts - * - * Transcript types and recorder for The Mind. - * - * Records a replay-ready JSON transcript capturing all real-time events: - * card plays, penalties (life loss + discarded cards), level completions, - * and the final game outcome. Unlike turn-based games, The Mind events - * are timestamped relative to the level start to capture the real-time - * timing dimension. - * - * The recorder follows the engine's TranscriptRecorderBase pattern. - * Call the record methods as events occur, then finalize() when the game - * ends to seal the transcript. - * - * @module - */ - -import { TranscriptRecorderBase } from '../../src/core-engine/transcript'; -import type { PlayerId } from './TheMindGameState'; - -// --------------------------------------------------------------------------- -// Event types -// --------------------------------------------------------------------------- - -/** A card play event. */ -export interface MindCardPlayedEvent { - readonly type: 'card-played'; - /** Milliseconds since the current level started. */ - readonly timestamp: number; - /** Which player played the card (0 = human, 1 = AI). */ - readonly playerId: PlayerId; - /** The value of the played card. */ - readonly cardValue: number; - /** The pile top value AFTER this play. */ - readonly pileTopAfter: number; - /** Total cards on the pile after this play. */ - readonly pileSizeAfter: number; -} - -/** A penalty event (life lost, lower cards discarded). */ -export interface MindPenaltyEvent { - readonly type: 'penalty'; - /** Milliseconds since the current level started. */ - readonly timestamp: number; - /** Lives remaining AFTER this penalty. */ - readonly livesRemaining: number; - /** Cards discarded due to this penalty. */ - readonly discardedCards: ReadonlyArray<{ - readonly playerId: PlayerId; - readonly cardValue: number; - }>; -} - -/** A level completion event. */ -export interface MindLevelCompleteEvent { - readonly type: 'level-complete'; - /** Milliseconds since the completed level started. */ - readonly timestamp: number; - /** The level that was just completed. */ - readonly level: number; - /** Whether a bonus life was awarded. */ - readonly bonusLifeAwarded: boolean; - /** Lives remaining after any bonus award. */ - readonly livesAfter: number; - /** - * Cards dealt to each player for the NEXT level (values only). - * Undefined when the completed level is the final level (game won) - * since no new cards are dealt. Added in version 2 to support - * multi-level replay state reconstruction. - */ - readonly handsDealt?: [readonly number[], readonly number[]]; -} - -/** The final game outcome event. */ -export interface MindGameOverEvent { - readonly type: 'game-over'; - /** Milliseconds since the last level started. */ - readonly timestamp: number; - /** 'win' or 'loss'. */ - readonly outcome: 'win' | 'loss'; - /** The final level reached. */ - readonly finalLevel: number; - /** Lives remaining at game end. */ - readonly finalLives: number; -} - -/** Union of all Mind transcript event types. */ -export type MindEvent = - | MindCardPlayedEvent - | MindPenaltyEvent - | MindLevelCompleteEvent - | MindGameOverEvent; - -// --------------------------------------------------------------------------- -// Initial state snapshot -// --------------------------------------------------------------------------- - -/** Snapshot of the initial game state (before any plays). */ -export interface MindInitialState { - /** Player names. */ - readonly playerNames: [string, string]; - /** Which players are AI-controlled. */ - readonly isAI: [boolean, boolean]; - /** Starting lives. */ - readonly startingLives: number; - /** Starting level. */ - readonly startingLevel: number; - /** Cards dealt to each player at level start (values only). */ - readonly hands: [readonly number[], readonly number[]]; -} - -// --------------------------------------------------------------------------- -// Game results -// --------------------------------------------------------------------------- - -/** Final game results. */ -export interface MindGameResults { - /** 'win' or 'loss'. */ - readonly outcome: 'win' | 'loss'; - /** Final level reached. */ - readonly finalLevel: number; - /** Lives remaining at game end. */ - readonly finalLives: number; - /** Total cards played across all levels. */ - readonly totalCardsPlayed: number; - /** Total penalties incurred across all levels. */ - readonly totalPenalties: number; -} - -// --------------------------------------------------------------------------- -// Transcript -// --------------------------------------------------------------------------- - -/** A complete The Mind game transcript. */ -export interface MindTranscript { - /** Format version. Version 2 adds handsDealt to level-complete events. */ - readonly version: 1 | 2; - /** Game identifier. */ - readonly gameType: 'the-mind'; - /** ISO 8601 timestamp when the game started. */ - startedAt: string; - /** ISO 8601 timestamp when the game ended (empty until finalized). */ - endedAt: string; - /** Initial state snapshot. */ - readonly initialState: MindInitialState; - /** All recorded events in chronological order. */ - readonly events: MindEvent[]; - /** Final results (null until finalized). */ - results: MindGameResults | null; -} - -// --------------------------------------------------------------------------- -// Recorder -// --------------------------------------------------------------------------- - -/** - * Records a The Mind game transcript by capturing real-time events. - * - * Usage: - * 1. Create a recorder at game start with the initial state. - * 2. Call recordCardPlay() after each successful card play. - * 3. Call recordPenalty() when a penalty occurs (life lost). - * 4. Call recordLevelComplete() when a level is cleared. - * 5. Call finalize() when the game ends (win or loss). - * - * After finalize(), all record methods become no-ops (transcript is sealed). - */ -export class MindTranscriptRecorder extends TranscriptRecorderBase { - private sealed = false; - private cardsPlayedCount = 0; - private penaltyCount = 0; - - constructor(initialState: MindInitialState) { - super({ - version: 2, - gameType: 'the-mind', - startedAt: new Date().toISOString(), - endedAt: '', - initialState, - events: [], - results: null, - }); - } - - /** - * Record a card play event. - * - * @param timestamp - Milliseconds since the current level started. - * @param playerId - Which player played (0 or 1). - * @param cardValue - The value of the played card. - * @param pileTopAfter - Pile top value after the play. - * @param pileSizeAfter - Pile size after the play. - */ - recordCardPlay( - timestamp: number, - playerId: PlayerId, - cardValue: number, - pileTopAfter: number, - pileSizeAfter: number, - ): void { - if (this.sealed) return; - - this.transcript.events.push({ - type: 'card-played', - timestamp, - playerId, - cardValue, - pileTopAfter, - pileSizeAfter, - }); - this.cardsPlayedCount++; - } - - /** - * Record a penalty event. - * - * @param timestamp - Milliseconds since the current level started. - * @param livesRemaining - Lives remaining after the penalty. - * @param discardedCards - Cards discarded due to the penalty. - */ - recordPenalty( - timestamp: number, - livesRemaining: number, - discardedCards: ReadonlyArray<{ - playerId: PlayerId; - cardValue: number; - }>, - ): void { - if (this.sealed) return; - - this.transcript.events.push({ - type: 'penalty', - timestamp, - livesRemaining, - discardedCards, - }); - this.penaltyCount++; - } - - /** - * Record a level completion event. - * - * @param timestamp - Milliseconds since the completed level started. - * @param level - The level that was completed. - * @param bonusLifeAwarded - Whether a bonus life was awarded. - * @param livesAfter - Lives remaining after any bonus. - * @param handsDealt - Cards dealt for the next level (omit for final level). - */ - recordLevelComplete( - timestamp: number, - level: number, - bonusLifeAwarded: boolean, - livesAfter: number, - handsDealt?: [readonly number[], readonly number[]], - ): void { - if (this.sealed) return; - - const event: MindLevelCompleteEvent = { - type: 'level-complete', - timestamp, - level, - bonusLifeAwarded, - livesAfter, - ...(handsDealt ? { handsDealt } : {}), - }; - - this.transcript.events.push(event); - } - - /** - * Finalize the transcript and seal it. - * - * After calling this, all record methods become no-ops. - * - * @param timestamp - Milliseconds since the last level started. - * @param outcome - 'win' or 'loss'. - * @param finalLevel - The final level reached. - * @param finalLives - Lives remaining at game end. - * @returns The sealed transcript. - */ - finalize( - timestamp: number, - outcome: 'win' | 'loss', - finalLevel: number, - finalLives: number, - ): MindTranscript { - if (this.sealed) return this.transcript; - - this.transcript.events.push({ - type: 'game-over', - timestamp, - outcome, - finalLevel, - finalLives, - }); - - this.transcript.endedAt = new Date().toISOString(); - this.transcript.results = { - outcome, - finalLevel, - finalLives, - totalCardsPlayed: this.cardsPlayedCount, - totalPenalties: this.penaltyCount, - }; - - this.sealed = true; - return this.transcript; - } - - /** Check whether the transcript has been sealed. */ - isSealed(): boolean { - return this.sealed; - } -} diff --git a/example-games/the-mind/MindCard.ts b/example-games/the-mind/MindCard.ts deleted file mode 100644 index 792fb954..00000000 --- a/example-games/the-mind/MindCard.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * The Mind card type definitions and deck creation. - * - * The Mind uses 100 cards numbered 1 through 100. Players cooperatively - * play cards in ascending order without communicating. Each card has - * a numeric value and a faceUp state. - * - * Unlike the standard card-system Card (rank/suit), The Mind uses - * a simple numbered card type with values 1-100. - */ - -// ── Card type ─────────────────────────────────────────────── - -/** - * A single card in The Mind. - * - * Each card has a unique value from 1 to 100 and a mutable - * faceUp state indicating whether the card is visible. - */ -export interface MindCard { - /** Unique card value (1-100), also serves as the card's identity. */ - readonly value: number; - /** Whether the card is face-up (visible to players). */ - faceUp: boolean; -} - -// ── Constants ─────────────────────────────────────────────── - -/** Total number of cards in a Mind deck. */ -export const DECK_SIZE = 100; - -/** Minimum card value. */ -export const MIN_VALUE = 1; - -/** Maximum card value. */ -export const MAX_VALUE = 100; - -// ── Card display helpers ──────────────────────────────────── - -/** - * Human-readable label for a Mind card. - * Returns the card's numeric value as a string. - */ -export function cardLabel(card: MindCard): string { - return `${card.value}`; -} - -/** - * Asset key for loading the card's image. - * Follows the naming convention: `mind-{value}`. - * Examples: "mind-1", "mind-42", "mind-100" - */ -export function cardAssetKey(card: MindCard): string { - return `mind-${card.value}`; -} - -/** Asset key for the card back image. */ -export const CARD_BACK_KEY = 'mind-back'; - -// ── Deck creation ─────────────────────────────────────────── - -/** - * Create the full 100-card Mind deck (unshuffled). - * - * Cards are numbered 1 through 100 in ascending order. - * All cards are created face-down by default. - */ -export function createMindDeck(): MindCard[] { - const deck: MindCard[] = []; - - for (let value = MIN_VALUE; value <= MAX_VALUE; value++) { - deck.push({ value, faceUp: false }); - } - - return deck; -} - -// ── Shuffle re-export ─────────────────────────────────────── - -/** - * Fisher-Yates shuffle (in-place) with optional RNG. - * - * Re-exports `shuffleArray` from the shared card-system module, - * aliased as `shuffleDeck` for consistency with other example games. - * - * @returns The same array reference (mutated). - */ -import { shuffleArray } from '../../src/card-system/Deck'; - -export const shuffleDeck: typeof shuffleArray = shuffleArray; diff --git a/example-games/the-mind/MindCardRenderer.ts b/example-games/the-mind/MindCardRenderer.ts deleted file mode 100644 index 9aa80a70..00000000 --- a/example-games/the-mind/MindCardRenderer.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Mind Card Renderer - * - * Provides utilities for loading and displaying Mind card SVG assets - * in a Phaser scene. Cards are lazy-rasterised on first use via - * SvgHelpers.getOrCreateTexture, which produces DPR-aware texture keys. - * - * Migration notes (CG-0MP12H40Q003Y7OU): - * - preloadMindCardAssets is now registration-only in browser runtimes: - * it marks the scene as valid for SvgHelpers but does NOT eagerly - * rasterise SVG files via scene.load.svg. Textures are created lazily - * on first call to ensureMindCardTexture. - * - ensureMindCardTexture returns DPR-aware texture keys - * (e.g. ms_card_mind-42_48x65@2) via SvgHelpers.getOrCreateTexture. - * - The Node/test preload path continues to populate svgTextCache so - * that headless tests can access SVG source text without a browser. - * - Callers that need stable or legacy keys should use - * MindCardTextureAdapter (see MindCardTextureAdapter.ts). - */ - -import { - getOrCreateTexture, - fetchSvgText, - markSceneValid, - makeTextureKey, -} from '../../src/core-engine/SvgHelpers'; -import type { MindCard } from './MindCard'; -import { cardAssetKey, CARD_BACK_KEY, MIN_VALUE, MAX_VALUE } from './MindCard'; - -// ── Constants ────────────────────────────────────────────── - -/** Default card sprite width in pixels (matches SVG source at 140px). */ -export const MIND_CARD_W = 48; - -/** Default card sprite height in pixels (matches SVG source at 190px). */ -export const MIND_CARD_H = 65; - -/** Base path to The Mind card SVG assets (relative to Vite public dir). */ -const ASSET_PATH = 'assets/cards/the-mind'; - -// Module-level cache for SVG source text when running in Node (tests) or -// when preload reads files. Keys are template IDs (e.g. 'mind-42' or -// 'mind-back'). -const svgTextCache = new Map(); - -// ── Texture key helpers ──────────────────────────────────── - -/** - * Return the Phaser texture key for a MindCard, taking faceUp state - * into account. Face-down cards return the card back key. - * - * NOTE: This returns the *template ID* (e.g. 'mind-42' or 'mind-back'), - * NOT the DPR-aware texture key. For DPR-aware keys, use - * MindCardTextureAdapter.getCanonicalTextureKey() or ensureMindCardTexture(). - * - * @param card The MindCard to get a texture key for. - * @returns The template ID string (e.g. 'mind-42' or 'mind-back'). - * @throws Error if card value is outside the valid range (1-100). - */ -export function getMindCardTexture(card: MindCard): string { - if (!card.faceUp) return CARD_BACK_KEY; - validateValue(card.value); - return cardAssetKey(card); -} - -/** - * Return the template ID for a Mind card value. - * - * NOTE: This returns the *template ID* (e.g. 'mind-42'), NOT the - * DPR-aware texture key. For DPR-aware keys, use - * MindCardTextureAdapter.getCanonicalTextureKey(). - * - * @param value Card value (1-100). - * @returns The template ID string (e.g. 'mind-42'). - * @throws Error if value is outside the valid range (1-100). - */ -export function mindCardTextureKey(value: number): string { - validateValue(value); - return `mind-${value}`; -} - -/** - * Compute the DPR-aware texture key for a Mind card template ID. - * - * This is a convenience wrapper around SvgHelpers.makeTextureKey - * that applies the Mind card naming convention. - * - * @param templateId The template ID (e.g. 'mind-42' or 'mind-back'). - * @param width Card width in logical pixels. - * @param height Card height in logical pixels. - * @param dpr Device pixel ratio (defaults to window.devicePixelRatio or 1). - * @returns DPR-aware texture key (e.g. 'ms_card_mind-42_48x65@2'). - */ -export function makeMindCardTextureKey( - templateId: string, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, - dpr?: number, -): string { - const resolvedDpr = dpr ?? (typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1); - return makeTextureKey(templateId, width, height, resolvedDpr); -} - -// ── SVG text resolution ─────────────────────────────────── - -/** - * Resolve SVG text for a template ID from local caches or remote fetch. - * - * Resolution order: - * 1. Module-level svgTextCache (populated by Node preload path) - * 2. Phaser text cache (browser runtime) - * 3. Disk read (Node fallback) - * 4. Network fetch via SvgHelpers.fetchSvgText - */ -async function resolveSvgText(scene: Phaser.Scene, templateId: string): Promise { - // 1. Check module-level cache (populated by preload in Node) - let svgText = svgTextCache.get(templateId); - if (svgText) return svgText; - - // 2. Check Phaser text cache (browser runtime) - const cacheText = (scene as any).cache?.text?.get?.(`svg:${templateId}`) as string | undefined; - if (cacheText) return cacheText; - - // 3. Node fallback: try synchronously reading from disk - if (typeof window === 'undefined') { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('fs'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const path = require('path'); - const filePath = path.join(process.cwd(), 'public', ASSET_PATH, `${templateId}.svg`); - svgText = fs.readFileSync(filePath, 'utf8'); - if (svgText) return svgText; - } catch { - // Fall through to network fetch - } - } - - // 4. Network fetch via SvgHelpers - try { - const url = `/${ASSET_PATH}/${templateId}.svg`; - svgText = await fetchSvgText(url); - return svgText; - } catch { - return undefined; - } -} - -// ── Preloading ───────────────────────────────────────────── - -/** - * Preload Mind card assets for a Phaser scene. - * - * In **browser** runtimes this is registration-only: it marks the scene - * as valid for SvgHelpers so that lazy rasterisation can proceed when - * ensureMindCardTexture is called. No textures are eagerly created. - * - * In **Node/test** runtimes this synchronously reads all SVG files from - * disk into the module-level svgTextCache for headless test access. - * - * Call this from your scene's preload() method. - * - * @param scene The Phaser scene (null is tolerated but no registration occurs). - * @param width Card sprite width in pixels (defaults to MIND_CARD_W). - * @param height Card sprite height in pixels (defaults to MIND_CARD_H). - */ -export function preloadMindCardAssets( - scene: Phaser.Scene | null, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, -): void { - // Keep width/height parameters for API compatibility — they are used - // by lazy rasterisation when textures are generated on demand. - void width; - void height; - - if (typeof window === 'undefined') { - // Node: synchronously read from the public assets directory. - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const fs = require('fs'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const path = require('path'); - const base = path.join(process.cwd(), 'public', ASSET_PATH); - - const backPath = path.join(base, 'mind-back.svg'); - svgTextCache.set(CARD_BACK_KEY, fs.readFileSync(backPath, 'utf8')); - - for (let v = MIN_VALUE; v <= MAX_VALUE; v++) { - const key = `mind-${v}`; - const filePath = path.join(base, `${key}.svg`); - svgTextCache.set(key, fs.readFileSync(filePath, 'utf8')); - } - } catch (err) { - // Best-effort: tests that need these assets should ensure they exist. - } - } else { - // Browser: registration-only for lazy rasterisation plus a static - // card-back fallback image (Main Street pattern) so first paint never - // shows missing-texture placeholders. - if (scene) { - markSceneValid(scene); - - try { - // Use Phaser image loader (not load.svg) to provide a reliable - // immediate fallback texture key while DPR-aware textures are - // generated lazily by SvgHelpers. - if (!scene.textures?.exists(CARD_BACK_KEY)) { - (scene.load as any)?.image?.(CARD_BACK_KEY, `${ASSET_PATH}/mind-back.svg`); - } - } catch { - // Best-effort: keep preload resilient in constrained environments. - } - } - } -} - -// ── Lazy texture generation ──────────────────────────────── - -/** - * Ensure a Mind card texture exists (or is scheduled) and return the - * DPR-aware texture key + readiness/promise info. - * - * This implements lazy rasterisation via SvgHelpers.getOrCreateTexture: - * textures are only generated on first use, not during preload. - * - * In Node/test environments where Image/document are unavailable, this - * returns the template ID as key with ready=false (no rasterisation). - * - * @param scene The Phaser scene whose texture manager will hold the texture. - * @param value Card value (1-100). - * @param width Card width in logical pixels (defaults to MIND_CARD_W). - * @param height Card height in logical pixels (defaults to MIND_CARD_H). - * @returns Object with the DPR-aware texture key, ready state, and - * optional rasterisation promise. - */ -export async function ensureMindCardTexture( - scene: Phaser.Scene, - value: number, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, -): Promise<{ key: string; ready: boolean; promise?: Promise }> { - validateValue(value); - const templateId = mindCardTextureKey(value); - - // Resolve SVG text from cache, scene, disk, or network. - const svgText = await resolveSvgText(scene, templateId); - - if (!svgText) { - // Could not obtain SVG text — return template ID as a fallback key. - return { key: templateId, ready: false }; - } - - // In Node/test environments we cannot rasterise canvas textures because - // Image/document are not available. Return the template ID with - // ready=false so callers know the texture is not yet rasterised. - if (typeof (globalThis as any).Image === 'undefined' || typeof (globalThis as any).document === 'undefined') { - // Return a DPR-aware key even in Node so test assertions match the - // expected format, but mark as not ready (no actual texture created). - const dpr = 1; // Node has no window.devicePixelRatio - return { key: makeTextureKey(templateId, width, height, dpr), ready: false }; - } - - // Browser: use SvgHelpers.getOrCreateTexture for lazy rasterisation. - return getOrCreateTexture(scene, templateId, svgText, width, height); -} - -/** - * Ensure the card-back texture exists (or is scheduled). - * - * Follows the same lazy rasterisation pattern as ensureMindCardTexture - * but operates on the card-back SVG asset. - * - * @param scene The Phaser scene whose texture manager will hold the texture. - * @param width Card width in logical pixels (defaults to MIND_CARD_W). - * @param height Card height in logical pixels (defaults to MIND_CARD_H). - * @returns Object with the DPR-aware texture key, ready state, and - * optional rasterisation promise. - */ -export async function ensureMindCardBackTexture( - scene: Phaser.Scene, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, -): Promise<{ key: string; ready: boolean; promise?: Promise }> { - const templateId = CARD_BACK_KEY; - - const svgText = await resolveSvgText(scene, templateId); - - if (!svgText) { - // Fall back to preloaded static image key when available. - if (scene.textures?.exists(CARD_BACK_KEY)) { - return { key: CARD_BACK_KEY, ready: true }; - } - return { key: templateId, ready: false }; - } - - if (typeof (globalThis as any).Image === 'undefined' || typeof (globalThis as any).document === 'undefined') { - const dpr = 1; - return { key: makeTextureKey(templateId, width, height, dpr), ready: false }; - } - - return getOrCreateTexture(scene, templateId, svgText, width, height); -} - -// ── Validation ───────────────────────────────────────────── - -/** - * Validate that a card value is within the allowed range (1-100). - * - * @param value The card value to validate. - * @throws Error if value is outside 1-100 (inclusive). - */ -function validateValue(value: number): void { - if (value < MIN_VALUE || value > MAX_VALUE || !Number.isInteger(value)) { - throw new Error( - `Invalid Mind card value: ${value}. Must be an integer between ${MIN_VALUE} and ${MAX_VALUE}.`, - ); - } -} \ No newline at end of file diff --git a/example-games/the-mind/MindCardTextureAdapter.ts b/example-games/the-mind/MindCardTextureAdapter.ts deleted file mode 100644 index a06d3410..00000000 --- a/example-games/the-mind/MindCardTextureAdapter.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * MindCardTextureAdapter - * - * Provides a stable, DPR-aware API for resolving Mind card texture keys. - * This adapter bridges the gap between legacy template IDs (e.g. 'mind-42') - * and the DPR-aware texture keys produced by SvgHelpers.getOrCreateTexture - * (e.g. 'ms_card_mind-42_48x65@2'). - * - * Migration notes (CG-0MP12H40Q003Y7OU): - * - Callers should use this adapter instead of directly using legacy - * template IDs (mindCardTextureKey, getMindCardTexture) when setting - * sprite textures. The adapter computes the correct DPR-aware key. - * - getMindCardTexture() and mindCardTextureKey() in MindCardRenderer - * remain available but return template IDs, NOT DPR-aware keys. - * Prefer resolveTemplateId() + getCanonicalTextureKey() for sprite - * texture lookups. - * - For card-back textures, use resolveBackTemplateId() instead of - * CARD_BACK_KEY directly when computing DPR-aware keys. - */ - -import { makeTextureKey } from '../../src/core-engine/SvgHelpers'; -import { MIN_VALUE, MAX_VALUE, CARD_BACK_KEY } from './MindCard'; -import type { MindCard } from './MindCard'; -import { - ensureMindCardTexture, - ensureMindCardBackTexture, - MIND_CARD_W, - MIND_CARD_H, -} from './MindCardRenderer'; - -// ── Template ID resolution ───────────────────────────────── - -/** - * Resolve a Mind card value to its canonical template ID. - * - * Template IDs follow the pattern "mind-{value}" and serve as the - * stable identifier for a card regardless of DPR or dimensions. - * - * @param value Card value (1-100). - * @returns Template ID string (e.g. 'mind-42'). - * @throws Error if value is outside the valid range (1-100). - */ -export function resolveTemplateId(value: number): string { - if (value < MIN_VALUE || value > MAX_VALUE || !Number.isInteger(value)) { - throw new Error( - `Invalid Mind card value: ${value}. Must be an integer between ${MIN_VALUE} and ${MAX_VALUE}.`, - ); - } - return `mind-${value}`; -} - -/** - * Resolve the card-back template ID. - * - * @returns The card-back template ID ('mind-back'). - */ -export function resolveBackTemplateId(): string { - return CARD_BACK_KEY; -} - -// ── DPR-aware key computation ────────────────────────────── - -/** - * Compute the DPR-aware texture key for a Mind card template ID. - * - * This is a convenience wrapper around SvgHelpers.makeTextureKey - * that uses Mind card default dimensions when none are provided. - * - * @param templateId The template ID (e.g. 'mind-42' or 'mind-back'). - * @param width Card width in logical pixels (defaults to MIND_CARD_W). - * @param height Card height in logical pixels (defaults to MIND_CARD_H). - * @param dpr Device pixel ratio (defaults to window.devicePixelRatio or 1). - * @returns DPR-aware texture key (e.g. 'ms_card_mind-42_48x65@2'). - */ -export function getCanonicalTextureKey( - templateId: string, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, - dpr?: number, -): string { - const resolvedDpr = dpr ?? (typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1); - return makeTextureKey(templateId, width, height, resolvedDpr); -} - -// ── Convenience ensure wrappers ───────────────────────────── - -/** - * Ensure a Mind card texture exists and return the DPR-aware key. - * - * This wraps MindCardRenderer.ensureMindCardTexture, providing a - * stable API that always returns DPR-aware texture keys. - * - * @param scene The Phaser scene whose texture manager will hold the texture. - * @param value Card value (1-100). - * @param width Card width in logical pixels. - * @param height Card height in logical pixels. - * @returns Object with DPR-aware texture key, ready state, and optional promise. - */ -export async function ensureTexture( - scene: Phaser.Scene, - value: number, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, -): Promise<{ key: string; ready: boolean; promise?: Promise }> { - return ensureMindCardTexture(scene, value, width, height); -} - -/** - * Ensure the card-back texture exists and return the DPR-aware key. - * - * @param scene The Phaser scene whose texture manager will hold the texture. - * @param width Card width in logical pixels. - * @param height Card height in logical pixels. - * @returns Object with DPR-aware texture key, ready state, and optional promise. - */ -export async function ensureBackTexture( - scene: Phaser.Scene, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, -): Promise<{ key: string; ready: boolean; promise?: Promise }> { - return ensureMindCardBackTexture(scene, width, height); -} - -/** - * Get the DPR-aware texture key for a face-up or face-down MindCard. - * - * This combines template ID resolution with DPR-aware key computation. - * For face-down cards, it returns the card-back DPR-aware key. - * - * Note: This does NOT ensure the texture exists. Call ensureTexture() - * or ensureBackTexture() first, or use ensureMindCardTexture directly. - * - * @param card The MindCard to get a texture key for. - * @param width Card width in logical pixels (defaults to MIND_CARD_W). - * @param height Card height in logical pixels (defaults to MIND_CARD_H). - * @param dpr Device pixel ratio (defaults to window.devicePixelRatio or 1). - * @returns DPR-aware texture key. - */ -export function getTextureKey( - card: MindCard, - width: number = MIND_CARD_W, - height: number = MIND_CARD_H, - dpr?: number, -): string { - const templateId = card.faceUp ? resolveTemplateId(card.value) : resolveBackTemplateId(); - return getCanonicalTextureKey(templateId, width, height, dpr); -} - - diff --git a/example-games/the-mind/RunGameOrchestrator.ts b/example-games/the-mind/RunGameOrchestrator.ts deleted file mode 100644 index f4482f48..00000000 --- a/example-games/the-mind/RunGameOrchestrator.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { PlayerId, TheMindSession } from './TheMindGameState'; -import { playCard, isGameOver, getPileTopValue } from './TheMindGameState'; -import type { MindAiPlayer } from './AiStrategy'; -import { PROXIMITY_MIN_DELAY, PROXIMITY_THRESHOLD } from './AiStrategy'; -import type { MindTranscript, MindTranscriptRecorder } from './GameTranscript'; - -export interface PendingPlay { - readonly playerId: PlayerId; - readonly cardValue: number; - readonly fireTime: number; -} - -export interface SimulationStats { - totalPlays: number; - totalPenalties: number; - levelStartTime: number; -} - -export interface HeadlessResultSnapshot { - readonly totalPlays: number; - readonly totalPenalties: number; - readonly outcome: 'win' | 'loss'; - readonly finalLevel: number; - readonly finalLives: number; -} - -export function simulateGame( - session: TheMindSession, - aiPlayers: [MindAiPlayer, MindAiPlayer], - recorder: MindTranscriptRecorder, -): SimulationStats { - const stats: SimulationStats = { - totalPlays: 0, - totalPenalties: 0, - levelStartTime: 0, - }; - - commitLevelDelays(session, aiPlayers); - - while (!isGameOver(session)) { - const completed = runNextSimulationStep(session, aiPlayers, recorder, stats); - if (completed) { - break; - } - } - - return stats; -} - -export function runNextSimulationStep( - session: TheMindSession, - aiPlayers: [MindAiPlayer, MindAiPlayer], - recorder: MindTranscriptRecorder, - stats: SimulationStats, -): boolean { - const queue = buildPlayQueue( - aiPlayers, - stats.levelStartTime, - getPileTopValue(session), - ); - - if (queue.length === 0) { - return true; - } - - const next = queue[0]; - const timestamp = next.fireTime - stats.levelStartTime; - const result = playCard(session, next.playerId, next.cardValue); - - if (!result.success) { - aiPlayers[next.playerId].removeCard(next.cardValue); - return false; - } - - recordSuccessfulPlay( - session, - aiPlayers, - recorder, - stats, - next, - timestamp, - result, - ); - - if (result.levelComplete) { - return handleLevelCompletion( - session, - aiPlayers, - recorder, - stats, - next.fireTime, - timestamp, - result.bonusLifeAwarded, - ); - } - - return isGameOver(session); -} - -function recordSuccessfulPlay( - session: TheMindSession, - aiPlayers: [MindAiPlayer, MindAiPlayer], - recorder: MindTranscriptRecorder, - stats: SimulationStats, - next: PendingPlay, - timestamp: number, - result: ReturnType, -): void { - stats.totalPlays += 1; - - recorder.recordCardPlay( - timestamp, - next.playerId, - next.cardValue, - getPileTopValue(session), - session.pile.size(), - ); - - aiPlayers[next.playerId].removeCard(next.cardValue); - - if (!result.lifeLost) { - return; - } - - stats.totalPenalties += 1; - - recorder.recordPenalty( - timestamp, - session.lives, - result.penaltyCards.map((p) => ({ - playerId: p.playerId, - cardValue: p.card.value, - })), - ); - - for (const penaltyCard of result.penaltyCards) { - aiPlayers[penaltyCard.playerId].removeCard(penaltyCard.card.value); - } -} - -function handleLevelCompletion( - session: TheMindSession, - aiPlayers: [MindAiPlayer, MindAiPlayer], - recorder: MindTranscriptRecorder, - stats: SimulationStats, - fireTime: number, - timestamp: number, - bonusLifeAwarded: boolean, -): boolean { - const completedLevel = session.outcome === 'win' - ? session.currentLevel - : session.currentLevel - 1; - - const handsDealt = isGameOver(session) - ? undefined - : [ - session.players[0].hand.map((c) => c.value), - session.players[1].hand.map((c) => c.value), - ] as [readonly number[], readonly number[]]; - - recorder.recordLevelComplete( - timestamp, - completedLevel, - bonusLifeAwarded, - session.lives, - handsDealt, - ); - - if (isGameOver(session)) { - return true; - } - - stats.levelStartTime = fireTime; - commitLevelDelays(session, aiPlayers); - return false; -} - -export function commitLevelDelays( - session: TheMindSession, - aiPlayers: [MindAiPlayer, MindAiPlayer], -): void { - aiPlayers[0].commitLevel(session.players[0].hand); - aiPlayers[1].commitLevel(session.players[1].hand); -} - -export function buildPlayQueue( - aiPlayers: [MindAiPlayer, MindAiPlayer], - levelStartTime: number, - pileTopValue: number, -): PendingPlay[] { - const queue: PendingPlay[] = []; - - for (let p = 0; p < 2; p++) { - const playerId = p as PlayerId; - const delays = aiPlayers[p].getCardDelays(); - - for (const delayEntry of delays) { - const fireTime = applyProximityDelay( - levelStartTime, - delayEntry.delay, - delayEntry.card.value, - pileTopValue, - ); - - queue.push({ - playerId, - cardValue: delayEntry.card.value, - fireTime, - }); - } - } - - queue.sort((a, b) => a.fireTime - b.fireTime || a.cardValue - b.cardValue); - return queue; -} - -function applyProximityDelay( - levelStartTime: number, - rawDelay: number, - cardValue: number, - pileTopValue: number, -): number { - let fireTime = levelStartTime + Math.max(rawDelay, 0); - - if ( - pileTopValue > 0 && - cardValue - pileTopValue <= PROXIMITY_THRESHOLD - ) { - const minFireTime = levelStartTime + PROXIMITY_MIN_DELAY; - if (fireTime < minFireTime) { - fireTime = minFireTime; - } - } - - return fireTime; -} - -export function buildResultSnapshot( - stats: SimulationStats, - session: TheMindSession, -): HeadlessResultSnapshot { - return { - totalPlays: stats.totalPlays, - totalPenalties: stats.totalPenalties, - outcome: session.outcome as 'win' | 'loss', - finalLevel: session.currentLevel, - finalLives: session.lives, - }; -} - -export function finalizeTranscript( - recorder: MindTranscriptRecorder, - snapshot: HeadlessResultSnapshot, -): MindTranscript { - return recorder.finalize( - Date.now(), - snapshot.outcome, - snapshot.finalLevel, - snapshot.finalLives, - ); -} diff --git a/example-games/the-mind/TheMindGameState.ts b/example-games/the-mind/TheMindGameState.ts deleted file mode 100644 index 8657370c..00000000 --- a/example-games/the-mind/TheMindGameState.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * TheMindGameState.ts - * - * Pure game state and cooperative mechanics for The Mind -- no Phaser dependency. - * Manages the shared ascending pile, player hands, lives system with bonus - * awards, penalty logic (discard lower cards on out-of-order play), and level - * progression (1-8, cards per player = level number). - * - * The Mind is a cooperative card game where 2 players (1 human + 1 AI) play - * numbered cards (1-100) onto a single shared ascending pile. There are no - * turns -- either player can play at any moment. When a card is played while - * any player holds a lower-valued card, a life is lost and all lower cards - * are discarded. - */ - -import { Pile } from '../../src/card-system/Pile'; -import type { MindCard } from './MindCard'; -import { createMindDeck, shuffleDeck } from './MindCard'; - -import type { MultiplayerSetupOptions } from '../../src/core-engine/SetupOptions'; -import { resolveSetupOptions } from '../../src/core-engine/SetupOptions'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Number of levels in a complete game. */ -export const MAX_LEVEL = 8; - -/** Number of starting lives. */ -export const STARTING_LIVES = 2; - -/** Maximum number of lives a team can hold. */ -export const MAX_LIVES = 3; - -/** Levels at which a bonus life is awarded (after clearing). */ -export const BONUS_LIFE_LEVELS: ReadonlyArray = [3, 6]; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Player identifier: 0 = human, 1 = AI. */ -export type PlayerId = 0 | 1; - -/** Possible game outcomes. */ -export type GameOutcome = 'in-progress' | 'win' | 'loss'; - -/** Result of attempting to play a card. */ -export interface PlayResult { - /** Whether the play was accepted. */ - readonly success: boolean; - /** If a penalty occurred, the cards discarded from both players. */ - readonly penaltyCards: ReadonlyArray<{ playerId: PlayerId; card: MindCard }>; - /** Whether a life was lost on this play. */ - readonly lifeLost: boolean; - /** Whether the level was completed by this play. */ - readonly levelComplete: boolean; - /** Whether a bonus life was awarded after level completion. */ - readonly bonusLifeAwarded: boolean; - /** Error message if the play was rejected. */ - readonly error?: string; -} - -/** Setup options for creating a new game session. */ -export type TheMindSetupOptions = MultiplayerSetupOptions; - -/** Per-player state. */ -export interface MindPlayerState { - readonly name: string; - readonly isAI: boolean; - /** Cards in hand, kept sorted ascending by value. */ - hand: MindCard[]; -} - -/** The full game session state. */ -export interface TheMindSession { - /** The two players (index 0 = human, index 1 = AI by default). */ - players: [MindPlayerState, MindPlayerState]; - /** The shared ascending play pile. */ - pile: Pile; - /** Current level (1-8). */ - currentLevel: number; - /** Remaining lives. */ - lives: number; - /** Current game outcome. */ - outcome: GameOutcome; - /** Random number generator. */ - rng: () => number; -} - -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - -/** - * Create a new The Mind game session, ready to play level 1. - */ -export function setupTheMindGame( - options?: TheMindSetupOptions, -): TheMindSession { - const { players: playerInfos, rng } = resolveSetupOptions({ - ...options, - playerCount: 2, - }); - - const session: TheMindSession = { - players: [ - { name: playerInfos[0].name, isAI: playerInfos[0].isAI, hand: [] }, - { name: playerInfos[1].name, isAI: playerInfos[1].isAI, hand: [] }, - ], - pile: new Pile(), - currentLevel: 0, // Will be set by dealLevel - lives: STARTING_LIVES, - outcome: 'in-progress', - rng, - }; - - dealLevel(session, 1); - return session; -} - -// --------------------------------------------------------------------------- -// Dealing -// --------------------------------------------------------------------------- - -/** - * Deal cards for the specified level. - * - * Creates a fresh shuffled 100-card deck and deals `level` cards to each - * player. The pile is cleared for the new level. - * - * @param session - The game session to deal into. - * @param level - The level number (1-8). - * @throws Error if level is outside the valid range [1, MAX_LEVEL]. - */ -export function dealLevel( - session: TheMindSession, - level: number, -): void { - if (level < 1 || level > MAX_LEVEL) { - throw new Error( - `Invalid level ${level}: must be between 1 and ${MAX_LEVEL}`, - ); - } - - // Create and shuffle a fresh deck - const deck = createMindDeck(); - shuffleDeck(deck, session.rng); - - // Clear the pile for the new level - session.pile.clear(); - - // Deal `level` cards to each player - for (const player of session.players) { - player.hand = []; - } - - for (let i = 0; i < level; i++) { - for (const player of session.players) { - const card = deck.pop()!; - card.faceUp = false; // Cards start face-down (hidden from other player) - player.hand.push(card); - } - } - - // Sort each player's hand ascending by value - for (const player of session.players) { - player.hand.sort((a, b) => a.value - b.value); - } - - session.currentLevel = level; -} - -// --------------------------------------------------------------------------- -// Query helpers -// --------------------------------------------------------------------------- - -/** Get the top card value of the pile, or 0 if the pile is empty. */ -export function getPileTopValue(session: TheMindSession): number { - const top = session.pile.peek(); - return top ? top.value : 0; -} - -/** Check whether the game is over (win or loss). */ -export function isGameOver(session: TheMindSession): boolean { - return session.outcome !== 'in-progress'; -} - -/** Check whether all cards have been played for the current level. */ -export function isLevelComplete(session: TheMindSession): boolean { - return session.players.every((p) => p.hand.length === 0); -} - -/** - * Get all cards held by both players that are lower than the given value, - * across both hands. - */ -function getLowerCards( - session: TheMindSession, - value: number, -): Array<{ playerId: PlayerId; card: MindCard }> { - const result: Array<{ playerId: PlayerId; card: MindCard }> = []; - - for (let i = 0; i < 2; i++) { - const playerId = i as PlayerId; - for (const card of session.players[playerId].hand) { - if (card.value < value) { - result.push({ playerId, card }); - } - } - } - - return result; -} - -// --------------------------------------------------------------------------- -// Card play -// --------------------------------------------------------------------------- - -/** - * Attempt to play a card from a player's hand onto the shared pile. - * - * Rules: - * - The card must be in the player's hand. - * - The card value must be higher than the pile top. - * - If the game is already over, the play is rejected. - * - If any player holds a card with a lower value than the played card, - * a penalty occurs: a life is lost and all lower cards are discarded. - * - If lives reach 0 after a penalty, the game ends in a loss. - * - If the level is completed (both hands empty), the level advances. - * - If the final level is completed, the game ends in a win. - * - * @param session - The game session. - * @param playerId - Which player is playing (0 or 1). - * @param cardValue - The value of the card to play. - * @returns A PlayResult describing the outcome. - */ -export function playCard( - session: TheMindSession, - playerId: PlayerId, - cardValue: number, -): PlayResult { - // --- Guard: game already over --- - if (session.outcome !== 'in-progress') { - return { - success: false, - penaltyCards: [], - lifeLost: false, - levelComplete: false, - bonusLifeAwarded: false, - error: 'Game is already over', - }; - } - - // --- Guard: card not in hand --- - const player = session.players[playerId]; - const cardIndex = player.hand.findIndex((c) => c.value === cardValue); - if (cardIndex === -1) { - return { - success: false, - penaltyCards: [], - lifeLost: false, - levelComplete: false, - bonusLifeAwarded: false, - error: `Card with value ${cardValue} is not in player ${playerId}'s hand`, - }; - } - - // --- Guard: card value must be higher than pile top --- - const topValue = getPileTopValue(session); - if (cardValue <= topValue) { - return { - success: false, - penaltyCards: [], - lifeLost: false, - levelComplete: false, - bonusLifeAwarded: false, - error: `Card value ${cardValue} is not higher than pile top ${topValue}`, - }; - } - - // --- Check for penalty: any player holds a lower card? --- - // First remove the played card from hand so it isn't counted as "held" - const [playedCard] = player.hand.splice(cardIndex, 1); - - // Find all lower cards held by either player (excluding the just-played card) - const lowerCards = getLowerCards(session, cardValue); - const lifeLost = lowerCards.length > 0; - - // Apply penalty if needed - if (lifeLost) { - session.lives -= 1; - - // Discard all lower cards from both hands - for (const { playerId: pid, card } of lowerCards) { - const hand = session.players[pid].hand; - const idx = hand.findIndex((c) => c.value === card.value); - if (idx !== -1) { - hand.splice(idx, 1); - } - } - } - - // Place the card on the pile - playedCard.faceUp = true; - session.pile.push(playedCard); - - // Check for game loss (lives exhausted) - if (session.lives <= 0) { - session.outcome = 'loss'; - return { - success: true, - penaltyCards: lowerCards, - lifeLost: true, - levelComplete: false, - bonusLifeAwarded: false, - }; - } - - // Check for level completion - const levelDone = isLevelComplete(session); - let bonusLifeAwarded = false; - - if (levelDone) { - // Award bonus life if applicable - if ( - BONUS_LIFE_LEVELS.includes(session.currentLevel) && - session.lives < MAX_LIVES - ) { - session.lives += 1; - bonusLifeAwarded = true; - } - - // Check for game win - if (session.currentLevel >= MAX_LEVEL) { - session.outcome = 'win'; - } else { - // Auto-advance to next level - dealLevel(session, session.currentLevel + 1); - } - } - - return { - success: true, - penaltyCards: lowerCards, - lifeLost, - levelComplete: levelDone, - bonusLifeAwarded, - }; -} diff --git a/example-games/the-mind/createTheMindGame.ts b/example-games/the-mind/createTheMindGame.ts deleted file mode 100644 index c699f67a..00000000 --- a/example-games/the-mind/createTheMindGame.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Factory function to create a Phaser game instance for The Mind. - * Used by both main.ts and browser tests. - */ -import { createCardGame } from '../../src/ui/createCardGame'; -import type { CardGameOptions } from '../../src/ui/createCardGame'; -import { TheMindScene } from './scenes/TheMindScene'; - -export type TheMindGameOptions = Partial>; - -export function createTheMindGame( - options: TheMindGameOptions = {}, -): Phaser.Game { - return createCardGame({ - backgroundColor: '#1a1a2e', - scenes: [TheMindScene], - ...options, - }); -} diff --git a/example-games/the-mind/headlessGame.ts b/example-games/the-mind/headlessGame.ts deleted file mode 100644 index 2d7e46bc..00000000 --- a/example-games/the-mind/headlessGame.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * headlessGame.ts - * - * Headless (no Phaser) game runner for The Mind. - */ - -import { createSeededRng } from '../../src/core-engine/SeededRng'; -import { setupTheMindGame } from './TheMindGameState'; -import { MindAiPlayer } from './AiStrategy'; -import type { MindAiTimingConfig } from './AiStrategy'; -import { MindTranscriptRecorder } from './GameTranscript'; -import type { MindTranscript, MindInitialState } from './GameTranscript'; -import { - buildResultSnapshot, - finalizeTranscript, - simulateGame, -} from './RunGameOrchestrator'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Configuration for a headless game run. */ -export interface HeadlessGameConfig { - /** Seed for the game RNG (deck shuffling). Defaults to 42. */ - seed?: number; - /** Seed for Player 0's AI timing RNG. Defaults to seed + 1. */ - player0AiSeed?: number; - /** Seed for Player 1's AI timing RNG. Defaults to seed + 2. */ - player1AiSeed?: number; - /** Optional timing config override for both AI players. */ - timingConfig?: Partial; - /** Player names. Defaults to ['AI-0', 'AI-1']. */ - playerNames?: [string, string]; -} - -/** Result of a headless game run. */ -export interface HeadlessGameResult { - readonly transcript: MindTranscript; - readonly totalPlays: number; - readonly totalPenalties: number; - readonly outcome: 'win' | 'loss'; - readonly finalLevel: number; - readonly finalLives: number; -} - -// --------------------------------------------------------------------------- -// Headless runner -// --------------------------------------------------------------------------- - -/** - * Run a complete headless AI-vs-AI game of The Mind. - */ -export function runGame(config?: HeadlessGameConfig): HeadlessGameResult { - const { seed, p0AiSeed, p1AiSeed, names } = resolveConfig(config); - const session = createSession(seed, names); - const aiPlayers = createAiPlayers(p0AiSeed, p1AiSeed, config?.timingConfig); - const recorder = createRecorder(session, names); - - const stats = simulateGame(session, aiPlayers, recorder); - const snapshot = buildResultSnapshot(stats, session); - const transcript = finalizeTranscript(recorder, snapshot); - - return { - transcript, - ...snapshot, - }; -} - -function resolveConfig(config?: HeadlessGameConfig): { - seed: number; - p0AiSeed: number; - p1AiSeed: number; - names: [string, string]; -} { - const seed = config?.seed ?? 42; - return { - seed, - p0AiSeed: config?.player0AiSeed ?? seed + 1, - p1AiSeed: config?.player1AiSeed ?? seed + 2, - names: config?.playerNames ?? ['AI-0', 'AI-1'], - }; -} - -function createSession( - seed: number, - names: [string, string], -) { - return setupTheMindGame({ - playerNames: names, - isAI: [true, true], - rng: createSeededRng(seed), - }); -} - -function createAiPlayers( - player0Seed: number, - player1Seed: number, - timingConfig?: Partial, -): [MindAiPlayer, MindAiPlayer] { - return [ - new MindAiPlayer(undefined, createSeededRng(player0Seed), timingConfig), - new MindAiPlayer(undefined, createSeededRng(player1Seed), timingConfig), - ]; -} - -function createRecorder( - session: ReturnType, - names: [string, string], -): MindTranscriptRecorder { - const initialState: MindInitialState = { - playerNames: names, - isAI: [true, true], - startingLives: session.lives, - startingLevel: session.currentLevel, - hands: [ - session.players[0].hand.map((c) => c.value), - session.players[1].hand.map((c) => c.value), - ], - }; - - return new MindTranscriptRecorder(initialState); -} diff --git a/example-games/the-mind/help-content.json b/example-games/the-mind/help-content.json deleted file mode 100644 index 7eaad4e3..00000000 --- a/example-games/the-mind/help-content.json +++ /dev/null @@ -1,34 +0,0 @@ -[ - { - "heading": "The Mind", - "body": "A cooperative card game where you and an AI partner play numbered cards (1-100) onto a shared ascending pile -- without taking turns or communicating. Both players can play at any time. The challenge is timing: play your cards in the right order by feeling when the moment is right." - }, - { - "heading": "How to Play", - "body": "Cards must be played in ascending order onto the pile. You can see your own cards (face-up) but not the AI's cards (face-down). Click any card in your hand to play it.\n\nThere are no turns -- both you and the AI can play at any time. The AI uses internal timing to decide when to play. If you think the AI has a lower card, wait. If your lowest card feels safe, play it." - }, - { - "heading": "Levels", - "body": "The game has 8 levels. At level 1, each player receives 1 card. At level 2, each player receives 2 cards, and so on up to 8 cards each at level 8.\n\nComplete a level by playing all cards from both hands in ascending order. After completing level 3, you earn a bonus life." - }, - { - "heading": "Lives & Penalties", - "body": "You start with 2 lives (max 3). If a card is played out of order -- meaning the other player was holding a lower card -- a life is lost. All cards lower than the played card are discarded as a penalty.\n\nWhen all lives are lost, the game is over. Complete all 8 levels to win!" - }, - { - "heading": "The Pile", - "body": "The pile in the centre of the screen shows the last card played and a running count. Cards must always be played in ascending order -- you cannot play a card with a value lower than or equal to the top of the pile." - }, - { - "heading": "Auto-Play Mode", - "body": "Click the Auto-Play toggle (bottom-left) to let a second AI control your hand. Watch two AIs play a full game cooperatively. Toggle it off at any time to resume manual play." - }, - { - "heading": "Tips", - "body": "- If your lowest card is close to the pile's top value, play it quickly.\n- If there is a large gap between the pile and your lowest card, wait -- the AI may have cards in between.\n- Watch the AI's card count. Fewer cards means less risk of a penalty.\n- Pay attention to the pace of AI plays to develop a sense of its timing rhythm." - }, - { - "heading": "Controls", - "body": "Click a card in your hand to play it.\n\nPress ? to toggle this help panel.\nPress Escape to toggle the settings panel." - } -] diff --git a/example-games/the-mind/layouts/the-mind.layout.json b/example-games/the-mind/layouts/the-mind.layout.json deleted file mode 100644 index aab894c0..00000000 --- a/example-games/the-mind/layouts/the-mind.layout.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": 1, - "id": "the-mind-canonical", - "baseViewport": { - "width": 1280, - "height": 720 - }, - "requiredZones": [ - "humanHand", - "aiHand", - "playPile" - ], - "zones": { - "humanHand": { - "rect": { - "x": 0.0625, - "y": 0.75 - }, - "anchors": { - "center": { "x": 0.5, "y": 0.85 }, - "topCenter": { "x": 0.5, "y": 0.75 }, - "handCenterY": { "x": 0.5, "y": 0.82 } - } - }, - "aiHand": { - "rect": { - "x": 0.0625, - "y": 0.069444 - }, - "anchors": { - "center": { "x": 0.5, "y": 0.19 }, - "bottomCenter": { "x": 0.5, "y": 0.30 }, - "handCenterY": { "x": 0.5, "y": 0.19 } - } - }, - "playPile": { - "rect": { - "x": 0.40625, - "y": 0.333333 - }, - "anchors": { - "center": { "x": 0.5, "y": 0.5 }, - "topCenter": { "x": 0.5, "y": 0.333333 }, - "bottomCenter": { "x": 0.5, "y": 0.666666 } - } - } - } -} diff --git a/example-games/the-mind/scenes/MindAiScheduler.ts b/example-games/the-mind/scenes/MindAiScheduler.ts deleted file mode 100644 index 1f0e67d1..00000000 --- a/example-games/the-mind/scenes/MindAiScheduler.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * MindAiScheduler -- handles AI turn scheduling and auto-play spectator mode for The Mind. - */ - -import { MindAiPlayer, computeEffectiveDelay } from '../AiStrategy'; -import { getPileTopValue } from '../TheMindGameState'; -import type { TheMindSession } from '../TheMindGameState'; -export class MindAiScheduler { - private aiTimer: Phaser.Time.TimerEvent | null = null; - private humanAiTimer: Phaser.Time.TimerEvent | null = null; - private aiLevelStartTime = 0; - - aiPlayer: MindAiPlayer; - humanAiPlayer: MindAiPlayer; - autoPlayEnabled = false; - - constructor( - private scene: Phaser.Scene, - private session: TheMindSession, - ) { - this.aiPlayer = new MindAiPlayer(); - this.humanAiPlayer = new MindAiPlayer(); - } - - startLevel(): void { - this.aiLevelStartTime = Date.now(); - this.aiPlayer.commitLevel(this.session.players[1].hand); - if (this.autoPlayEnabled) { - this.humanAiPlayer.commitLevel(this.session.players[0].hand); - } - } - - scheduleAiPlay(phase: string, onPlay: (cardValue: number) => void): void { - this.cancelAiTimer(); - - const nextCard = this.aiPlayer.getNextCard(); - if (!nextCard) return; - - const elapsed = Date.now() - this.aiLevelStartTime; - const delay = computeEffectiveDelay( - nextCard.delay, - elapsed, - this.session.players[1].hand.length, - this.session.players[0].hand.length, - nextCard.card.value, - getPileTopValue(this.session), - ); - - this.aiTimer = this.scene.time.delayedCall(delay, () => { - if (phase !== 'playing') return; - onPlay(nextCard.card.value); - }); - } - - scheduleHumanAiPlay(phase: string, onPlay: (cardValue: number) => void): void { - this.cancelHumanAiTimer(); - if (!this.autoPlayEnabled) return; - - const nextCard = this.humanAiPlayer.getNextCard(); - if (!nextCard) return; - - const elapsed = Date.now() - this.aiLevelStartTime; - const delay = computeEffectiveDelay( - nextCard.delay, - elapsed, - this.session.players[0].hand.length, - this.session.players[1].hand.length, - nextCard.card.value, - getPileTopValue(this.session), - ); - - this.humanAiTimer = this.scene.time.delayedCall(delay, () => { - if (phase !== 'playing') return; - onPlay(nextCard.card.value); - }); - } - - rescheduleAiIfNeeded(phase: string, onPlay: (cardValue: number) => void): void { - if (this.aiPlayer.hasCards() && phase === 'playing') { - this.scheduleAiPlay(phase, onPlay); - } - } - - rescheduleHumanAiIfNeeded(phase: string, onPlay: (cardValue: number) => void): void { - if (this.autoPlayEnabled && this.humanAiPlayer.hasCards() && phase === 'playing') { - this.scheduleHumanAiPlay(phase, onPlay); - } - } - - cancelAiTimer(): void { - if (this.aiTimer) { - this.aiTimer.destroy(); - this.aiTimer = null; - } - } - - cancelHumanAiTimer(): void { - if (this.humanAiTimer) { - this.humanAiTimer.destroy(); - this.humanAiTimer = null; - } - } - - cancelAllTimers(): void { - this.cancelAiTimer(); - this.cancelHumanAiTimer(); - } - - removeCardFromAi(cardValue: number): void { - this.aiPlayer.removeCard(cardValue); - if (this.autoPlayEnabled) { - this.humanAiPlayer.removeCard(cardValue); - } - } - - removePenaltyCards(penaltyCards: ReadonlyArray<{ card: { value: number } }>): void { - for (const pc of penaltyCards) { - this.aiPlayer.removeCard(pc.card.value); - if (this.autoPlayEnabled) { - this.humanAiPlayer.removeCard(pc.card.value); - } - } - } - - toggleAutoPlay( - currentEnabled: boolean, - onToggle: (enabled: boolean) => void, - ): boolean { - const newEnabled = !currentEnabled; - this.autoPlayEnabled = newEnabled; - - if (newEnabled) { - this.humanAiPlayer.commitLevel(this.session.players[0].hand); - } else { - this.cancelHumanAiTimer(); - } - - onToggle(newEnabled); - return newEnabled; - } - - destroy(): void { - this.cancelAllTimers(); - } -} diff --git a/example-games/the-mind/scenes/MindAnimator.ts b/example-games/the-mind/scenes/MindAnimator.ts deleted file mode 100644 index 9d6f3c13..00000000 --- a/example-games/the-mind/scenes/MindAnimator.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * MindAnimator -- handles card animations and visual effects for The Mind. - */ - -import type { MindCard } from '../MindCard'; -import type { PlayResult, PlayerId } from '../TheMindGameState'; -import { - resolveTemplateId, - resolveBackTemplateId, - getCanonicalTextureKey, - ensureTexture, - ensureBackTexture, -} from '../MindCardTextureAdapter'; -import { flipCard, shakeIllegalMove } from '../../../src/ui'; -import type { SoundManager } from '../../../src/core-engine'; -import { - CARD_W, CARD_H, - ANIM_DURATION, PENALTY_REVEAL_DELAY, - DEPTH_PLAYED_CARD, DEPTH_OVERLAY_CONTENT, - PENALTY_CARD_ALPHA, - PENALTY_CLEANUP_EXTRA_DELAY, - LEVEL_COMPLETE_TEXT_Y_OFFSET, - LEVEL_COMPLETE_FADE_IN_DURATION, - LEVEL_COMPLETE_DISPLAY_DURATION, -} from './MindConstants'; -import { pickPenaltyStartPositions } from './penaltyAnimation'; -import type { MindRenderer } from './MindRenderer'; -import type { TheMindSession } from '../TheMindGameState'; - -export class MindAnimator { - /** When true, all animations are skipped and sprites snap to final state. */ - reducedMotion = false; - - constructor( - private scene: Phaser.Scene, - private session: TheMindSession, - private renderer: MindRenderer, - _soundManager: SoundManager | null, - ) {} - - private get layout() { return this.renderer['layout']; } - - // ── Card play animation ──────────────────────────────── - - animateCardTowardsPile( - playerId: PlayerId, - cardValue: number, - onComplete: () => void, - ): void { - if (this.reducedMotion) { - onComplete(); - return; - } - if (playerId === 0) { - this.animateHumanCardToPile(cardValue, onComplete); - } else { - this.animateAiCardToPile(cardValue, onComplete); - } - } - - private animateHumanCardToPile( - cardValue: number, - onComplete: () => void, - ): void { - const targetTex = getCanonicalTextureKey(resolveTemplateId(cardValue), CARD_W, CARD_H); - let sprite: Phaser.GameObjects.Image | undefined; - let spriteIdx = -1; - - for (let i = 0; i < this.renderer.humanCardSprites.length; i++) { - const candidate = this.renderer.humanCardSprites[i] as Phaser.GameObjects.Image & { __mindCardValue?: number }; - if (candidate.__mindCardValue === cardValue || candidate.texture.key === targetTex) { - sprite = candidate; - spriteIdx = i; - break; - } - } - - if (!sprite) { - this.scene.time.delayedCall(ANIM_DURATION, onComplete); - return; - } - - this.renderer.humanCardSprites.splice(spriteIdx, 1); - sprite.disableInteractive(); - // Reset hover scaling before animation so played cards do not - // momentarily appear wider than intended during travel to pile. - sprite.setScale(1); - sprite.setDisplaySize(CARD_W, CARD_H); - sprite.setDepth(DEPTH_PLAYED_CARD); - - this.scene.tweens.add({ - targets: sprite, - x: this.layout.playPileCenterX, - y: this.layout.playPileCenterY, - duration: ANIM_DURATION, - ease: 'Cubic.easeOut', - onComplete: () => { - // Keep the sprite visible at the pile position with face-up texture. - // The PileView's pileSprite handles the pile display separately, - // so we keep this sprite as a visual record of the last played card. - sprite!.setTexture(targetTex); - sprite!.setDisplaySize(CARD_W, CARD_H); - sprite!.setDepth(DEPTH_PLAYED_CARD); - onComplete(); - }, - }); - } - - private animateAiCardToPile( - cardValue: number, - onComplete: () => void, - ): void { - let sourceX = this.layout.playPileCenterX; - let sourceY = this.layout.aiHandCenterY; - - if (this.renderer.aiCardSprites.length > 0) { - const lastIdx = this.renderer.aiCardSprites.length - 1; - const srcSprite = this.renderer.aiCardSprites[lastIdx]; - sourceX = srcSprite.x; - sourceY = srcSprite.y; - this.renderer.aiCardSprites.splice(lastIdx, 1); - srcSprite.destroy(); - } - - void (async () => { - let backKey = getCanonicalTextureKey(resolveBackTemplateId(), CARD_W, CARD_H); - let faceUpTex = getCanonicalTextureKey(resolveTemplateId(cardValue), CARD_W, CARD_H); - - try { - const backRes = await ensureBackTexture(this.scene, CARD_W, CARD_H); - if (!backRes.ready && backRes.promise) { - await backRes.promise; - } - backKey = backRes.key; - } catch { - if (this.scene.textures?.exists(resolveBackTemplateId())) { - backKey = resolveBackTemplateId(); - } - } - - try { - const faceRes = await ensureTexture(this.scene, cardValue, CARD_W, CARD_H); - if (!faceRes.ready && faceRes.promise) { - await faceRes.promise; - } - faceUpTex = faceRes.key; - } catch { - // keep canonical fallback key - } - - const tempSprite = this.scene.add - .image(sourceX, sourceY, backKey) - .setDisplaySize(CARD_W, CARD_H) - .setDepth(DEPTH_PLAYED_CARD); - - this.scene.tweens.add({ - targets: tempSprite, - x: this.layout.playPileCenterX, - y: this.layout.playPileCenterY, - duration: ANIM_DURATION, - ease: 'Cubic.easeOut', - }); - - flipCard({ - scene: this.scene, - target: tempSprite, - newTexture: faceUpTex, - duration: ANIM_DURATION, - easeClose: 'Cubic.easeIn', - easeOpen: 'Cubic.easeOut', - onMidpoint: () => { - tempSprite.setDisplaySize(CARD_W, CARD_H); - }, - onComplete: () => { - // Keep the sprite visible at the pile position with face-up texture. - // The PileView's pileSprite handles the pile display separately, - // so we keep this sprite as a visual record of the last played card. - tempSprite.setScale(1); - tempSprite.setDisplaySize(CARD_W, CARD_H); - tempSprite.setTexture(faceUpTex); - tempSprite.setDepth(DEPTH_PLAYED_CARD); - onComplete(); - }, - }); - })(); - } - - // ── Penalty display ──────────────────────────────────── - - showPenaltyCards(result: PlayResult, onComplete: () => void): void { - if (this.reducedMotion) { - onComplete(); - return; - } - const penaltySprites: Phaser.GameObjects.Image[] = []; - - const startPositions = pickPenaltyStartPositions( - result.penaltyCards, - this.renderer.humanCardSprites.map((s) => ({ x: s.x, y: s.y })), - this.renderer.aiCardSprites.map((s) => ({ x: s.x, y: s.y })), - { - 0: { x: this.layout.playPileCenterX, y: this.layout.humanHandCenterY }, - 1: { x: this.layout.playPileCenterX, y: this.layout.aiHandCenterY }, - }, - ); - - for (let i = 0; i < result.penaltyCards.length; i++) { - const { card } = result.penaltyCards[i]; - const displayCard = { ...card, faceUp: true }; - const { x, y } = startPositions[i]; - - const sprite = this.scene.add - .image(x, y, getCanonicalTextureKey(resolveTemplateId(displayCard.value), CARD_W, CARD_H)) - .setDisplaySize(CARD_W, CARD_H) - .setDepth(DEPTH_PLAYED_CARD + 1) - .setTint(0xff4444); - - penaltySprites.push(sprite); - - this.scene.tweens.add({ - targets: sprite, - x: this.layout.playPileCenterX, - y: this.layout.playPileCenterY, - alpha: PENALTY_CARD_ALPHA, - duration: ANIM_DURATION, - }); - } - - this.scene.time.delayedCall(PENALTY_REVEAL_DELAY, () => { - for (const sprite of penaltySprites) { - this.scene.tweens.add({ - targets: sprite, - alpha: 0, - duration: ANIM_DURATION, - onComplete: () => sprite.destroy(), - }); - } - - this.scene.time.delayedCall(ANIM_DURATION + PENALTY_CLEANUP_EXTRA_DELAY, () => { - onComplete(); - }); - }); - } - - // ── Invalid move feedback ────────────────────────────── - - showInvalidPlayFeedback(cardValue: number): void { - const hand = this.session.players[0].hand; - const idx = hand.findIndex((c: MindCard) => c.value === cardValue); - if (idx === -1 || idx >= this.renderer.humanCardSprites.length) return; - - const sprite = this.renderer.humanCardSprites[idx]; - shakeIllegalMove({ scene: this.scene, target: sprite }); - } - - // ── Level complete display ───────────────────────────── - - showLevelCompleteText( - completedLevel: number, - bonusLifeAwarded: boolean, - onComplete: () => void, - ): void { - if (this.reducedMotion) { - onComplete(); - return; - } - const bonusText = bonusLifeAwarded - ? '\nBonus life awarded!' - : ''; - - const levelText = this.scene.add - .text( - this.layout.playPileCenterX, - this.layout.playPileCenterY + LEVEL_COMPLETE_TEXT_Y_OFFSET, - `Level ${completedLevel} Complete!${bonusText}`, - { - fontSize: '28px', - color: '#88ff88', - fontFamily: 'sans-serif', - align: 'center', - }, - ) - .setOrigin(0.5) - .setDepth(DEPTH_OVERLAY_CONTENT) - .setAlpha(0); - - this.scene.tweens.add({ - targets: levelText, - alpha: 1, - duration: LEVEL_COMPLETE_FADE_IN_DURATION, - }); - - this.scene.time.delayedCall(LEVEL_COMPLETE_DISPLAY_DURATION, () => { - levelText.destroy(); - onComplete(); - }); - } -} diff --git a/example-games/the-mind/scenes/MindAudioKeys.ts b/example-games/the-mind/scenes/MindAudioKeys.ts deleted file mode 100644 index 0d5011e6..00000000 --- a/example-games/the-mind/scenes/MindAudioKeys.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * MindAudioKeys -- audio asset keys for The Mind. - * - * All SFX keys use the standard `sfx-` prefix — no game-specific prefix. - * See docs/SFX_CONVENTION.md for the naming convention. - */ - -import { COMMON_SFX_KEYS } from '../../../src/core-engine/SoundManager'; - -export const SFX_KEYS = { - CARD_PLAY: 'sfx-card-play', - LIFE_LOST: 'sfx-life-lost', - LEVEL_COMPLETE: 'sfx-level-complete', - GAME_WIN: 'sfx-game-win', - GAME_LOST: 'sfx-game-lost', - UI_CLICK: COMMON_SFX_KEYS.UI_CLICK, -} as const; diff --git a/example-games/the-mind/scenes/MindConstants.ts b/example-games/the-mind/scenes/MindConstants.ts deleted file mode 100644 index 7ad6805a..00000000 --- a/example-games/the-mind/scenes/MindConstants.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * MindConstants -- shared timing, rendering, and game-logic constants for The Mind. - * - * Layout positions are now defined via SLL in `../layouts/the-mind.layout.json` - * and resolved through `MindLayoutAdapter.ts`. - */ - -import { GAME_W } from '../../../src/ui'; - -// Card display dimensions (~50% larger than default for readability) -export const CARD_W = 120; -export const CARD_H = 164; - -export const CARD_GAP = 8; -export const MAX_HAND_WIDTH = GAME_W - 80; // leave 40px margin each side - -// Timing -export const LEVEL_COMPLETE_DELAY = 2000; -export const PENALTY_REVEAL_DELAY = 1000; -export const ANIM_DURATION = 250; -export const PRE_PENALTY_PAUSE = 120; - -// Depths -export const DEPTH_CARDS = 1; -export const DEPTH_PILE = 2; -export const DEPTH_PLAYED_CARD = 3; -export const DEPTH_UI = 5; -export const DEPTH_OVERLAY = 2000; -export const DEPTH_OVERLAY_CONTENT = DEPTH_OVERLAY + 1; - -// ── Overlay display ──────────────────────────────────────── -export const OVERLAY_BG_ALPHA = 0.75; -export const OVERLAY_BOX_WIDTH = 460; -export const OVERLAY_BOX_HEIGHT = 280; -export const OVERLAY_BOX_ALPHA = 0.9; -export const OVERLAY_BUTTON_FONT_SIZE = '18px'; -export const OVERLAY_BUTTON_Y_OFFSET = 60; -export const OVERLAY_BUTTON_SPACING = 90; - -// ── Auto-play button ─────────────────────────────────────── -export const AUTO_PLAY_BUTTON_X = 20; -export const AUTO_PLAY_BUTTON_MARGIN = 20; -export const AUTO_PLAY_FONT_SIZE = '12px'; - -// ── HUD / status display ─────────────────────────────────── -export const STATUS_X_OFFSET = 100; -export const STATUS_LEVEL_Y = 55; -export const STATUS_LIVES_Y = 79; - -// ── Pile display ─────────────────────────────────────────── -export const PILE_COUNT_Y_OFFSET = 32; -export const PILE_COUNT_FONT_SIZE = '11px'; -export const PILE_VALUE_Y_OFFSET = 14; -export const PILE_VALUE_FONT_SIZE = '14px'; - -// ── Instruction text ─────────────────────────────────────── -export const INSTRUCTION_MARGIN = 20; -export const INSTRUCTION_FONT_SIZE = '12px'; - -// ── Card hover interaction ───────────────────────────────── -export const HOVER_SCALE = 1.03; -export const HOVER_Y_OFFSET = -4; - -// ── Life flash animation ─────────────────────────────────── -export const FLASH_DELAY = 150; -export const FLASH_REPEATS = 5; -export const FLASH_TIMER_OFFSET = 50; - -// ── Penalty card display ─────────────────────────────────── -export const PENALTY_CARD_ALPHA = 0.8; -export const PENALTY_CLEANUP_EXTRA_DELAY = 50; - -// ── Level-complete text ──────────────────────────────────── -export const LEVEL_COMPLETE_TEXT_Y_OFFSET = 40; -export const LEVEL_COMPLETE_FADE_IN_DURATION = 300; -export const LEVEL_COMPLETE_DISPLAY_DURATION = 2000; - -// ── Phase state machine ───────────────────────────────────── -export type GamePhase = - | 'dealing' - | 'playing' - | 'animating' - | 'penalty' - | 'level-complete' - | 'game-won' - | 'game-lost'; diff --git a/example-games/the-mind/scenes/MindLayoutAdapter.ts b/example-games/the-mind/scenes/MindLayoutAdapter.ts deleted file mode 100644 index 6fa024e4..00000000 --- a/example-games/the-mind/scenes/MindLayoutAdapter.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * MindLayoutAdapter -- maps SLL layout zones to The Mind-specific layout shape. - * - * Uses the SLL layout JSON as the single source of truth for zone positioning. - * - * @module example-games/the-mind/scenes/MindLayoutAdapter - */ - -import { anchorPoint } from '../../../src/ui/screen-layout'; -import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; -import mindLayoutJson from '../layouts/the-mind.layout.json'; - -const parsedLayout = parseScreenLayoutDocument(mindLayoutJson); - -if (!parsedLayout.valid) { - throw new Error( - `Invalid The Mind SLL layout: ${parsedLayout.errors[0]?.message ?? 'unknown parse error'}`, - ); -} - -const MIND_SLL_LAYOUT = parsedLayout.layout; - -export interface MindLayout { - gameW: number; - gameH: number; - playPileCenterX: number; - playPileCenterY: number; - humanHandCenterY: number; - aiHandCenterY: number; -} - -/** - * Compute The Mind layout using SLL zones as the single source of truth. - */ -export function computeMindLayout(viewport?: { width: number; height: number }): MindLayout { - const gameW = viewport?.width ?? 1280; - const gameH = viewport?.height ?? 720; - const vp = { width: gameW, height: gameH }; - - const playPileCenter = anchorPoint(MIND_SLL_LAYOUT, 'playPile', 'center', vp, 1); - const humanHandCenterY = anchorPoint(MIND_SLL_LAYOUT, 'humanHand', 'handCenterY', vp, 1); - const aiHandCenterY = anchorPoint(MIND_SLL_LAYOUT, 'aiHand', 'handCenterY', vp, 1); - - return { - gameW, - gameH, - playPileCenterX: Math.round(playPileCenter.x), - playPileCenterY: Math.round(playPileCenter.y), - humanHandCenterY: Math.round(humanHandCenterY.y), - aiHandCenterY: Math.round(aiHandCenterY.y), - }; -} diff --git a/example-games/the-mind/scenes/MindRenderer.ts b/example-games/the-mind/scenes/MindRenderer.ts deleted file mode 100644 index fd3cff11..00000000 --- a/example-games/the-mind/scenes/MindRenderer.ts +++ /dev/null @@ -1,538 +0,0 @@ -/** - * MindRenderer -- creates and refreshes all visual game objects for The Mind. - * - * Phase 1 migration (CG-0MQ6IEM920091HF6): - * - Human hand now uses shared HandView component. - * - AI hand now uses shared HandView component. - * - Play pile now uses shared PileView component. - * - Custom texture resolution via Mind-specific texture adapters. - */ - -import { FONT_FAMILY, HandView, PileView, layoutCardPositions, type CardTextureResolver } from '../../../src/ui'; -import { createSceneHeader } from '@ui/Renderer'; -import { createMindHudText } from '../../../src/ui/Renderer/adapters/MindAdapter'; -import { applyEnsuredTexture } from '../../../src/ui/Renderer'; -import { - ensureTexture, - ensureBackTexture, - resolveBackTemplateId, - resolveTemplateId, - getCanonicalTextureKey, -} from '../MindCardTextureAdapter'; -import type { MindCard } from '../MindCard'; -import type { TheMindSession } from '../TheMindGameState'; -import { MAX_LEVEL } from '../TheMindGameState'; -import { - CARD_W, CARD_H, CARD_GAP, MAX_HAND_WIDTH, - DEPTH_CARDS, DEPTH_UI, - STATUS_X_OFFSET, - STATUS_LEVEL_Y, - STATUS_LIVES_Y, - PILE_COUNT_Y_OFFSET, - PILE_COUNT_FONT_SIZE, - PILE_VALUE_Y_OFFSET, - PILE_VALUE_FONT_SIZE, - INSTRUCTION_MARGIN, - INSTRUCTION_FONT_SIZE, - HOVER_SCALE, - HOVER_Y_OFFSET, - FLASH_DELAY, - FLASH_REPEATS, - FLASH_TIMER_OFFSET, -} from './MindConstants'; -import { - computeMindLayout, - type MindLayout, -} from './MindLayoutAdapter'; - -export class MindRenderer { - // ── Shared view components (Phase 1 migration: CG-0MQ6IEM920091HF6) ── - - /** HandView for the human player's hand. */ - humanHandView!: HandView; - - /** HandView for the AI player's hand (face-down). */ - aiHandView!: HandView; - - /** PileView for the play pile. */ - pileView!: PileView; - - // Legacy sprite refs (kept for backward compat with animator / tests) - humanCardSprites: Phaser.GameObjects.Image[] = []; - private lastHumanHandRenderArgs: - | { - onCardClick: (card: MindCard) => void; - phase: string; - autoPlayEnabled: boolean; - } - | null = null; - - // Legacy AI hand sprite refs (kept for backward compat) - aiCardSprites: Phaser.GameObjects.Image[] = []; - aiCountText: Phaser.GameObjects.Text | null = null; - - // Legacy pile sprite refs (kept for backward compat) - pileSprite!: Phaser.GameObjects.Image; - pileCountText!: Phaser.GameObjects.Text; - pileValueText!: Phaser.GameObjects.Text; - - // Display objects -- UI - levelText!: Phaser.GameObjects.Text; - livesText!: Phaser.GameObjects.Text; - instructionText!: Phaser.GameObjects.Text; - - /** SLL-derived layout resolved once at construction. */ - private layout: MindLayout; - - private get sceneW(): number { return (this.scene.game.config.width as number) ?? 1280; } - private get sceneH(): number { return (this.scene.game.config.height as number) ?? 720; } - - constructor( - private scene: Phaser.Scene, - private session: TheMindSession, - ) { - this.layout = computeMindLayout({ - width: (this.scene.game.config.width as number) ?? 1280, - height: (this.scene.game.config.height as number) ?? 720, - }); - } - - private getBackTextureFallbackKey(): string { - const canonical = getCanonicalTextureKey(resolveBackTemplateId(), CARD_W, CARD_H); - if (this.scene.textures?.exists(canonical)) return canonical; - if (this.scene.textures?.exists(resolveBackTemplateId())) return resolveBackTemplateId(); - return canonical; - } - - private ensureAiBackTextures(): void { - void (async () => { - try { - const result = await ensureBackTexture(this.scene, CARD_W, CARD_H); - if (!result.ready && result.promise) { - await result.promise; - } - for (const sprite of this.aiCardSprites) { - sprite.setTexture(result.key); - sprite.setDisplaySize(CARD_W, CARD_H); - } - } catch { - // keep fallback back texture - } - })(); - } - - // ── UI creation ───────────────────────────────────────── - - createHeader(): void { - createSceneHeader(this.scene, 'The Mind'); - } - - createStatusDisplay(): void { - this.levelText = createMindHudText( - this.scene, - this.sceneW - STATUS_X_OFFSET, STATUS_LEVEL_Y, '', - '#aaccff', - ); - - this.livesText = createMindHudText( - this.scene, - this.sceneW - STATUS_X_OFFSET, STATUS_LIVES_Y, '', - '#ff6666', - ); - } - - createPile(): void { - const backKey = this.getBackTextureFallbackKey(); - - // ── Shared PileView for the play pile (Phase 1 migration) ── - this.pileView = new PileView(this.scene, { - x: this.layout.playPileCenterX, - y: this.layout.playPileCenterY, - emptyTexture: backKey, - emptyAlpha: 0.3, - fullAlpha: 1, - countOffsetY: CARD_H / 2 + PILE_COUNT_Y_OFFSET, - countFontSize: PILE_COUNT_FONT_SIZE, - countColor: '#888888', - label: 'Pile', - }); - - // Wire the pile model to PileView. - // TheMindSession.pile is a Pile which satisfies CardPile. - this.pileView.setPile(this.session.pile as any); - - // PileView handles the sprite and count label. - // The value text (e.g. "42") is a Mind-specific overlay. - this.pileSprite = this.pileView.getSprite(); - this.pileCountText = this.pileView.getCountText(); - - // Value overlay (numeric value of the top card) - this.pileValueText = this.scene.add - .text(this.layout.playPileCenterX, this.layout.playPileCenterY + CARD_H / 2 + PILE_VALUE_Y_OFFSET, '', { - fontSize: PILE_VALUE_FONT_SIZE, - color: '#ffffff', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5) - .setDepth(DEPTH_UI); - } - - createInstruction(): void { - this.instructionText = this.scene.add - .text(this.sceneW / 2, this.sceneH - INSTRUCTION_MARGIN, '', { - fontSize: INSTRUCTION_FONT_SIZE, - color: '#aaaaaa', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5) - .setDepth(DEPTH_UI); - } - - // ── Status refresh ───────────────────────────────────── - - refreshStatus(): void { - this.levelText.setText( - `Level ${this.session.currentLevel} / ${MAX_LEVEL}`, - ); - - const hearts = '\u2764'.repeat(this.session.lives); - this.livesText.setText(`Lives: ${hearts}`); - } - - refreshPile(): void { - const topCard = this.session.pile.peek(); - const pileSize = this.session.pile.size(); - - if (pileSize > 0 && topCard) { - const backKey = this.getBackTextureFallbackKey(); - const faceKey = getCanonicalTextureKey(resolveTemplateId(topCard.value), CARD_W, CARD_H); - const hasFaceTexture = !!this.scene.textures?.exists?.(faceKey); - - // Avoid flicker: if face texture is already available, use it immediately - // instead of flashing back texture first. - this.pileSprite.setTexture(hasFaceTexture ? faceKey : backKey); - this.pileSprite.setDisplaySize(CARD_W, CARD_H); - this.pileSprite.setAlpha(1); - this.pileValueText.setText(`${topCard.value}`); - - void applyEnsuredTexture( - this.pileSprite, - ensureTexture(this.scene, topCard.value, CARD_W, CARD_H), - () => !!this.pileSprite, - CARD_W, - CARD_H, - ); - } else { - const backKey = this.getBackTextureFallbackKey(); - this.pileSprite.setTexture(backKey); - this.pileSprite.setDisplaySize(CARD_W, CARD_H); - this.pileSprite.setAlpha(0.3); - this.pileValueText.setText('Empty'); - } - - this.pileCountText.setText( - pileSize > 0 ? `${pileSize} card${pileSize !== 1 ? 's' : ''}` : '', - ); - } - - /** - * Create HandView components for the human and AI hands. - * Call this once during scene creation, before rendering the initial state. - */ - createHands(): void { - // Human hand HandView - this.humanHandView = new HandView(this.scene, { - baseX: this.sceneW / 2, - baseY: this.layout.humanHandCenterY, - spacing: CARD_GAP + CARD_W, - cardWidth: CARD_W, - maxWidth: MAX_HAND_WIDTH, - showLabels: false, - selectionEnabled: false, - clickEnabled: true, - arcRadius: 0, - maxRotationDegrees: 0, - }); - - // AI hand HandView (face-down cards) - this.aiHandView = new HandView(this.scene, { - baseX: this.sceneW / 2, - baseY: this.layout.aiHandCenterY, - spacing: CARD_GAP + CARD_W, - cardWidth: CARD_W, - maxWidth: MAX_HAND_WIDTH, - showLabels: false, - selectionEnabled: false, - clickEnabled: false, - arcRadius: 0, - maxRotationDegrees: 0, - }); - } - - // ── Human hand rendering (Phase 1: uses HandView) ────── - - renderHumanHand(onCardClick: (card: MindCard) => void, phase: string, autoPlayEnabled: boolean): void { - this.lastHumanHandRenderArgs = { onCardClick, phase, autoPlayEnabled }; - - const hand = this.session.players[0].hand; - - if (hand.length === 0) { - this.humanHandView.setCards([], { cardTextureFn: this._humanCardTextureFn }); - return; - } - - // Use HandView for layout, selection, and click handling. - // Mind-specific: each card's texture is loaded lazily via applyEnsuredTexture. - this.humanHandView.setCards(hand as any, { cardTextureFn: this._humanCardTextureFn }); - this.humanHandView.on('cardclick', (idx: number) => { - if (idx >= 0 && idx < hand.length) { - onCardClick(hand[idx]); - } - }); - - // Update sprite display size and store card value for lazy texture loading. - const sprites = this.humanHandView.getSprites() as Phaser.GameObjects.Image[]; - this.humanCardSprites = sprites; - - for (let i = 0; i < sprites.length; i++) { - const sprite = sprites[i]; - const card = hand[i]; - (sprite as any).__mindCardValue = card.value; - sprite.setDisplaySize(CARD_W, CARD_H); - sprite.setDepth(DEPTH_CARDS + i); - sprite.setInteractive({ useHandCursor: true }); - - // Kick off lazy rasterisation - void applyEnsuredTexture( - sprite, - ensureTexture(this.scene, card.value, CARD_W, CARD_H), - () => this.humanCardSprites.includes(sprite), - CARD_W, - CARD_H, - ); - - // Hover feedback (only during playing phase, not auto-play) - sprite.on('pointerover', () => { - if (phase === 'playing' && !autoPlayEnabled) { - sprite.setDisplaySize(CARD_W * HOVER_SCALE, CARD_H * HOVER_SCALE); - sprite.setY(this.layout.humanHandCenterY + HOVER_Y_OFFSET); - } - }); - sprite.on('pointerout', () => { - sprite.setDisplaySize(CARD_W, CARD_H); - sprite.setY(this.layout.humanHandCenterY); - }); - } - } - - /** - * Mind-specific texture resolver for the human hand. - * Returns the fallback back texture key (actual card textures loaded lazily). - */ - private _humanCardTextureFn: CardTextureResolver = ( - _card: MindCard, - ): string => { - // Return card-back as placeholder; lazy texture updates replace it. - return this.getBackTextureFallbackKey(); - }; - - refreshHumanHand(): void { - const hand = this.session.players[0].hand; - const sprites = this.humanCardSprites; - - if (hand.length !== sprites.length) { - // Can't re-render here without callbacks; caller should use renderHumanHand - return; - } - - for (let i = 0; i < hand.length; i++) { - const card = hand[i]; - const sprite = sprites[i]; - (sprite as any).__mindCardValue = card.value; - - // Update sprite with lazy texture loading. - sprite.setDisplaySize(CARD_W, CARD_H); - void applyEnsuredTexture( - sprite, - ensureTexture(this.scene, card.value, CARD_W, CARD_H), - () => sprites[i] === sprite, - CARD_W, - CARD_H, - ); - } - } - - // ── AI hand rendering (Phase 1: uses HandView) ───────── - - renderAiHand(): void { - const hand = this.session.players[1].hand; - const backKey = this.getBackTextureFallbackKey(); - - if (hand.length === 0) { - if (this.aiCountText) this.aiCountText.setText(''); - this.aiHandView.setCards([]); - this.aiCardSprites = []; - return; - } - - // Use HandView for layout; AI cards are always face-down. - this.aiHandView.setCards(hand as any, { cardTextureFn: () => backKey }); - const sprites = this.aiHandView.getSprites() as Phaser.GameObjects.Image[]; - this.aiCardSprites = sprites; - - // Apply Mind-specific properties to sprites. - for (let i = 0; i < sprites.length; i++) { - const sprite = sprites[i]; - sprite.setDisplaySize(CARD_W, CARD_H); - sprite.setDepth(DEPTH_CARDS + i); - } - - // Ensure AI back textures are loaded. - this.ensureAiBackTextures(); - } - - refreshAiHand(): void { - const hand = this.session.players[1].hand; - - if (hand.length !== this.aiCardSprites.length) { - return; - } - - if (this.aiCountText) { - this.aiCountText.setText( - hand.length > 0 - ? `AI: ${hand.length} card${hand.length !== 1 ? 's' : ''}` - : '', - ); - } - } - - // ── Refresh all ──────────────────────────────────────── - - refreshAll(): void { - const humanHand = this.session.players[0].hand; - const humanSprites = this.humanCardSprites; - - if ( - humanHand.length !== humanSprites.length && - this.lastHumanHandRenderArgs - ) { - this.renderHumanHand( - this.lastHumanHandRenderArgs.onCardClick, - this.lastHumanHandRenderArgs.phase, - this.lastHumanHandRenderArgs.autoPlayEnabled, - ); - } else { - this.refreshHumanHand(); - } - - const aiHand = this.session.players[1].hand; - if (aiHand.length !== this.aiCardSprites.length) { - this.renderAiHand(); - } else { - this.refreshAiHand(); - } - - this.refreshPile(); - this.refreshStatus(); - } - - // ── Replay helpers ───────────────────────────────────── - - renderReplayHand( - cardValues: number[], - y: number, - faceUp: boolean, - spriteArray: Phaser.GameObjects.Image[], - label: string, - labelColor: string, - ): void { - if (cardValues.length === 0) return; - - const { positions } = layoutCardPositions({ - count: cardValues.length, - cardWidth: CARD_W, - gap: CARD_GAP, - centerX: this.sceneW / 2, - maxWidth: MAX_HAND_WIDTH, - }); - - for (let i = 0; i < cardValues.length; i++) { - const x = positions[i]; - const card: MindCard = { value: cardValues[i], faceUp }; - const backKey = this.getBackTextureFallbackKey(); - const sprite = this.scene.add - .image(x, y, backKey) - .setDisplaySize(CARD_W, CARD_H) - .setDepth(DEPTH_CARDS + i); - - if (faceUp) { - void applyEnsuredTexture( - sprite, - ensureTexture(this.scene, card.value, CARD_W, CARD_H), - () => (sprite as any).active !== false, - CARD_W, - CARD_H, - ); - } - - spriteArray.push(sprite); - } - - this.scene.add - .text(this.sceneW / 2, y - CARD_H / 2 - 14, label, { - fontSize: '12px', - color: labelColor, - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5) - .setDepth(DEPTH_UI); - } - - clearSprites(): void { - for (const sprite of this.humanCardSprites) sprite.destroy(); - this.humanCardSprites = []; - for (const sprite of this.aiCardSprites) sprite.destroy(); - this.aiCardSprites = []; - } - - // ── Destroy (Phase 1 migration) ───────────────────────── - - /** Clean up all display objects including shared view components. */ - destroy(): void { - this.humanHandView.destroy(); - this.aiHandView.destroy(); - this.pileView.destroy(); - this.clearSprites(); - } - - disableGameInteraction(autoPlayButton?: Phaser.GameObjects.Text): void { - for (const sprite of this.humanCardSprites) { - if (sprite && sprite.scene) { - sprite.disableInteractive(); - } - } - if (autoPlayButton) { - if (autoPlayButton.scene) { - autoPlayButton.disableInteractive(); - } - } - } - - flashLives(): void { - let flashes = 0; - const flashTimer = this.scene.time.addEvent({ - delay: FLASH_DELAY, - repeat: FLASH_REPEATS, - callback: () => { - flashes++; - this.livesText.setColor(flashes % 2 === 0 ? '#ff6666' : '#ffffff'); - }, - }); - - this.scene.time.delayedCall(FLASH_DELAY * 6 + FLASH_TIMER_OFFSET, () => { - flashTimer.destroy(); - this.livesText.setColor('#ff6666'); - }); - } -} diff --git a/example-games/the-mind/scenes/MindReplayController.ts b/example-games/the-mind/scenes/MindReplayController.ts deleted file mode 100644 index 3576fa62..00000000 --- a/example-games/the-mind/scenes/MindReplayController.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * MindReplayController -- handles replay mode state injection for The Mind. - */ - -import { resolveTemplateId, resolveBackTemplateId, getCanonicalTextureKey } from '../MindCardTextureAdapter'; -import { MAX_LEVEL } from '../TheMindGameState'; -import type { MindRenderer } from './MindRenderer'; -import { DEPTH_UI, CARD_W, CARD_H } from './MindConstants'; - -export class MindReplayController { - replayStepIndex = 0; - - constructor( - private scene: Phaser.Scene, - private renderer: MindRenderer, - private replayMode: { value: boolean }, - ) {} - - loadBoardState(state: { - humanHand: number[]; - aiHand: number[]; - pileTop: number; - pileSize: number; - currentLevel: number; - lives: number; - stepIndex?: number; - }): void { - if (!this.replayMode.value) { - throw new Error( - 'loadBoardState() is only available in replay mode (?mode=replay)', - ); - } - - this.renderer.clearSprites(); - - const layout = (this.renderer as any).layout; - this.renderer.renderReplayHand( - state.humanHand, - layout.humanHandCenterY, - true, - this.renderer.humanCardSprites, - 'Your Hand', - '#88ff88', - ); - - this.renderer.renderReplayHand( - state.aiHand, - layout.aiHandCenterY, - false, - this.renderer.aiCardSprites, - 'AI Hand', - '#ffaa44', - ); - - if (this.renderer.aiCountText) this.renderer.aiCountText.destroy(); - if (state.aiHand.length > 0) { - this.renderer.aiCountText = this.scene.add - .text(this.scene.scale.width / 2, layout.aiHandCenterY + CARD_H / 2 + 14, '', { - fontSize: '12px', - color: '#aaaaaa', - fontFamily: 'sans-serif', - }) - .setOrigin(0.5) - .setDepth(DEPTH_UI); - this.renderer.aiCountText.setText( - `AI: ${state.aiHand.length} card${state.aiHand.length !== 1 ? 's' : ''}`, - ); - } else { - this.renderer.aiCountText = null; - } - - if (state.pileTop > 0) { - const faceUpKey = getCanonicalTextureKey(resolveTemplateId(state.pileTop), CARD_W, CARD_H); - this.renderer.pileSprite.setTexture(faceUpKey); - this.renderer.pileSprite.setDisplaySize(CARD_W, CARD_H); - this.renderer.pileSprite.setAlpha(1); - this.renderer.pileValueText.setText(`${state.pileTop}`); - } else { - const backKey = getCanonicalTextureKey(resolveBackTemplateId(), CARD_W, CARD_H); - this.renderer.pileSprite.setTexture(backKey); - this.renderer.pileSprite.setDisplaySize(CARD_W, CARD_H); - this.renderer.pileSprite.setAlpha(0.3); - this.renderer.pileValueText.setText('Empty'); - } - this.renderer.pileCountText.setText( - state.pileSize > 0 - ? `${state.pileSize} card${state.pileSize !== 1 ? 's' : ''}` - : '', - ); - - this.renderer.levelText.setText(`Level ${state.currentLevel} / ${MAX_LEVEL}`); - const hearts = '\u2764'.repeat(Math.max(0, state.lives)); - this.renderer.livesText.setText(`Lives: ${hearts}`); - - if (state.stepIndex !== undefined) { - this.replayStepIndex = state.stepIndex; - } - - (this.scene as any).emitStateSettled(this.replayStepIndex, 'playing'); - } -} diff --git a/example-games/the-mind/scenes/MindTurnController.ts b/example-games/the-mind/scenes/MindTurnController.ts deleted file mode 100644 index 72073ac1..00000000 --- a/example-games/the-mind/scenes/MindTurnController.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * MindTurnController -- handles card play logic, level lifecycle, and game over for The Mind. - */ - -import type { PlayResult, PlayerId, TheMindSession } from '../TheMindGameState'; -import { playCard, isGameOver, getPileTopValue } from '../TheMindGameState'; -import { MindTranscriptRecorder } from '../GameTranscript'; -import type { GameEventEmitter, SoundManager } from '../../../src/core-engine'; -import { SFX_KEYS } from './MindAudioKeys'; -import type { MindAiScheduler } from './MindAiScheduler'; - -export class MindTurnController { - turnCounter = 0; - levelStartTime = 0; - - constructor( - private session: TheMindSession, - private recorder: MindTranscriptRecorder, - private gameEvents: GameEventEmitter, - private soundManager: SoundManager | null, - ) {} - - performPlay( - playerId: PlayerId, - cardValue: number, - aiScheduler: MindAiScheduler, - animateCard: (playerId: PlayerId, cardValue: number, onComplete: () => void) => void, - onPenaltyComplete: (result: PlayResult) => void, - onNormalComplete: (result: PlayResult) => void, - onInvalidPlay: (cardValue: number) => void, - ): void { - const timestamp = Date.now() - this.levelStartTime; - const result = playCard(this.session, playerId, cardValue); - - if (!result.success) { - if (playerId === 0) { - onInvalidPlay(cardValue); - } - return; - } - - this.turnCounter++; - this.soundManager?.play(SFX_KEYS.CARD_PLAY); - - this.recorder.recordCardPlay( - timestamp, - playerId, - cardValue, - getPileTopValue(this.session), - this.session.pile.size(), - ); - - aiScheduler.removeCardFromAi(cardValue); - - if (result.lifeLost) { - aiScheduler.cancelAllTimers(); - this.soundManager?.play(SFX_KEYS.LIFE_LOST); - - this.recorder.recordPenalty( - timestamp, - this.session.lives, - result.penaltyCards.map((p) => ({ - playerId: p.playerId, - cardValue: p.card.value, - })), - ); - - aiScheduler.removePenaltyCards(result.penaltyCards); - - animateCard(playerId, cardValue, () => { - onPenaltyComplete(result); - }); - return; - } - - animateCard(playerId, cardValue, () => { - onNormalComplete(result); - }); - } - - handleLevelComplete( - result: PlayResult, - onUiUpdate: () => void, - onNextLevel: () => void, - showLevelCompleteText: (completedLevel: number, bonusLifeAwarded: boolean, onComplete: () => void) => void, - ): void { - const timestamp = Date.now() - this.levelStartTime; - const completedLevel = this.session.currentLevel - (result.levelComplete ? 1 : 0); - const handsDealt: [readonly number[], readonly number[]] | undefined = - !(isGameOver(this.session) && this.session.outcome === 'win') - ? [ - this.session.players[0].hand.map((c) => c.value), - this.session.players[1].hand.map((c) => c.value), - ] - : undefined; - - this.recorder.recordLevelComplete( - timestamp, - completedLevel, - result.bonusLifeAwarded, - this.session.lives, - handsDealt, - ); - - if (isGameOver(this.session) && this.session.outcome === 'win') { - return; // Let caller handle game over - } - - this.soundManager?.play(SFX_KEYS.LEVEL_COMPLETE); - onUiUpdate(); - - showLevelCompleteText(completedLevel, result.bonusLifeAwarded, () => { - onNextLevel(); - }); - } - - handleGameOver( - onWin: () => void, - onLoss: () => void, - ): 'win' | 'loss' { - const timestamp = Date.now() - this.levelStartTime; - const outcome = this.session.outcome as 'win' | 'loss'; - - this.recorder.finalize( - timestamp, - outcome, - this.session.currentLevel, - this.session.lives, - ); - - this.gameEvents.emit('game-ended', { - finalTurnNumber: this.turnCounter, - winnerIndex: outcome === 'win' ? 0 : -1, - reason: - outcome === 'win' - ? `Completed all 8 levels!` - : 'Ran out of lives', - }); - - if (outcome === 'win') { - onWin(); - } else { - onLoss(); - } - - return outcome; - } - - createRecorder(initialState: { - playerNames: [string, string]; - isAI: [boolean, boolean]; - startingLives: number; - startingLevel: number; - hands: [number[], number[]]; - }): MindTranscriptRecorder { - return new MindTranscriptRecorder(initialState); - } -} diff --git a/example-games/the-mind/scenes/TheMindScene.ts b/example-games/the-mind/scenes/TheMindScene.ts deleted file mode 100644 index 2d731eb7..00000000 --- a/example-games/the-mind/scenes/TheMindScene.ts +++ /dev/null @@ -1,568 +0,0 @@ -/** - * TheMindScene -- the main Phaser scene for The Mind. - * - * Orchestrates the visual interface by delegating responsibilities to - * composable helper classes: - * - MindRenderer: board layout and sprite refresh - * - MindAnimator: card animations and visual effects - * - MindAiScheduler: AI turn scheduling and auto-play spectator mode - * - MindOverlayManager: win/loss overlays - * - MindReplayController: replay mode state injection - * - MindTurnController: card play logic, level lifecycle, and game over - */ - -import type { MindCard } from '../MindCard'; -import type { PlayResult, TheMindSession } from '../TheMindGameState'; -import { - setupTheMindGame, - isGameOver, - MAX_LEVEL, -} from '../TheMindGameState'; -import { MindAiPlayer } from '../AiStrategy'; -import { - preloadMindCardAssets, -} from '../MindCardRenderer'; -import { MindTranscriptRecorder } from '../GameTranscript'; -import type { EventSoundMapping } from '../../../src/core-engine/SoundManager'; -import { - CardGameScene, - OverlayManager, - createSceneHeader, - createParameterizedOverlay, - overlayCenterY, - audioPathWithFallback, -} from '../../../src/ui'; -import type { HelpSection } from '../../../src/ui'; -import helpContent from '../help-content.json'; - -import { - PRE_PENALTY_PAUSE, - DEPTH_OVERLAY, - DEPTH_OVERLAY_CONTENT, - DEPTH_UI, - OVERLAY_BG_ALPHA, - OVERLAY_BOX_WIDTH, - OVERLAY_BOX_HEIGHT, - OVERLAY_BOX_ALPHA, - OVERLAY_BUTTON_FONT_SIZE, - OVERLAY_BUTTON_Y_OFFSET, - AUTO_PLAY_BUTTON_X, - AUTO_PLAY_BUTTON_MARGIN, - AUTO_PLAY_FONT_SIZE, - type GamePhase, -} from './MindConstants'; -import { SFX_KEYS } from './MindAudioKeys'; -import { MindRenderer } from './MindRenderer'; -import { MindAnimator } from './MindAnimator'; -import { MindAiScheduler } from './MindAiScheduler'; - -import { MindReplayController } from './MindReplayController'; -import { MindTurnController } from './MindTurnController'; - -import { GAME_W, GAME_H } from '../../../src/ui'; - -export class TheMindScene extends CardGameScene { - // Game state (accessed by tests) - session!: TheMindSession; - recorder!: MindTranscriptRecorder; - phase: GamePhase = 'dealing'; - turnCounter = 0; - replayStepIndex = 0; - - // AI scheduling (accessed by tests indirectly) - aiPlayer!: MindAiPlayer; - - // Auto-play spectator mode - autoPlayEnabled = false; - autoPlayButton!: Phaser.GameObjects.Text; - - // Display objects -- human hand (accessed by tests) - humanCardSprites: Phaser.GameObjects.Image[] = []; - - // Display objects -- AI hand (accessed by tests) - aiCardSprites: Phaser.GameObjects.Image[] = []; - aiCountText: Phaser.GameObjects.Text | null = null; - - // Display objects -- pile (accessed by tests) - pileSprite!: Phaser.GameObjects.Image; - pileCountText!: Phaser.GameObjects.Text; - pileValueText!: Phaser.GameObjects.Text; - - // Display objects -- UI (accessed by tests) - levelText!: Phaser.GameObjects.Text; - livesText!: Phaser.GameObjects.Text; - instructionText!: Phaser.GameObjects.Text; - - // Overlay tracking - overlayObjects: Phaser.GameObjects.GameObject[] = []; - - private overlayManager!: OverlayManager; - - // Helpers - private mindRenderer!: MindRenderer; - private mindAnimator!: MindAnimator; - private aiScheduler!: MindAiScheduler; - private replayController!: MindReplayController; - private turnController!: MindTurnController; - - constructor() { - super({ key: 'TheMindScene' }); - } - - // ── Preload ───────────────────────────────────────────── - - preload(): void { - preloadMindCardAssets(this, 120, 164); - - const ns = 'the-mind'; - const audioDir = 'the-mind'; - this.load.audio(`${ns}:${SFX_KEYS.CARD_PLAY}`, audioPathWithFallback(audioDir, 'card-play.wav')); - this.load.audio(`${ns}:${SFX_KEYS.LIFE_LOST}`, audioPathWithFallback(audioDir, 'life-lost.wav')); - this.load.audio(`${ns}:${SFX_KEYS.LEVEL_COMPLETE}`, audioPathWithFallback(audioDir, 'level-complete.wav')); - this.load.audio(`${ns}:${SFX_KEYS.GAME_WIN}`, audioPathWithFallback(audioDir, 'game-win.wav')); - this.load.audio(`${ns}:${SFX_KEYS.GAME_LOST}`, audioPathWithFallback(audioDir, 'game-lost.wav')); - this.load.audio(`${ns}:${SFX_KEYS.UI_CLICK}`, audioPathWithFallback(audioDir, 'ui-click.wav')); - } - - // ── Create ────────────────────────────────────────────── - - create(): void { - this.cameras.main.setBackgroundColor('#1a1a2e'); - this.events.on('shutdown', this.shutdown, this); - - this.resetSceneState(); - super.create(); - - if (this.replayMode) { - this.createReplayView(); - return; - } - - this.createSoundSystem(); - this.initializeGameControllers(); - // Propagate reduced motion preference to the animator - if (this.settingsPanel) { - this.mindAnimator.reducedMotion = this.settingsPanel.reducedMotion; - } - this.createPrimaryView(); - this.renderInitialState(); - this.startLevel(); - } - - private resetSceneState(): void { - this.phase = 'dealing'; - this.humanCardSprites = []; - this.aiCardSprites = []; - this.overlayObjects = []; - this.overlayManager = new OverlayManager(this); - this.turnCounter = 0; - this.replayStepIndex = 0; - this.aiCountText = null; - - const urlParams = new URLSearchParams(window.location.search); - this.autoPlayEnabled = urlParams.get('autoplay') === 'true'; - } - - private createReplayView(): void { - this.createHeader(); - this.createStatusDisplay(); - // In replay mode, the replay controller handles rendering; skip shared view init. - this.createPile(); - this.createInstruction(); - this.instructionText.setText(''); - this.levelText.setText('Level 1 / 8'); - this.livesText.setText('Lives: \u2764\u2764'); - this.emitStateSettled(0, 'playing'); - } - - private initializeGameControllers(): void { - this.session = setupTheMindGame(); - this.aiPlayer = new MindAiPlayer(); - this.recorder = this.createRecorder(); - - this.mindRenderer = new MindRenderer(this, this.session); - this.mindAnimator = new MindAnimator(this, this.session, this.mindRenderer, this.soundManager); - this.aiScheduler = new MindAiScheduler(this, this.session); - - this.replayController = new MindReplayController(this, this.mindRenderer, { value: this.replayMode }); - this.turnController = new MindTurnController(this.session, this.recorder, this.gameEvents, this.soundManager); - this.aiScheduler.autoPlayEnabled = this.autoPlayEnabled; - } - - private createPrimaryView(): void { - this.mindRenderer.createHeader(); - this.mindRenderer.createStatusDisplay(); - this.mindRenderer.createHands(); - this.mindRenderer.createPile(); - this.mindRenderer.createInstruction(); - this.createAutoPlayButton(); - this.initHelpPanel(helpContent as HelpSection[]); - this.bindRendererObjects(); - } - - private bindRendererObjects(): void { - this.humanCardSprites = this.mindRenderer.humanCardSprites; - this.aiCardSprites = this.mindRenderer.aiCardSprites; - this.aiCountText = this.mindRenderer.aiCountText; - this.pileSprite = this.mindRenderer.pileSprite; - this.pileCountText = this.mindRenderer.pileCountText; - this.pileValueText = this.mindRenderer.pileValueText; - this.levelText = this.mindRenderer.levelText; - this.livesText = this.mindRenderer.livesText; - this.instructionText = this.mindRenderer.instructionText; - } - - private renderInitialState(): void { - this.mindRenderer.renderHumanHand( - (card) => this.onHumanCardClick(card), - this.phase, - this.autoPlayEnabled, - ); - this.mindRenderer.renderAiHand(); - this.mindRenderer.refreshStatus(); - this.mindRenderer.refreshPile(); - } - - // ── Header & Status ──────────────────────────────────── - - private createHeader(): void { - createSceneHeader(this, 'The Mind'); - } - - private createStatusDisplay(): void { - this.mindRenderer.createStatusDisplay(); - } - - private createPile(): void { - this.mindRenderer.createPile(); - } - - private createInstruction(): void { - this.mindRenderer.createInstruction(); - } - - // ── Sound system ──────────────────────────────────────── - - private createSoundSystem(): void { - const mapping: EventSoundMapping = { - 'game-ended': SFX_KEYS.UI_CLICK, - }; - this.initSoundSystem(Object.values(SFX_KEYS), mapping, { namespace: 'the-mind' }); - this.initSettingsPanel(); - } - - // ── Auto-play spectator mode ─────────────────────────── - - private createAutoPlayButton(): void { - const label = this.autoPlayEnabled ? '[ Auto-Play: ON ]' : '[ Auto-Play: OFF ]'; - this.autoPlayButton = this.add - .text(AUTO_PLAY_BUTTON_X, this.scale.height - AUTO_PLAY_BUTTON_MARGIN, label, { - fontSize: AUTO_PLAY_FONT_SIZE, - color: this.autoPlayEnabled ? '#88ff88' : '#888888', - fontFamily: 'sans-serif', - }) - .setOrigin(0, 1) - .setDepth(DEPTH_UI) - .setInteractive({ useHandCursor: true }); - - this.autoPlayButton.on('pointerdown', () => this.toggleAutoPlay()); - this.autoPlayButton.on('pointerover', () => - this.autoPlayButton.setColor('#ffffff'), - ); - this.autoPlayButton.on('pointerout', () => - this.autoPlayButton.setColor( - this.autoPlayEnabled ? '#88ff88' : '#888888', - ), - ); - } - - private toggleAutoPlay(): void { - this.autoPlayEnabled = this.aiScheduler.toggleAutoPlay( - this.autoPlayEnabled, - (enabled) => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - this.autoPlayButton.setText( - enabled ? '[ Auto-Play: ON ]' : '[ Auto-Play: OFF ]', - ); - this.autoPlayButton.setColor( - enabled ? '#88ff88' : '#888888', - ); - if (enabled) { - this.instructionText.setText('Spectator mode — watching AI play'); - if (this.phase === 'playing') { - this.aiScheduler.scheduleHumanAiPlay(this.phase, (v) => this.performPlay(0, v)); - } - } else { - this.instructionText.setText('Click a card to play it onto the pile'); - } - }, - ); - - this.gameEvents.emit('ui-interaction', { - elementId: 'auto-play-toggle', - action: this.autoPlayEnabled ? 'enabled' : 'disabled', - }); - } - - // ── Status refresh ───────────────────────────────────── - - // ── Level lifecycle ──────────────────────────────────── - - private startLevel(): void { - this.turnController.levelStartTime = Date.now(); - this.aiScheduler.startLevel(); - this.setPhase('playing'); - this.aiScheduler.scheduleAiPlay(this.phase, (v) => this.performPlay(1, v)); - if (this.autoPlayEnabled) { - this.aiScheduler.scheduleHumanAiPlay(this.phase, (v) => this.performPlay(0, v)); - } - } - - private setPhase(phase: GamePhase): void { - this.phase = phase; - - switch (phase) { - case 'playing': - this.instructionText.setText( - this.autoPlayEnabled - ? 'Spectator mode \u2014 watching AI play' - : 'Click a card to play it onto the pile', - ); - break; - case 'animating': - this.instructionText.setText(''); - break; - case 'penalty': - this.instructionText.setText('Penalty! A life was lost...'); - break; - default: - this.instructionText.setText(''); - } - } - - // ── Human input ──────────────────────────────────────── - - private onHumanCardClick(card: MindCard): void { - if (this.phase !== 'playing') return; - if (this.autoPlayEnabled) return; - - this.performPlay(0, card.value); - } - - // ── Card play logic ──────────────────────────────────── - - private performPlay(playerId: 0 | 1, cardValue: number): void { - this.turnController.performPlay( - playerId, - cardValue, - this.aiScheduler, - (pid, val, onComplete) => this.mindAnimator.animateCardTowardsPile(pid, val, onComplete), - (result) => this.handlePenalty(result), - (result) => this.handleNormalPlay(result), - (val) => this.mindAnimator.showInvalidPlayFeedback(val), - ); - } - - private handlePenalty(result: PlayResult): void { - this.setPhase('penalty'); - this.mindRenderer.refreshPile(); - this.mindRenderer.refreshStatus(); - this.mindRenderer.flashLives(); - - this.time.delayedCall(PRE_PENALTY_PAUSE, () => { - this.mindAnimator.showPenaltyCards(result, () => { - this.mindRenderer.refreshAll(); - - if (isGameOver(this.session)) { - this.handleGameOver(); - return; - } - - if (result.levelComplete) { - this.handleLevelComplete(result); - return; - } - - this.setPhase('playing'); - this.aiScheduler.scheduleAiPlay(this.phase, (v) => this.performPlay(1, v)); - if (this.autoPlayEnabled) { - this.aiScheduler.scheduleHumanAiPlay(this.phase, (v) => this.performPlay(0, v)); - } - }); - }); - } - - private handleNormalPlay(result: PlayResult): void { - this.mindRenderer.refreshAll(); - - if (result.levelComplete) { - this.handleLevelComplete(result); - return; - } - - this.setPhase('playing'); - this.aiScheduler.rescheduleAiIfNeeded(this.phase, (v) => this.performPlay(1, v)); - this.aiScheduler.rescheduleHumanAiIfNeeded(this.phase, (v) => this.performPlay(0, v)); - } - - // ── Level completion ─────────────────────────────────── - - private handleLevelComplete(result: PlayResult): void { - this.aiScheduler.cancelAllTimers(); - - this.turnController.handleLevelComplete( - result, - () => this.mindRenderer.refreshAll(), - () => { - this.mindRenderer.renderHumanHand( - (card) => this.onHumanCardClick(card), - this.phase, - this.autoPlayEnabled, - ); - this.mindRenderer.renderAiHand(); - this.mindRenderer.refreshStatus(); - this.mindRenderer.refreshPile(); - this.startLevel(); - }, - (completedLevel, bonusLifeAwarded, onComplete) => { - this.setPhase('level-complete'); - this.mindAnimator.showLevelCompleteText(completedLevel, bonusLifeAwarded, onComplete); - }, - ); - - if (isGameOver(this.session) && this.session.outcome === 'win') { - this.handleGameOver(); - } - } - - // ── Game over ────────────────────────────────────────── - - handleGameOver(): void { - this.aiScheduler.cancelAllTimers(); - this.mindRenderer.disableGameInteraction(this.autoPlayButton); - - this.turnController.handleGameOver( - () => { - this.setPhase('game-won'); - this.showWinOverlay(); - }, - () => { - this.setPhase('game-lost'); - this.showLossOverlay(); - }, - ); - } - - // ── Overlay helpers ──────────────────────────────────── - - private showWinOverlay(): void { - this.soundManager?.play(SFX_KEYS.GAME_WIN); - this.showOutcomeOverlay({ - title: 'You Win!', - titleColor: '#88ff88', - detailText: `Completed all ${MAX_LEVEL} levels!\nLives remaining: ${'❤'.repeat(this.session.lives)}`, - primaryButtonLabel: '[ Play Again ]', - primaryButtonEvent: 'play-again', - }); - } - - private showLossOverlay(): void { - this.soundManager?.play(SFX_KEYS.GAME_LOST); - this.showOutcomeOverlay({ - title: 'Game Over', - titleColor: '#ff6666', - detailText: `Reached Level ${this.session.currentLevel} of ${MAX_LEVEL}\nRan out of lives!`, - primaryButtonLabel: '[ Try Again ]', - primaryButtonEvent: 'try-again', - }); - } - - private showOutcomeOverlay(config: { - title: string; - titleColor: string; - detailText: string; - primaryButtonLabel: string; - primaryButtonEvent: string; - }): void { - this.overlayManager.dismiss(); - - const result = createParameterizedOverlay(this, { - title: config.title, - titleColor: config.titleColor, - detailText: config.detailText, - titleY: overlayCenterY(-60), - detailY: overlayCenterY(-15), - titleDepth: DEPTH_OVERLAY_CONTENT, - detailDepth: DEPTH_OVERLAY_CONTENT, - background: { depth: DEPTH_OVERLAY, alpha: OVERLAY_BG_ALPHA }, - box: { width: OVERLAY_BOX_WIDTH, height: OVERLAY_BOX_HEIGHT, alpha: OVERLAY_BOX_ALPHA }, - buttons: [ - { - label: config.primaryButtonLabel, - x: GAME_W / 2, - y: GAME_H / 2 + OVERLAY_BUTTON_Y_OFFSET, - config: { fontSize: OVERLAY_BUTTON_FONT_SIZE }, - onClick: () => { - this.soundManager?.play(SFX_KEYS.UI_CLICK); - this.gameEvents.emit('ui-interaction', { - elementId: config.primaryButtonEvent, - action: 'click', - }); - this.time.delayedCall(0, () => this.scene.restart()); - }, - }, - ], - }); - - this.overlayManager.add(...result); - this.overlayObjects = this.overlayManager.objects; - } - - // ── Refresh all ──────────────────────────────────────── - - // ── Transcript helper ────────────────────────────────── - - private createRecorder(): MindTranscriptRecorder { - return new MindTranscriptRecorder({ - playerNames: [ - this.session.players[0].name, - this.session.players[1].name, - ], - isAI: [ - this.session.players[0].isAI, - this.session.players[1].isAI, - ], - startingLives: this.session.lives, - startingLevel: this.session.currentLevel, - hands: [ - this.session.players[0].hand.map((c) => c.value), - this.session.players[1].hand.map((c) => c.value), - ], - }); - } - - // ── Replay API ───────────────────────────────────────── - - loadBoardState(state: { - humanHand: number[]; - aiHand: number[]; - pileTop: number; - pileSize: number; - currentLevel: number; - lives: number; - stepIndex?: number; - }): void { - this.replayController.loadBoardState(state); - this.replayStepIndex = this.replayController.replayStepIndex; - } - - // ── Shutdown ──────────────────────────────────────────── - - shutdown(): void { - this.events.off('shutdown', this.shutdown, this); - this.aiScheduler.destroy(); - this.overlayManager.dismiss(); - this.overlayObjects = []; - this.shutdownBase(); - } -} diff --git a/example-games/the-mind/scenes/penaltyAnimation.ts b/example-games/the-mind/scenes/penaltyAnimation.ts deleted file mode 100644 index c800c4ee..00000000 --- a/example-games/the-mind/scenes/penaltyAnimation.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type SpritePoint = { x: number; y: number }; - -export type PenaltyCardRef = { - playerId: 0 | 1; - card: { value: number }; -}; - -/** - * Pick visual start positions for penalty card reveal animations. - * - * Lower cards in The Mind are the leftmost cards in a sorted hand, so when - * penalty cards are removed we animate from the leftmost visible sprites of - * each player first. - */ -export function pickPenaltyStartPositions( - penaltyCards: ReadonlyArray, - humanSprites: ReadonlyArray, - aiSprites: ReadonlyArray, - fallbackByPlayer: { 0: SpritePoint; 1: SpritePoint } = { - 0: { x: 0, y: 0 }, - 1: { x: 0, y: 0 }, - }, -): SpritePoint[] { - let humanIdx = 0; - let aiIdx = 0; - - return penaltyCards.map((p) => { - if (p.playerId === 0) { - const src = humanSprites[humanIdx]; - humanIdx += 1; - return src ?? fallbackByPlayer[0]; - } - - const src = aiSprites[aiIdx]; - aiIdx += 1; - return src ?? fallbackByPlayer[1]; - }); -} diff --git a/final-CG-0MRDE9EYB0013E20.json b/final-CG-0MRDE9EYB0013E20.json new file mode 100644 index 00000000..eee6293d --- /dev/null +++ b/final-CG-0MRDE9EYB0013E20.json @@ -0,0 +1,2 @@ +{"effort": {"unit": "hours", "tshirt": "Medium", "o": 22.0, "m": 41.0, "p": 82.0, "expected": 44.67, "recommended": 55.67, "range": [33.0, 93.0]}, "risk": {"probability": 3.06, "impact": 3.06, "score": 9, "level": "Medium", "top_drivers": ["Business and Community Space family balancing", "Event balancing and Monte Carlo validation tests", "Npm script, guardrail updates, and integration"], "mitigations": ["Add targeted tests and integration checks", "Lock dependencies and add compatibility tests", "Schedule extra review for risky components"]}, "confidence_percent": 90, "assumptions": ["CSV numeric columns are sufficient inputs without additional metadata", "Larger cost spread improves strategic differentiation without breaking the economic loop", "Existing tier column (1-5) is a reasonable starting point for band-based analysis", "Incidents can be safely excluded from cost adjustment", "Zero new dependencies required (Node.js built-ins + existing tsx/vite tooling)", "Compile + run pattern (tsc --noEmit + tsx) works for the npm script"], "unknowns": ["Precise curve-fitting formula parameters (will need tuning during implementation)", "How Monte Carlo guardrails will shift with wider cost spread", "Whether tier reassignment will conflict with existing CSV tier assignments used by other systems", "Pawn Shop edge case handling (no synergy) needs manual review of output"], "input_stage": "plan_complete", "original_certainty": 80.0, "adjusted_certainty": 80.0, "update_result": {"success": true, "returncode": 0, "stdout": "{\n \"success\": true,\n \"workItem\": {\n \"id\": \"CG-0MRDE9EYB0013E20\",\n \"title\": \"CSV Auto-Balancing Tool and Card Cost/Reward Spread Pass\",\n \"description\": \"# Intake Brief: CSV Auto-Balancing Tool and Card Cost/Reward Spread Pass (CG-0MRDE9EYB0013E20)\\n\\n**Priority: High** | **Type: Feature** | **Assignee: Map**\\n\\n> Write a stand-alone CLI tool that performs an automated balancing pass on the Main Street card data CSV, focusing on cost/reward relationships (both monetary and reputation). Produce a modified CSV and a summary table of changes. Document the balancing methodology in a standalone markdown document and consolidate all existing balancing content into it. The end result should be a meaningfully larger spread of costs for cards across all families.\\n\\n## Problem Statement\\n\\nThe Main Street card pool has a narrow cost spread across all card families (businesses: 4\u201310, events: 0\u20134, upgrades: 2\u20136), with many cards clustered at the same price points. This limits strategic differentiation\u2014cheap cards feel as expensive as mid-tier options\u2014and reduces meaningful affordability tension during gameplay. Additionally, balancing documentation is scattered across multiple doc files, making it hard to reason about the overall economy holistically.\\n\\n## Users\\n\\n- **Game Designer / Balancer:** Needs a repeatable, data-driven tool to analyze and adjust card cost/reward ratios across all card families.\\n - *\\\"As a game balancer, I want to run a single CLI command that analyzes the card data CSV for balance issues and outputs a rebalanced CSV with a clear summary of adjustments made.\\\"*\\n\\n- **Engine Developer:** Needs the balancing methodology documented in one place to understand the tool's heuristics and validate its outputs.\\n - *\\\"As a developer, I want a standalone balancing methodology document that explains how cost/reward curves are computed so I can review, extend, or tune the tool.\\\"*\\n\\n- **QA / Tester:** Needs to verify that rebalanced card values produce reasonable Monte Carlo results.\\n - *\\\"As a tester, I want to run `npm run monte-carlo` after a balancing pass and see that win rates and score distributions stay within CI guardrails.\\\"*\\n\\n## Acceptance Criteria\\n\\n1. **Stand-alone CLI tool:** A script at `scripts/balance-cards.ts` reads `example-games/main-street/card-data.csv`, performs a data-driven balancing pass, and produces a modified CSV at a configurable output path. Invoked via `npm run balance-cards` (or `balance-cards` npm script).\\n\\n2. **Summary table output:** The tool prints a summary table to stdout showing:\\n - Number of cards adjusted per family\\n - Old vs. new cost range per family\\n - Old vs. new reward range per family (baseIncome, coinDelta, reputationDelta)\\n - A list of specific adjustments (card ID, field, old value, new value, rationale code)\\n\\n3. **Hybrid algorithm:** The tool uses a hybrid approach combining:\\n - **Curve-fitting:** Models expected cost as a function of card stats (baseIncome, synergy types, coinDelta, reputationDelta, income bonus, etc.) and flags/updates outliers.\\n - **Tier/band analysis:** Defines cost bands (e.g., budget, economy, standard, premium, flagship) and distributes cards to ensure each band has representation, producing a larger overall cost spread.\\n\\n4. **All card families processed:** The pass covers Business, Community Space, Event (Investment only; Incidents remain at cost 0), Upgrade, and Staff cards, each with family-appropriate heuristics.\\n\\n5. **Balancing methodology document:** A new `docs/main-street/balancing-methodology.md` is created documenting:\\n - The algorithmic approach (curve-fitting + tier bands)\\n - How each card family's cost/reward is modelled\\n - The computed spread targets\\n - All existing balancing content from `docs/main-street/content-design-and-progression.md` (Section 4, 4.1, 4.2) and `docs/main-street/the-build-gdd.md` (Section 4) is moved into this document and cross-referenced from the origin docs.\\n\\n6. **Validation:** After applying the rebalanced CSV, `npm run monte-carlo` (200 seeds) produces win rates and score distributions within the CI guardrails defined in `tests/main-street/monte-carlo-balance.test.ts`. If the wider cost spread shifts baseline metrics, the test guardrails are updated to reflect the new expected ranges and a rationale for the update is documented.\\n\\n7. **Full project test suite must pass** with the new changes.\\n\\n8. **All related documentation is updated** to reflect the changes, including code comments, README, and any relevant wiki or docs site entries.\\n\\n## Constraints\\n\\n- **CSV is source of truth:** The tool must read from and write to the same CSV format defined by `generate-card-csv.ts`. It must not change the CSV schema (column structure).\\n- **Incidents remain free:** All Incident-trigger events must retain cost=0 and not be subject to cost adjustment.\\n- **Deterministic output:** Running the tool twice on the same input must produce identical output (no random noise in the algorithm).\\n- **Existing Monte Carlo guardrails:** After applying the rebalanced CSV, the existing Monte Carlo tests in `tests/main-street/monte-carlo-balance.test.ts` must still pass at default CI seed counts (20 seeds) and at main-branch CI counts (200 seeds).\\n- **Backward-compatible CSV:** The tool must not reorder or delete rows in the CSV. Only numeric values (cost, baseIncome, coinDelta, reputationDelta, incomeBonus, ongoingCost, reputationPerTurn, reputationBonus) may change.\\n- **Licensing:** All new dependencies must be permissively licensed (MIT, Apache 2.0, CC0). Prefer zero new dependencies where possible.\\n\\n## Existing State\\n\\n- **Current cost spread (narrow):**\\n - Business: 4\u201310 (range 6), heavily clustered at 6 and 8\\n - Events (Investment): 2\u20134 (range 2); 23 of 36 events have cost 0 (Incidents)\\n - Upgrades: 2\u20136 (range 4), clustered at 3 and 4\\n - Staff: 3\u201310 (range 7)\u2014already decent spread\\n - BaseIncome for businesses/CS: mostly 0\u20131, only one at 2\\n - reputationPerTurn: only 1 card out of 20 has one (Clinic at +0.2)\\n\\n- **Existing balancing docs scattered across:**\\n - `docs/main-street/content-design-and-progression.md` \u2014 Section 4 (Difficulty and Balance), 4.1 (Provisional Numeric Balance Targets), 4.2 (Tuning Levers)\\n - `docs/main-street/the-build-gdd.md` \u2014 Section 4 (Difficulty and Balance)\\n - `docs/main-street/card-catalog.md` \u2014 Event Balance Summary table, Upgrade Cost Distribution table\\n - `tests/main-street/monte-carlo-balance.test.ts` \u2014 Monte Carlo guardrails\\n\\n- **Tooling already in place:**\\n - `scripts/generate-card-csv.ts` \u2014 Reads MainStreetCards.ts and writes card-data.csv\\n - `scripts/monte-carlo.ts` \u2014 Runs the Monte Carlo balance harness\\n - `npm run monte-carlo` \u2014 Wraps the harness with 200 seeds\\n\\n## Desired Change\\n\\n### 1. New CLI tool (`scripts/balance-cards.ts`)\\n- Stand-alone executable via `npx tsx` / npm script.\\n- Read `card-data.csv`, analyze each row's cost/reward relationship using family-specific heuristics.\\n- Apply a hybrid algorithm:\\n - **For businesses/CS:** Cost vs. (baseIncome + synergyTypes count + synergyCoinBonus + synergyRepBonus + reputationPerTurn + incomeBonus potential) curve. Assign tier bands.\\n - **For Investment events:** Cost vs. (coinDelta + reputationDelta + target scope) curve.\\n - **For upgrades:** Cost vs. (incomeBonus + synergyRangeBonus + requiredLevel + reputationBonus) curve.\\n- Write adjusted CSV to a configurable output path (default: overwrite input).\\n- Print a structured summary table with old/new ranges and per-card diffs.\\n\\n### 2. Balancing methodology document (`docs/main-street/balancing-methodology.md`)\\n- Algorithm description and rationale for each family.\\n- Tier band definitions.\\n- Curve fitting formulas.\\n- Migration of existing balancing content from:\\n - `docs/main-street/content-design-and-progression.md` Sections 4, 4.1, 4.2 \u2192 replace with cross-reference \\\"See [balancing-methodology.md](balancing-methodology.md)\\\"\\n - `docs/main-street/the-build-gdd.md` Section 4 \u2192 replace with cross-reference\\n\\n### 3. npm script\\n- Add `\\\"balance-cards\\\": \\\"npx tsx scripts/balance-cards.ts\\\"` to `package.json`\\n\\n## Key Files\\n\\n- `scripts/balance-cards.ts` \u2014 New file. The stand-alone balancing tool.\\n- `example-games/main-street/card-data.csv` \u2014 Input and default output path for the tool.\\n- `docs/main-street/balancing-methodology.md` \u2014 New file. Balancing methodology document.\\n- `docs/main-street/content-design-and-progression.md` \u2014 Edit to remove Sections 4, 4.1, 4.2 and replace with cross-reference.\\n- `docs/main-street/the-build-gdd.md` \u2014 Edit to remove Section 4 and replace with cross-reference.\\n- `docs/main-street/card-catalog.md` \u2014 May need updates if cost/reward spreadsheet data changes.\\n- `package.json` \u2014 Add `balance-cards` npm script.\\n- `tests/main-street/monte-carlo-balance.test.ts` \u2014 Update guardrails if cost spread changes affect baseline metrics.\\n\\n## Related Work\\n\\n- **Move tier definitions into the CSV (CG-0MR91VLA6009V1XG)** [completed]: Moved tier data into the CSV, which the balancing tool will read for tier-based heuristics.\\n- **Main Street: Content Design and Progression (CG-0MM4RCE861AQ7PGW)** [completed]: Contains the Difficulty and Balance sections whose content will be migrated into `balancing-methodology.md`.\\n- **The Build GDD (CG-0MM4RDIMT1HLP2DE)** [completed]: Contains Section 4 (Difficulty and Balance) whose content will be migrated.\\n- **Main Street Monte Carlo balance heuristics test** (`tests/main-street/monte-carlo-balance.test.ts`): The validation gate for rebalanced card values.\\n- **Main Street Monte Carlo harness** (`scripts/monte-carlo.ts`): Running the harness validates that rebalanced values produce acceptable win rates.\\n- **Main Street Card Catalog** (`docs/main-street/card-catalog.md`): Contains Event Balance Summary and Upgrade Cost Distribution tables whose data will shift after rebalancing.\\n- **CSV generation script** (`scripts/generate-card-csv.ts`): The reference CSV format implementation the tool must match.\\n\\n## Risks & Assumptions\\n\\n### Risks\\n- **Scope creep:** The tool touches all 5 card families with different balancing heuristics. Mitigation: record additional balance improvement ideas (multi-synergy costing, metaprogression unlock pricing) as separate work items rather than expanding scope.\\n- **Monte Carlo breakage:** Aggressive cost rebalancing could push win rates outside CI guardrails (20\u201380% at 20 seeds). Mitigation: the tool must be run with Monte Carlo validation; if CI fails, adjust algorithm parameters (band sizes, curve shape) until guardrails are satisfied.\\n- **CSV column dependency:** The tool relies on all CSV columns having correct numeric values. Missing or malformed data could produce nonsensical outputs. Mitigation: add input validation with clear error messages.\\n- **Algorithmic bias:** A purely automated balancing pass could produce mathematically \\\"optimal\\\" but thematically nonsensical card costs (e.g., making a Bakery more expensive than a Cinema). Mitigation: the summary table should highlight changes for human review; the tool outputs a \\\"rationale code\\\" per adjustment.\\n- **Stale baseline guardrails:** If the Monte Carlo CI tolerances were set for the current narrow cost spread, a wider spread may naturally change median scores and win rates. Mitigation: the tool should document expected new guardrail ranges in the methodology doc; the developer may need to update the test guardrails.\\n\\n### Assumptions\\n- The CSV's existing numeric columns (cost, baseIncome, coinDelta, reputationDelta, etc.) are sufficient inputs for the balancing algorithm without requiring additional metadata.\\n- A larger cost spread will improve strategic differentiation without breaking the economic loop (validated by Monte Carlo).\\n- The `tier` column in the CSV (values 1\u20135) is a reasonable starting point for tier-based cost banding.\\n- Incident events (cost=0, trigger=Incident) can be safely excluded from cost adjustment without unbalancing the economy.\\n- The tool does not need to handle custom edge cases (e.g., Pawn Shop's \\\"no synergy\\\" rule) beyond what the CSV already encodes (empty synergyCoinBonus/synergyRepBonus).\\n\\n## Appendix: Clarifying Questions & Answers\\n\\n1. **Q: Tool output format \u2014 Should the tool produce a modified CSV or just a report?**\\n - A (user): \\\"(a) a modified CSV with adjusted cost/reward values, plus (b) a summary table of changes made.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n2. **Q: \\\"Larger spread\\\" quantitative target \u2014 Do you have target ranges in mind or should the tool compute optimal spread?**\\n - A (user): \\\"tool should optimize.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n3. **Q: Scope of balancing \u2014 Should the tool also adjust baseIncome, reputationPerTurn, event deltas, upgrade tradeoffs?**\\n - A (user): \\\"yes, all of the suggestions.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n4. **Q: CLI entry point \u2014 Do you want an npm script for the tool?**\\n - A (user): \\\"yes.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n5. **Q: Incident events \u2014 Should they remain at cost 0 or also get suggestions?**\\n - A (user): \\\"keep incidents as free.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n6. **Q: Algorithm approach \u2014 Curve-fitting, tier/band, or hybrid?**\\n - A (user): \\\"(c) hybrid.\\\" \u2014 Source: interactive reply, 2026-07-09. Final: yes.\\n\\n## Related work (automated report)\\n\",\n \"status\": \"in-progress\",\n \"priority\": \"high\",\n \"sortIndex\": 100,\n \"parentId\": null,\n \"createdAt\": \"2026-07-09T10:59:07.380Z\",\n \"updatedAt\": \"2026-07-09T15:44:36.693Z\",\n \"tags\": [],\n \"assignee\": \"Map\",\n \"stage\": \"plan_complete\",\n \"issueType\": \"feature\",\n \"createdBy\": \"\",\n \"deletedBy\": \"\",\n \"deleteReason\": \"\",\n \"risk\": \"Medium\",\n \"effort\": \"Medium\",\n \"needsProducerReview\": false\n }\n}\n", "stderr": "[runtime] Received beforeExit; awaiting 0 pending task(s)...\n[runtime] All tasks complete.\n"}, "human_text": "# Effort and Risk Report\n\n## Effort Estimate\n\n- **T-shirt size:** Medium\n- **Three-point (PERT):** O=22.00h, M=41.00h, P=82.00h\n- **Expected (E=(O+4M+P)/6):** 44.67h\n- **Recommended (with overheads):** 55.67h\n- **Range:** [33.00h \u2014 93.00h]\n- **Unit:** hours\n\n### Work Breakdown Structure (WBS)\n\n| # | Item | O (h) | M (h) | P (h) | Expected (h) |\n|---|------|-------|-------|-------|-------------|\n| 1 | Core balancing algorithm unit tests | 2.00 | 4.00 | 8.00 | 4.33 |\n| 2 | CLI framework with CSV I/O and backup management | 3.00 | 5.00 | 10.00 | 5.50 |\n| 3 | Business and Community Space family balancing | 4.00 | 8.00 | 16.00 | 8.67 |\n| 4 | Upgrade and staff balancing algorithm tests | 1.00 | 2.00 | 4.00 | 2.17 |\n| 5 | Upgrade and staff family balancing | 3.00 | 6.00 | 12.00 | 6.50 |\n| 6 | Event balancing and Monte Carlo validation tests | 2.00 | 4.00 | 8.00 | 4.33 |\n| 7 | Event family balancing | 2.00 | 3.00 | 6.00 | 3.33 |\n| 8 | Balancing methodology document and migration | 3.00 | 5.00 | 10.00 | 5.50 |\n| 9 | Npm script, guardrail updates, and integration | 2.00 | 4.00 | 8.00 | 4.33 |\n\n\n## Risk Assessment\n\n- **Risk Score:** 9/25 \u2014 **Medium**\n- **Probability:** 3.06/5 | **Impact:** 3.06/5\n\n### Top Risk Drivers & Mitigations\n\n1. **Business and Community Space family balancing** \u2014 Add targeted tests and integration checks\n2. **Event balancing and Monte Carlo validation tests** \u2014 Lock dependencies and add compatibility tests\n3. **Npm script, guardrail updates, and integration** \u2014 Schedule extra review for risky components\n\n\n## Confidence\n\n- **Confidence:** 90%\n- **Unknowns:** Precise curve-fitting formula parameters (will need tuning during implementation); How Monte Carlo guardrails will shift with wider cost spread; Whether tier reassignment will conflict with existing CSV tier assignments used by other systems; Pawn Shop edge case handling (no synergy) needs manual review of output\n- **Assumptions:** CSV numeric columns are sufficient inputs without additional metadata; Larger cost spread improves strategic differentiation without breaking the economic loop; Existing tier column (1-5) is a reasonable starting point for band-based analysis; Incidents can be safely excluded from cost adjustment; Zero new dependencies required (Node.js built-ins + existing tsx/vite tooling); Compile + run pattern (tsc --noEmit + tsx) works for the npm script\n", "human_render_rc": 0, "human_render_stderr": "", "comment_result": {"returncode": 0, "stdout": "{\n \"success\": true,\n \"comment\": {\n \"id\": \"CG-C0MRDOGXZD009XWB0\",\n \"workItemId\": \"CG-0MRDE9EYB0013E20\",\n \"author\": \"effort_and_risk_skill\",\n \"comment\": \"# Effort and Risk Report\\n\\n## Effort Estimate\\n\\n- **T-shirt size:** Medium\\n- **Three-point (PERT):** O=22.00h, M=41.00h, P=82.00h\\n- **Expected (E=(O+4M+P)/6):** 44.67h\\n- **Recommended (with overheads):** 55.67h\\n- **Range:** [33.00h \u2014 93.00h]\\n- **Unit:** hours\\n\\n### Work Breakdown Structure (WBS)\\n\\n| # | Item | O (h) | M (h) | P (h) | Expected (h) |\\n|---|------|-------|-------|-------|-------------|\\n| 1 | Core balancing algorithm unit tests | 2.00 | 4.00 | 8.00 | 4.33 |\\n| 2 | CLI framework with CSV I/O and backup management | 3.00 | 5.00 | 10.00 | 5.50 |\\n| 3 | Business and Community Space family balancing | 4.00 | 8.00 | 16.00 | 8.67 |\\n| 4 | Upgrade and staff balancing algorithm tests | 1.00 | 2.00 | 4.00 | 2.17 |\\n| 5 | Upgrade and staff family balancing | 3.00 | 6.00 | 12.00 | 6.50 |\\n| 6 | Event balancing and Monte Carlo validation tests | 2.00 | 4.00 | 8.00 | 4.33 |\\n| 7 | Event family balancing | 2.00 | 3.00 | 6.00 | 3.33 |\\n| 8 | Balancing methodology document and migration | 3.00 | 5.00 | 10.00 | 5.50 |\\n| 9 | Npm script, guardrail updates, and integration | 2.00 | 4.00 | 8.00 | 4.33 |\\n\\n\\n## Risk Assessment\\n\\n- **Risk Score:** 9/25 \u2014 **Medium**\\n- **Probability:** 3.06/5 | **Impact:** 3.06/5\\n\\n### Top Risk Drivers & Mitigations\\n\\n1. **Business and Community Space family balancing** \u2014 Add targeted tests and integration checks\\n2. **Event balancing and Monte Carlo validation tests** \u2014 Lock dependencies and add compatibility tests\\n3. **Npm script, guardrail updates, and integration** \u2014 Schedule extra review for risky components\\n\\n\\n## Confidence\\n\\n- **Confidence:** 90%\\n- **Unknowns:** Precise curve-fitting formula parameters (will need tuning during implementation); How Monte Carlo guardrails will shift with wider cost spread; Whether tier reassignment will conflict with existing CSV tier assignments used by other systems; Pawn Shop edge case handling (no synergy) needs manual review of output\\n- **Assumptions:** CSV numeric columns are sufficient inputs without additional metadata; Larger cost spread improves strategic differentiation without breaking the economic loop; Existing tier column (1-5) is a reasonable starting point for band-based analysis; Incidents can be safely excluded from cost adjustment; Zero new dependencies required (Node.js built-ins + existing tsx/vite tooling); Compile + run pattern (tsc --noEmit + tsx) works for the npm script\\n\\n\\n```json\\n{\\n \\\"effort\\\": {\\n \\\"unit\\\": \\\"hours\\\",\\n \\\"tshirt\\\": \\\"Medium\\\",\\n \\\"o\\\": 22.0,\\n \\\"m\\\": 41.0,\\n \\\"p\\\": 82.0,\\n \\\"expected\\\": 44.67,\\n \\\"recommended\\\": 55.67,\\n \\\"range\\\": [\\n 33.0,\\n 93.0\\n ]\\n },\\n \\\"risk\\\": {\\n \\\"probability\\\": 3.06,\\n \\\"impact\\\": 3.06,\\n \\\"score\\\": 9,\\n \\\"level\\\": \\\"Medium\\\",\\n \\\"top_drivers\\\": [\\n \\\"Business and Community Space family balancing\\\",\\n \\\"Event balancing and Monte Carlo validation tests\\\",\\n \\\"Npm script, guardrail updates, and integration\\\"\\n ],\\n \\\"mitigations\\\": [\\n \\\"Add targeted tests and integration checks\\\",\\n \\\"Lock dependencies and add compatibility tests\\\",\\n \\\"Schedule extra review for risky components\\\"\\n ]\\n },\\n \\\"confidence_percent\\\": 90,\\n \\\"assumptions\\\": [\\n \\\"CSV numeric columns are sufficient inputs without additional metadata\\\",\\n \\\"Larger cost spread improves strategic differentiation without breaking the economic loop\\\",\\n \\\"Existing tier column (1-5) is a reasonable starting point for band-based analysis\\\",\\n \\\"Incidents can be safely excluded from cost adjustment\\\",\\n \\\"Zero new dependencies required (Node.js built-ins + existing tsx/vite tooling)\\\",\\n \\\"Compile + run pattern (tsc --noEmit + tsx) works for the npm script\\\"\\n ],\\n \\\"unknowns\\\": [\\n \\\"Precise curve-fitting formula parameters (will need tuning during implementation)\\\",\\n \\\"How Monte Carlo guardrails will shift with wider cost spread\\\",\\n \\\"Whether tier reassignment will conflict with existing CSV tier assignments used by other systems\\\",\\n \\\"Pawn Shop edge case handling (no synergy) needs manual review of output\\\"\\n ]\\n}\\n```\",\n \"createdAt\": \"2026-07-09T15:44:54.793Z\",\n \"references\": []\n }\n}\n", "stderr": "[runtime] Received beforeExit; awaiting 0 pending task(s)...\n[runtime] All tasks complete.\n", "success": true}} + diff --git a/main.ts b/main.ts index 4559ed90..d71e6e46 100644 --- a/main.ts +++ b/main.ts @@ -18,7 +18,6 @@ import { BeleagueredCastleScene } from './example-games/beleaguered-castle/scene import { SushiGoScene } from './example-games/sushi-go/scenes/SushiGoScene'; import { FeudalismScene } from './example-games/feudalism/scenes/FeudalismScene'; import { LostCitiesScene } from './example-games/lost-cities/scenes/LostCitiesScene'; -import { TheMindScene } from './example-games/the-mind/scenes/TheMindScene'; import { MainStreetScene } from './example-games/main-street/scenes/MainStreetScene'; import { GymRouterScene, @@ -34,6 +33,7 @@ import { GymSllScene, GymTooltipScene, GymHudComponentsScene, + GymLayoutOwnershipScene, } from './example-games/gym'; // ── Game catalogue ───────────────────────────────────────── @@ -80,13 +80,6 @@ export const GAMES: GameEntry[] = [ 'Two-player expedition card game (human vs. AI). Bet on up to 5 expeditions across a 3-round match, manage risk with investment cards, and outscore the AI.', thumbnail: 'games/lost-cities/thumbnail', }, - { - sceneKey: 'TheMindScene', - title: 'The Mind', - description: - 'Cooperative real-time card game (human + AI). Play numbered cards 1-100 onto a shared ascending pile without communicating. Survive 8 levels without losing all lives.', - thumbnail: 'games/the-mind/thumbnail', - }, { sceneKey: 'MainStreetScene', title: 'Main Street', @@ -114,7 +107,6 @@ createCardGame({ SushiGoScene, FeudalismScene, LostCitiesScene, - TheMindScene, MainStreetScene, // Gym demo scenes GymRouterScene, @@ -130,6 +122,7 @@ createCardGame({ GymSllScene, GymTooltipScene, GymHudComponentsScene, + GymLayoutOwnershipScene, ], render: isReplayMode ? { preserveDrawingBuffer: true } : undefined, callbacks: { diff --git a/package-lock.json b/package-lock.json index f53fc8ce..be2a8d7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tableau-card-engine", - "version": "0.1.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tableau-card-engine", - "version": "0.1.0", + "version": "0.1.3", "license": "MIT", "dependencies": { "phaser": "4.0.0-rc.7" diff --git a/package.json b/package.json index 0bd8444a..95514a1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.3", + "version": "0.1.4", "description": "Tableau Card Engine (TCE) -- a modular game engine for building single-player tableau card games using Phaser 4 RC and TypeScript", "private": true, "type": "module", @@ -8,6 +8,7 @@ "dev": "vite --host", "build": "tsc --noEmit && vite build", "preview": "vite preview", + "balance-cards": "tsc --noEmit && tsx scripts/run-balance-cards.ts", "test": "vitest run --project unit && vitest run --project browser && bash scripts/run-tutorial-tests.sh", "monte-carlo": "tsx scripts/monte-carlo.ts --seeds 200 --seed-prefix mc-balance --maxTurns 25 --strategy greedy --out results/main-street-monte-carlo.json --csv-out results/main-street-monte-carlo.csv", "replay": "tsx scripts/replay.ts", diff --git a/public/assets/audio/the-mind/card-play.wav b/public/assets/audio/the-mind/card-play.wav deleted file mode 100644 index b04703f2..00000000 Binary files a/public/assets/audio/the-mind/card-play.wav and /dev/null differ diff --git a/public/assets/audio/the-mind/game-lost.wav b/public/assets/audio/the-mind/game-lost.wav deleted file mode 100644 index 83a46e9c..00000000 Binary files a/public/assets/audio/the-mind/game-lost.wav and /dev/null differ diff --git a/public/assets/audio/the-mind/game-win.wav b/public/assets/audio/the-mind/game-win.wav deleted file mode 100644 index c85cb6ef..00000000 Binary files a/public/assets/audio/the-mind/game-win.wav and /dev/null differ diff --git a/public/assets/audio/the-mind/level-complete.wav b/public/assets/audio/the-mind/level-complete.wav deleted file mode 100644 index 45b05e5d..00000000 Binary files a/public/assets/audio/the-mind/level-complete.wav and /dev/null differ diff --git a/public/assets/audio/the-mind/life-lost.wav b/public/assets/audio/the-mind/life-lost.wav deleted file mode 100644 index d1afc913..00000000 Binary files a/public/assets/audio/the-mind/life-lost.wav and /dev/null differ diff --git a/public/assets/audio/the-mind/ui-click.wav b/public/assets/audio/the-mind/ui-click.wav deleted file mode 100644 index 0f436a3d..00000000 Binary files a/public/assets/audio/the-mind/ui-click.wav and /dev/null differ diff --git a/public/assets/cards/the-mind/mind-1.svg b/public/assets/cards/the-mind/mind-1.svg deleted file mode 100644 index 60988425..00000000 --- a/public/assets/cards/the-mind/mind-1.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 1 - 1 - 1 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-10.svg b/public/assets/cards/the-mind/mind-10.svg deleted file mode 100644 index 1dd5c285..00000000 --- a/public/assets/cards/the-mind/mind-10.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 10 - 10 - 10 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-100.svg b/public/assets/cards/the-mind/mind-100.svg deleted file mode 100644 index 5a902b29..00000000 --- a/public/assets/cards/the-mind/mind-100.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 100 - 100 - 100 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-11.svg b/public/assets/cards/the-mind/mind-11.svg deleted file mode 100644 index 8196d6d2..00000000 --- a/public/assets/cards/the-mind/mind-11.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 11 - 11 - 11 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-12.svg b/public/assets/cards/the-mind/mind-12.svg deleted file mode 100644 index 2f06d3ae..00000000 --- a/public/assets/cards/the-mind/mind-12.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 12 - 12 - 12 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-13.svg b/public/assets/cards/the-mind/mind-13.svg deleted file mode 100644 index 1ac6d64e..00000000 --- a/public/assets/cards/the-mind/mind-13.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 13 - 13 - 13 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-14.svg b/public/assets/cards/the-mind/mind-14.svg deleted file mode 100644 index e2bbb533..00000000 --- a/public/assets/cards/the-mind/mind-14.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 14 - 14 - 14 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-15.svg b/public/assets/cards/the-mind/mind-15.svg deleted file mode 100644 index a6949ac1..00000000 --- a/public/assets/cards/the-mind/mind-15.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 15 - 15 - 15 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-16.svg b/public/assets/cards/the-mind/mind-16.svg deleted file mode 100644 index a52cb352..00000000 --- a/public/assets/cards/the-mind/mind-16.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 16 - 16 - 16 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-17.svg b/public/assets/cards/the-mind/mind-17.svg deleted file mode 100644 index c3a411bb..00000000 --- a/public/assets/cards/the-mind/mind-17.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 17 - 17 - 17 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-18.svg b/public/assets/cards/the-mind/mind-18.svg deleted file mode 100644 index 643f052a..00000000 --- a/public/assets/cards/the-mind/mind-18.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 18 - 18 - 18 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-19.svg b/public/assets/cards/the-mind/mind-19.svg deleted file mode 100644 index c9e42592..00000000 --- a/public/assets/cards/the-mind/mind-19.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 19 - 19 - 19 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-2.svg b/public/assets/cards/the-mind/mind-2.svg deleted file mode 100644 index cc8fd9c7..00000000 --- a/public/assets/cards/the-mind/mind-2.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 2 - 2 - 2 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-20.svg b/public/assets/cards/the-mind/mind-20.svg deleted file mode 100644 index 4b077742..00000000 --- a/public/assets/cards/the-mind/mind-20.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 20 - 20 - 20 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-21.svg b/public/assets/cards/the-mind/mind-21.svg deleted file mode 100644 index cc2c5b13..00000000 --- a/public/assets/cards/the-mind/mind-21.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 21 - 21 - 21 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-22.svg b/public/assets/cards/the-mind/mind-22.svg deleted file mode 100644 index c4e87398..00000000 --- a/public/assets/cards/the-mind/mind-22.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 22 - 22 - 22 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-23.svg b/public/assets/cards/the-mind/mind-23.svg deleted file mode 100644 index dbfbd4c0..00000000 --- a/public/assets/cards/the-mind/mind-23.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 23 - 23 - 23 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-24.svg b/public/assets/cards/the-mind/mind-24.svg deleted file mode 100644 index 392a72f3..00000000 --- a/public/assets/cards/the-mind/mind-24.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 24 - 24 - 24 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-25.svg b/public/assets/cards/the-mind/mind-25.svg deleted file mode 100644 index ba0c8b01..00000000 --- a/public/assets/cards/the-mind/mind-25.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 25 - 25 - 25 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-26.svg b/public/assets/cards/the-mind/mind-26.svg deleted file mode 100644 index 4a08db03..00000000 --- a/public/assets/cards/the-mind/mind-26.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 26 - 26 - 26 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-27.svg b/public/assets/cards/the-mind/mind-27.svg deleted file mode 100644 index 3fd40993..00000000 --- a/public/assets/cards/the-mind/mind-27.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 27 - 27 - 27 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-28.svg b/public/assets/cards/the-mind/mind-28.svg deleted file mode 100644 index 2699f5bc..00000000 --- a/public/assets/cards/the-mind/mind-28.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 28 - 28 - 28 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-29.svg b/public/assets/cards/the-mind/mind-29.svg deleted file mode 100644 index 1fee78d2..00000000 --- a/public/assets/cards/the-mind/mind-29.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 29 - 29 - 29 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-3.svg b/public/assets/cards/the-mind/mind-3.svg deleted file mode 100644 index 79331ecb..00000000 --- a/public/assets/cards/the-mind/mind-3.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 3 - 3 - 3 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-30.svg b/public/assets/cards/the-mind/mind-30.svg deleted file mode 100644 index 6a706820..00000000 --- a/public/assets/cards/the-mind/mind-30.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 30 - 30 - 30 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-31.svg b/public/assets/cards/the-mind/mind-31.svg deleted file mode 100644 index 5f921a28..00000000 --- a/public/assets/cards/the-mind/mind-31.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 31 - 31 - 31 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-32.svg b/public/assets/cards/the-mind/mind-32.svg deleted file mode 100644 index 2f9d5a9f..00000000 --- a/public/assets/cards/the-mind/mind-32.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 32 - 32 - 32 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-33.svg b/public/assets/cards/the-mind/mind-33.svg deleted file mode 100644 index f20c7062..00000000 --- a/public/assets/cards/the-mind/mind-33.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 33 - 33 - 33 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-34.svg b/public/assets/cards/the-mind/mind-34.svg deleted file mode 100644 index 1a941b5a..00000000 --- a/public/assets/cards/the-mind/mind-34.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 34 - 34 - 34 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-35.svg b/public/assets/cards/the-mind/mind-35.svg deleted file mode 100644 index 0eb77535..00000000 --- a/public/assets/cards/the-mind/mind-35.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 35 - 35 - 35 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-36.svg b/public/assets/cards/the-mind/mind-36.svg deleted file mode 100644 index f57a021f..00000000 --- a/public/assets/cards/the-mind/mind-36.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 36 - 36 - 36 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-37.svg b/public/assets/cards/the-mind/mind-37.svg deleted file mode 100644 index 4c2d3466..00000000 --- a/public/assets/cards/the-mind/mind-37.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 37 - 37 - 37 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-38.svg b/public/assets/cards/the-mind/mind-38.svg deleted file mode 100644 index 25ec9670..00000000 --- a/public/assets/cards/the-mind/mind-38.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 38 - 38 - 38 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-39.svg b/public/assets/cards/the-mind/mind-39.svg deleted file mode 100644 index 78f2403d..00000000 --- a/public/assets/cards/the-mind/mind-39.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 39 - 39 - 39 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-4.svg b/public/assets/cards/the-mind/mind-4.svg deleted file mode 100644 index a691b55f..00000000 --- a/public/assets/cards/the-mind/mind-4.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 4 - 4 - 4 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-40.svg b/public/assets/cards/the-mind/mind-40.svg deleted file mode 100644 index ac0e8113..00000000 --- a/public/assets/cards/the-mind/mind-40.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 40 - 40 - 40 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-41.svg b/public/assets/cards/the-mind/mind-41.svg deleted file mode 100644 index 262b6295..00000000 --- a/public/assets/cards/the-mind/mind-41.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 41 - 41 - 41 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-42.svg b/public/assets/cards/the-mind/mind-42.svg deleted file mode 100644 index fa0589dc..00000000 --- a/public/assets/cards/the-mind/mind-42.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 42 - 42 - 42 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-43.svg b/public/assets/cards/the-mind/mind-43.svg deleted file mode 100644 index 0043a80d..00000000 --- a/public/assets/cards/the-mind/mind-43.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 43 - 43 - 43 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-44.svg b/public/assets/cards/the-mind/mind-44.svg deleted file mode 100644 index 2e02ebb2..00000000 --- a/public/assets/cards/the-mind/mind-44.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 44 - 44 - 44 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-45.svg b/public/assets/cards/the-mind/mind-45.svg deleted file mode 100644 index 851919be..00000000 --- a/public/assets/cards/the-mind/mind-45.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 45 - 45 - 45 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-46.svg b/public/assets/cards/the-mind/mind-46.svg deleted file mode 100644 index 9e698f33..00000000 --- a/public/assets/cards/the-mind/mind-46.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 46 - 46 - 46 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-47.svg b/public/assets/cards/the-mind/mind-47.svg deleted file mode 100644 index b3424a51..00000000 --- a/public/assets/cards/the-mind/mind-47.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 47 - 47 - 47 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-48.svg b/public/assets/cards/the-mind/mind-48.svg deleted file mode 100644 index b8999f35..00000000 --- a/public/assets/cards/the-mind/mind-48.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 48 - 48 - 48 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-49.svg b/public/assets/cards/the-mind/mind-49.svg deleted file mode 100644 index 01583af6..00000000 --- a/public/assets/cards/the-mind/mind-49.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 49 - 49 - 49 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-5.svg b/public/assets/cards/the-mind/mind-5.svg deleted file mode 100644 index 62c1c48e..00000000 --- a/public/assets/cards/the-mind/mind-5.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 5 - 5 - 5 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-50.svg b/public/assets/cards/the-mind/mind-50.svg deleted file mode 100644 index a34ee033..00000000 --- a/public/assets/cards/the-mind/mind-50.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 50 - 50 - 50 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-51.svg b/public/assets/cards/the-mind/mind-51.svg deleted file mode 100644 index 45e88189..00000000 --- a/public/assets/cards/the-mind/mind-51.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 51 - 51 - 51 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-52.svg b/public/assets/cards/the-mind/mind-52.svg deleted file mode 100644 index 250b25fa..00000000 --- a/public/assets/cards/the-mind/mind-52.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 52 - 52 - 52 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-53.svg b/public/assets/cards/the-mind/mind-53.svg deleted file mode 100644 index 7a716c08..00000000 --- a/public/assets/cards/the-mind/mind-53.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 53 - 53 - 53 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-54.svg b/public/assets/cards/the-mind/mind-54.svg deleted file mode 100644 index 4a2a59de..00000000 --- a/public/assets/cards/the-mind/mind-54.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 54 - 54 - 54 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-55.svg b/public/assets/cards/the-mind/mind-55.svg deleted file mode 100644 index 3bd12ad3..00000000 --- a/public/assets/cards/the-mind/mind-55.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 55 - 55 - 55 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-56.svg b/public/assets/cards/the-mind/mind-56.svg deleted file mode 100644 index 194d617a..00000000 --- a/public/assets/cards/the-mind/mind-56.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 56 - 56 - 56 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-57.svg b/public/assets/cards/the-mind/mind-57.svg deleted file mode 100644 index 5f0d45f2..00000000 --- a/public/assets/cards/the-mind/mind-57.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 57 - 57 - 57 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-58.svg b/public/assets/cards/the-mind/mind-58.svg deleted file mode 100644 index 3d4e31ce..00000000 --- a/public/assets/cards/the-mind/mind-58.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 58 - 58 - 58 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-59.svg b/public/assets/cards/the-mind/mind-59.svg deleted file mode 100644 index e7bc0d2c..00000000 --- a/public/assets/cards/the-mind/mind-59.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 59 - 59 - 59 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-6.svg b/public/assets/cards/the-mind/mind-6.svg deleted file mode 100644 index 9fa79031..00000000 --- a/public/assets/cards/the-mind/mind-6.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 6 - 6 - 6 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-60.svg b/public/assets/cards/the-mind/mind-60.svg deleted file mode 100644 index f7cb1bae..00000000 --- a/public/assets/cards/the-mind/mind-60.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 60 - 60 - 60 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-61.svg b/public/assets/cards/the-mind/mind-61.svg deleted file mode 100644 index d54157fa..00000000 --- a/public/assets/cards/the-mind/mind-61.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 61 - 61 - 61 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-62.svg b/public/assets/cards/the-mind/mind-62.svg deleted file mode 100644 index c8005c74..00000000 --- a/public/assets/cards/the-mind/mind-62.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 62 - 62 - 62 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-63.svg b/public/assets/cards/the-mind/mind-63.svg deleted file mode 100644 index 2553c6b6..00000000 --- a/public/assets/cards/the-mind/mind-63.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 63 - 63 - 63 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-64.svg b/public/assets/cards/the-mind/mind-64.svg deleted file mode 100644 index 6ec64cba..00000000 --- a/public/assets/cards/the-mind/mind-64.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 64 - 64 - 64 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-65.svg b/public/assets/cards/the-mind/mind-65.svg deleted file mode 100644 index 352e57b4..00000000 --- a/public/assets/cards/the-mind/mind-65.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 65 - 65 - 65 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-66.svg b/public/assets/cards/the-mind/mind-66.svg deleted file mode 100644 index 43502733..00000000 --- a/public/assets/cards/the-mind/mind-66.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 66 - 66 - 66 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-67.svg b/public/assets/cards/the-mind/mind-67.svg deleted file mode 100644 index 30a406a3..00000000 --- a/public/assets/cards/the-mind/mind-67.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 67 - 67 - 67 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-68.svg b/public/assets/cards/the-mind/mind-68.svg deleted file mode 100644 index 11edd9ba..00000000 --- a/public/assets/cards/the-mind/mind-68.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 68 - 68 - 68 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-69.svg b/public/assets/cards/the-mind/mind-69.svg deleted file mode 100644 index 34d480cd..00000000 --- a/public/assets/cards/the-mind/mind-69.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 69 - 69 - 69 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-7.svg b/public/assets/cards/the-mind/mind-7.svg deleted file mode 100644 index c35c9982..00000000 --- a/public/assets/cards/the-mind/mind-7.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 7 - 7 - 7 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-70.svg b/public/assets/cards/the-mind/mind-70.svg deleted file mode 100644 index 7019af26..00000000 --- a/public/assets/cards/the-mind/mind-70.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 70 - 70 - 70 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-71.svg b/public/assets/cards/the-mind/mind-71.svg deleted file mode 100644 index d744e708..00000000 --- a/public/assets/cards/the-mind/mind-71.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 71 - 71 - 71 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-72.svg b/public/assets/cards/the-mind/mind-72.svg deleted file mode 100644 index c1ff3fcd..00000000 --- a/public/assets/cards/the-mind/mind-72.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 72 - 72 - 72 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-73.svg b/public/assets/cards/the-mind/mind-73.svg deleted file mode 100644 index d431b18b..00000000 --- a/public/assets/cards/the-mind/mind-73.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 73 - 73 - 73 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-74.svg b/public/assets/cards/the-mind/mind-74.svg deleted file mode 100644 index 186eb2da..00000000 --- a/public/assets/cards/the-mind/mind-74.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 74 - 74 - 74 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-75.svg b/public/assets/cards/the-mind/mind-75.svg deleted file mode 100644 index 6b2ea1ad..00000000 --- a/public/assets/cards/the-mind/mind-75.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 75 - 75 - 75 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-76.svg b/public/assets/cards/the-mind/mind-76.svg deleted file mode 100644 index 359d1fba..00000000 --- a/public/assets/cards/the-mind/mind-76.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 76 - 76 - 76 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-77.svg b/public/assets/cards/the-mind/mind-77.svg deleted file mode 100644 index e7417caf..00000000 --- a/public/assets/cards/the-mind/mind-77.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 77 - 77 - 77 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-78.svg b/public/assets/cards/the-mind/mind-78.svg deleted file mode 100644 index 81d65d82..00000000 --- a/public/assets/cards/the-mind/mind-78.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 78 - 78 - 78 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-79.svg b/public/assets/cards/the-mind/mind-79.svg deleted file mode 100644 index 87f31efb..00000000 --- a/public/assets/cards/the-mind/mind-79.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 79 - 79 - 79 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-8.svg b/public/assets/cards/the-mind/mind-8.svg deleted file mode 100644 index d58315e5..00000000 --- a/public/assets/cards/the-mind/mind-8.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 8 - 8 - 8 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-80.svg b/public/assets/cards/the-mind/mind-80.svg deleted file mode 100644 index e36a9daa..00000000 --- a/public/assets/cards/the-mind/mind-80.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 80 - 80 - 80 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-81.svg b/public/assets/cards/the-mind/mind-81.svg deleted file mode 100644 index 5bac7960..00000000 --- a/public/assets/cards/the-mind/mind-81.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 81 - 81 - 81 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-82.svg b/public/assets/cards/the-mind/mind-82.svg deleted file mode 100644 index 3cc9b288..00000000 --- a/public/assets/cards/the-mind/mind-82.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 82 - 82 - 82 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-83.svg b/public/assets/cards/the-mind/mind-83.svg deleted file mode 100644 index 51ade991..00000000 --- a/public/assets/cards/the-mind/mind-83.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 83 - 83 - 83 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-84.svg b/public/assets/cards/the-mind/mind-84.svg deleted file mode 100644 index 287b113d..00000000 --- a/public/assets/cards/the-mind/mind-84.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 84 - 84 - 84 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-85.svg b/public/assets/cards/the-mind/mind-85.svg deleted file mode 100644 index c7f0ca62..00000000 --- a/public/assets/cards/the-mind/mind-85.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 85 - 85 - 85 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-86.svg b/public/assets/cards/the-mind/mind-86.svg deleted file mode 100644 index 0f51b874..00000000 --- a/public/assets/cards/the-mind/mind-86.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 86 - 86 - 86 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-87.svg b/public/assets/cards/the-mind/mind-87.svg deleted file mode 100644 index 9b572095..00000000 --- a/public/assets/cards/the-mind/mind-87.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 87 - 87 - 87 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-88.svg b/public/assets/cards/the-mind/mind-88.svg deleted file mode 100644 index be9ccee5..00000000 --- a/public/assets/cards/the-mind/mind-88.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 88 - 88 - 88 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-89.svg b/public/assets/cards/the-mind/mind-89.svg deleted file mode 100644 index c97f5fdf..00000000 --- a/public/assets/cards/the-mind/mind-89.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 89 - 89 - 89 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-9.svg b/public/assets/cards/the-mind/mind-9.svg deleted file mode 100644 index 103cded4..00000000 --- a/public/assets/cards/the-mind/mind-9.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 9 - 9 - 9 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-90.svg b/public/assets/cards/the-mind/mind-90.svg deleted file mode 100644 index dd958da4..00000000 --- a/public/assets/cards/the-mind/mind-90.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 90 - 90 - 90 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-91.svg b/public/assets/cards/the-mind/mind-91.svg deleted file mode 100644 index 47612457..00000000 --- a/public/assets/cards/the-mind/mind-91.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 91 - 91 - 91 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-92.svg b/public/assets/cards/the-mind/mind-92.svg deleted file mode 100644 index 47065465..00000000 --- a/public/assets/cards/the-mind/mind-92.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 92 - 92 - 92 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-93.svg b/public/assets/cards/the-mind/mind-93.svg deleted file mode 100644 index 6d81187b..00000000 --- a/public/assets/cards/the-mind/mind-93.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 93 - 93 - 93 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-94.svg b/public/assets/cards/the-mind/mind-94.svg deleted file mode 100644 index 66f52254..00000000 --- a/public/assets/cards/the-mind/mind-94.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 94 - 94 - 94 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-95.svg b/public/assets/cards/the-mind/mind-95.svg deleted file mode 100644 index 819e7261..00000000 --- a/public/assets/cards/the-mind/mind-95.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 95 - 95 - 95 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-96.svg b/public/assets/cards/the-mind/mind-96.svg deleted file mode 100644 index e162262d..00000000 --- a/public/assets/cards/the-mind/mind-96.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 96 - 96 - 96 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-97.svg b/public/assets/cards/the-mind/mind-97.svg deleted file mode 100644 index 9c6c3bab..00000000 --- a/public/assets/cards/the-mind/mind-97.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 97 - 97 - 97 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-98.svg b/public/assets/cards/the-mind/mind-98.svg deleted file mode 100644 index 9ecc7bbf..00000000 --- a/public/assets/cards/the-mind/mind-98.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 98 - 98 - 98 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-99.svg b/public/assets/cards/the-mind/mind-99.svg deleted file mode 100644 index ea3a4d8b..00000000 --- a/public/assets/cards/the-mind/mind-99.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - 99 - 99 - 99 - \ No newline at end of file diff --git a/public/assets/cards/the-mind/mind-back.svg b/public/assets/cards/the-mind/mind-back.svg deleted file mode 100644 index 9a31769f..00000000 --- a/public/assets/cards/the-mind/mind-back.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - ? - \ No newline at end of file diff --git a/public/assets/games/main-street/svg/cards/biz-laundromat.svg b/public/assets/games/main-street/svg/cards/biz-laundromat.svg index 355dfee0..216fdc08 100644 --- a/public/assets/games/main-street/svg/cards/biz-laundromat.svg +++ b/public/assets/games/main-street/svg/cards/biz-laundromat.svg @@ -11,7 +11,7 @@ Laundromat -6 +3 Service icon diff --git a/public/assets/games/main-street/svg/cards/cs-library.svg b/public/assets/games/main-street/svg/cards/cs-library.svg index 1117cbf4..e37898ef 100644 --- a/public/assets/games/main-street/svg/cards/cs-library.svg +++ b/public/assets/games/main-street/svg/cards/cs-library.svg @@ -6,7 +6,7 @@ - + Library diff --git a/public/assets/games/main-street/svg/cards/cs-park.svg b/public/assets/games/main-street/svg/cards/cs-park.svg index f241e0a7..9b524d6a 100644 --- a/public/assets/games/main-street/svg/cards/cs-park.svg +++ b/public/assets/games/main-street/svg/cards/cs-park.svg @@ -6,7 +6,7 @@ - + Park diff --git a/public/assets/games/main-street/svg/cards/staff-assistant.svg b/public/assets/games/main-street/svg/cards/staff-assistant.svg index e08210f0..6835e31f 100644 --- a/public/assets/games/main-street/svg/cards/staff-assistant.svg +++ b/public/assets/games/main-street/svg/cards/staff-assistant.svg @@ -6,7 +6,7 @@ - + Assistant diff --git a/public/assets/games/main-street/svg/cards/staff-director.svg b/public/assets/games/main-street/svg/cards/staff-director.svg index da87381e..8aed0ae2 100644 --- a/public/assets/games/main-street/svg/cards/staff-director.svg +++ b/public/assets/games/main-street/svg/cards/staff-director.svg @@ -6,7 +6,7 @@ - + Director diff --git a/public/assets/games/main-street/svg/cards/staff-manager.svg b/public/assets/games/main-street/svg/cards/staff-manager.svg index 076e7209..8ad571c4 100644 --- a/public/assets/games/main-street/svg/cards/staff-manager.svg +++ b/public/assets/games/main-street/svg/cards/staff-manager.svg @@ -6,7 +6,7 @@ - + Manager diff --git a/public/assets/games/main-street/svg/cards/upg-readers-cafe.svg b/public/assets/games/main-street/svg/cards/upg-readers-cafe.svg index 065ac7a6..93ad695b 100644 --- a/public/assets/games/main-street/svg/cards/upg-readers-cafe.svg +++ b/public/assets/games/main-street/svg/cards/upg-readers-cafe.svg @@ -1,5 +1,5 @@ - + @@ -9,7 +9,7 @@ - upg-readers-cafe + Upgrade to Reader's Café 3 diff --git a/public/assets/games/the-mind/thumbnail.png b/public/assets/games/the-mind/thumbnail.png deleted file mode 100644 index 8f3e5522..00000000 Binary files a/public/assets/games/the-mind/thumbnail.png and /dev/null differ diff --git a/scripts/adapters/TheMindReplayAdapter.ts b/scripts/adapters/TheMindReplayAdapter.ts deleted file mode 100644 index 1b8f43c9..00000000 --- a/scripts/adapters/TheMindReplayAdapter.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * TheMindReplayAdapter -- replay adapter for The Mind. - * - * Handles The Mind transcript validation, state reconstruction, and - * state injection (via `TheMindScene.loadBoardState()`) for the replay - * tool. - * - * Unlike turn-based games, The Mind records real-time events (card-played, - * penalty, level-complete, game-over). Each event is a replay step. - * Board state is reconstructed by replaying events from `initialState` - * up to the target step index. - * - * Version 2 transcripts include `handsDealt` on `level-complete` events, - * enabling multi-level replay state reconstruction. - * - * @see ReplayAdapter -- interface definition - * @see example-games/the-mind/GameTranscript.ts -- transcript types - * - * Related work items: - * - CG-0MM0GQMDL061Z4UA (The Mind replay pipeline) - * - CG-0MLTFUL061DWDGA2 (adapter pattern) - */ - -import type { Page } from 'playwright'; -import type { - ReplayAdapter, - ValidationResult, - TakeoverOptions, -} from './ReplayAdapter'; - -// ── The Mind transcript types (minimal, for adapter) ────── - -type PlayerId = 0 | 1; - -interface MindCardPlayedEvent { - readonly type: 'card-played'; - readonly timestamp: number; - readonly playerId: PlayerId; - readonly cardValue: number; - readonly pileTopAfter: number; - readonly pileSizeAfter: number; -} - -interface MindPenaltyEvent { - readonly type: 'penalty'; - readonly timestamp: number; - readonly livesRemaining: number; - readonly discardedCards: ReadonlyArray<{ - readonly playerId: PlayerId; - readonly cardValue: number; - }>; -} - -interface MindLevelCompleteEvent { - readonly type: 'level-complete'; - readonly timestamp: number; - readonly level: number; - readonly bonusLifeAwarded: boolean; - readonly livesAfter: number; - readonly handsDealt?: [readonly number[], readonly number[]]; -} - -interface MindGameOverEvent { - readonly type: 'game-over'; - readonly timestamp: number; - readonly outcome: 'win' | 'loss'; - readonly finalLevel: number; - readonly finalLives: number; -} - -type MindEvent = - | MindCardPlayedEvent - | MindPenaltyEvent - | MindLevelCompleteEvent - | MindGameOverEvent; - -interface MindInitialState { - readonly playerNames: [string, string]; - readonly isAI: [boolean, boolean]; - readonly startingLives: number; - readonly startingLevel: number; - readonly hands: [readonly number[], readonly number[]]; -} - -interface MindTranscript { - readonly version: 1 | 2; - readonly gameType: 'the-mind'; - readonly startedAt: string; - readonly endedAt: string; - readonly initialState: MindInitialState; - readonly events: MindEvent[]; - readonly results: { - readonly outcome: 'win' | 'loss'; - readonly finalLevel: number; - readonly finalLives: number; - readonly totalCardsPlayed: number; - readonly totalPenalties: number; - } | null; -} - -// ── Reconstructed board state ───────────────────────────── - -/** - * Snapshot of the visual board state at a given event index. - * Passed to `TheMindScene.loadBoardState()`. - */ -interface BoardSnapshot { - humanHand: number[]; - aiHand: number[]; - pileTop: number; - pileSize: number; - currentLevel: number; - lives: number; - stepIndex: number; -} - -// ── Type guard ───────────────────────────────────────────── - -function isMindTranscript(raw: unknown): raw is MindTranscript { - if (typeof raw !== 'object' || raw === null) return false; - const obj = raw as Record; - return obj.gameType === 'the-mind'; -} - -// ── State reconstruction ────────────────────────────────── - -/** - * Reconstruct the board state at a given event index by replaying - * events from `initialState` through `events[0..targetIndex]`. - * - * Returns the initial state (before any events) when targetIndex is -1. - */ -function reconstructState( - transcript: MindTranscript, - targetIndex: number, -): BoardSnapshot { - const { initialState, events } = transcript; - - // Start with initial state - const humanHand = [...initialState.hands[0]]; - const aiHand = [...initialState.hands[1]]; - let pileTop = 0; - let pileSize = 0; - let lives = initialState.startingLives; - let level = initialState.startingLevel; - - // Replay events up to and including targetIndex - for (let i = 0; i <= targetIndex && i < events.length; i++) { - const event = events[i]; - - switch (event.type) { - case 'card-played': { - // Remove the card from the player's hand - const hand = event.playerId === 0 ? humanHand : aiHand; - const cardIdx = hand.indexOf(event.cardValue); - if (cardIdx !== -1) hand.splice(cardIdx, 1); - pileTop = event.pileTopAfter; - pileSize = event.pileSizeAfter; - break; - } - - case 'penalty': { - // Remove discarded cards from their respective hands - for (const discard of event.discardedCards) { - const hand = discard.playerId === 0 ? humanHand : aiHand; - const idx = hand.indexOf(discard.cardValue); - if (idx !== -1) hand.splice(idx, 1); - } - lives = event.livesRemaining; - break; - } - - case 'level-complete': { - lives = event.livesAfter; - level = event.level + 1; - // Reset hands for the next level if handsDealt is available (v2) - if (event.handsDealt) { - humanHand.length = 0; - humanHand.push(...event.handsDealt[0]); - aiHand.length = 0; - aiHand.push(...event.handsDealt[1]); - } - // Reset pile for new level - pileTop = 0; - pileSize = 0; - break; - } - - case 'game-over': { - // No visual state change needed beyond what preceding events set - break; - } - } - } - - return { - humanHand: humanHand.sort((a, b) => a - b), - aiHand: aiHand.sort((a, b) => a - b), - pileTop, - pileSize, - currentLevel: level, - lives, - stepIndex: targetIndex, - }; -} - -// ── Event description helpers ───────────────────────────── - -function describeEvent( - event: MindEvent, - playerNames: [string, string], -): string { - switch (event.type) { - case 'card-played': - return `${playerNames[event.playerId]} plays ${event.cardValue} (pile: ${event.pileTopAfter})`; - case 'penalty': { - const discards = event.discardedCards - .map((d) => `${playerNames[d.playerId]}:${d.cardValue}`) - .join(', '); - return `Penalty! Discarded [${discards}], lives: ${event.livesRemaining}`; - } - case 'level-complete': - return `Level ${event.level} complete${event.bonusLifeAwarded ? ' (+1 life)' : ''}, lives: ${event.livesAfter}`; - case 'game-over': - return `Game over: ${event.outcome} at level ${event.finalLevel}, lives: ${event.finalLives}`; - } -} - -// ── Adapter implementation ──────────────────────────────── - -export class TheMindReplayAdapter implements ReplayAdapter { - readonly gameType = 'the-mind'; - readonly sceneKey = 'TheMindScene'; - - canHandle(raw: unknown): boolean { - return isMindTranscript(raw); - } - - validateTranscript(raw: unknown): ValidationResult { - if (!isMindTranscript(raw)) { - return { - valid: false, - error: 'Transcript does not match The Mind schema.', - }; - } - - if (raw.version !== 1 && raw.version !== 2) { - return { - valid: false, - error: `Unsupported The Mind transcript version: ${raw.version}. Expected: 1 or 2`, - }; - } - - if (!raw.initialState) { - return { valid: false, error: 'Transcript has no initialState.' }; - } - - if (!Array.isArray(raw.initialState.hands) || raw.initialState.hands.length !== 2) { - return { - valid: false, - error: 'Transcript initialState.hands must be an array of 2 hand arrays.', - }; - } - - if (!Array.isArray(raw.events)) { - return { valid: false, error: 'Transcript has no events array.' }; - } - - return { valid: true }; - } - - getTurnCount(transcript: unknown): number { - const t = transcript as MindTranscript; - return t.events.length; - } - - getVersion(transcript: unknown): number { - return (transcript as MindTranscript).version; - } - - getSummaryLine(transcript: unknown): string { - const t = transcript as MindTranscript; - const players = t.initialState.playerNames.join(', '); - const events = t.events.length; - const outcome = t.results - ? `${t.results.outcome} at level ${t.results.finalLevel}` - : 'in-progress'; - return `Players: ${players}, Events: ${events}, Outcome: ${outcome}`; - } - - supportsInteractiveTakeover(_transcript: unknown): boolean { - // The Mind does not support interactive takeover. - return false; - } - - getReplayUrl(baseUrl: string): string { - return `${baseUrl}?mode=replay&game=the-mind`; - } - - async startScene(page: Page): Promise { - await page.evaluate(` - (() => { - const game = window.__PHASER_GAME__; - game.scene.start('TheMindScene'); - })() - `); - } - - async waitForSceneReady(page: Page, timeoutMs: number): Promise { - await page.waitForFunction( - `(() => { - const game = window.__PHASER_GAME__; - if (!game) return false; - const scene = game.scene.getScene('TheMindScene'); - return scene && scene.sys.isActive(); - })()`, - { timeout: timeoutMs }, - ); - } - - async injectInitialState( - page: Page, - transcript: unknown, - timeoutMs: number, - ): Promise { - const t = transcript as MindTranscript; - const snapshot = reconstructState(t, -1); - await this._injectBoardState(page, snapshot, timeoutMs); - } - - async injectTurnState( - page: Page, - transcript: unknown, - turnIndex: number, - timeoutMs: number, - ): Promise { - const t = transcript as MindTranscript; - const snapshot = reconstructState(t, turnIndex); - await this._injectBoardState(page, snapshot, timeoutMs); - } - - describeTurn(transcript: unknown, turnIndex: number): string { - const t = transcript as MindTranscript; - const event = t.events[turnIndex]; - if (!event) return `Event ${turnIndex + 1} (unknown)`; - return `Event ${turnIndex + 1}: ${describeEvent(event, t.initialState.playerNames)}`; - } - - describeLastAction(transcript: unknown, turnIndex: number): string { - if (turnIndex < 0) return 'N/A (initial state)'; - return this.describeTurn(transcript, turnIndex); - } - - async showTakeoverOverlay( - _page: Page, - _options: TakeoverOptions, - ): Promise { - throw new Error( - 'The Mind does not support interactive takeover yet.', - ); - } - - // ── Private helpers ────────────────────────────────────── - - /** - * Inject a board state snapshot via - * `TheMindScene.loadBoardState()` and wait for the - * `state-settled` event. - */ - private async _injectBoardState( - page: Page, - snapshot: BoardSnapshot, - timeoutMs: number, - ): Promise { - const stateJson = JSON.stringify(snapshot); - - await page.evaluate(` - new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Timed out waiting for state-settled after loadBoardState')), - ${timeoutMs}, - ); - const emitter = window.__GAME_EVENTS__; - if (!emitter) { - clearTimeout(timer); - reject(new Error('__GAME_EVENTS__ not found on window')); - return; - } - emitter.once('state-settled', () => { - clearTimeout(timer); - resolve(); - }); - const game = window.__PHASER_GAME__; - const scene = game.scene.getScene('TheMindScene'); - if (!scene) { - clearTimeout(timer); - reject(new Error('TheMindScene not found')); - return; - } - scene.loadBoardState(${stateJson}); - }) - `); - } -} diff --git a/scripts/adapters/index.ts b/scripts/adapters/index.ts index ecf2e641..72b732ef 100644 --- a/scripts/adapters/index.ts +++ b/scripts/adapters/index.ts @@ -28,7 +28,6 @@ export { adapterRegistry } from './AdapterRegistry'; // Order matters: explicit-field adapters first, structural-match last import { BeleagueredCastleReplayAdapter } from './BeleagueredCastleReplayAdapter'; import { LostCitiesReplayAdapter } from './LostCitiesReplayAdapter'; -import { TheMindReplayAdapter } from './TheMindReplayAdapter'; import { SushiGoReplayAdapter } from './SushiGoReplayAdapter'; import { FeudalismReplayAdapter } from './FeudalismReplayAdapter'; import { MainStreetReplayAdapter } from './MainStreetReplayAdapter'; @@ -41,9 +40,6 @@ adapterRegistry.register(new BeleagueredCastleReplayAdapter()); // LC has an explicit `gameType: 'lost-cities'` field -- register before Golf adapterRegistry.register(new LostCitiesReplayAdapter()); -// The Mind has an explicit `gameType: 'the-mind'` field -- register before Golf -adapterRegistry.register(new TheMindReplayAdapter()); - // Sushi Go has an explicit `gameType: 'sushi-go'` field -- register before Golf adapterRegistry.register(new SushiGoReplayAdapter()); diff --git a/scripts/balance-cards/algorithm.ts b/scripts/balance-cards/algorithm.ts new file mode 100644 index 00000000..6a03aa26 --- /dev/null +++ b/scripts/balance-cards/algorithm.ts @@ -0,0 +1,554 @@ +import type { CsvRow } from './csv'; +import type { RationaleCode } from './rationale'; + +/** + * Safely get a string value from a CsvRow by key, defaulting to '' for missing/optional fields. + */ +function csvVal(row: CsvRow, field: string): string { + return (row as unknown as Record)[field] ?? ''; +} + +// ── Type definitions ────────────────────────────────────────────────── + +export interface Adjustment { + cardId: string; + cardName: string; + family: string; + field: string; + oldValue: number; + newValue: number; + rationale: RationaleCode; +} + +export interface FamilySummary { + family: string; + cardsAdjusted: number; + totalCards: number; + oldCostMin: number; + oldCostMax: number; + newCostMin: number; + newCostMax: number; + oldRewardMin: number; + oldRewardMax: number; + newRewardMin: number; + newRewardMax: number; +} + +export interface BalancingResult { + rows: CsvRow[]; + adjustments: Adjustment[]; + summaries: FamilySummary[]; +} + +// ── Tier band definitions ───────────────────────────────────────────── + +export const TIER_BANDS: Record = { + budget: { min: 0, max: 3, label: 'Budget' }, + economy: { min: 4, max: 5, label: 'Economy' }, + standard: { min: 6, max: 7, label: 'Standard' }, + premium: { min: 8, max: 9, label: 'Premium' }, + flagship: { min: 10, max: 14, label: 'Flagship' }, +}; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function roundHalf(value: number): number { + return Math.round(value * 2) / 2; +} + +function countSynergyTypes(synergyTypes: string): number { + if (!synergyTypes) return 0; + return synergyTypes.split('|').filter(Boolean).length; +} + +// ── Curve-fitting: Business/Community Space ─────────────────────────── + +export function computeBusinessExpectedCost(row: CsvRow): number { + const baseIncome = parseFloat(csvVal(row, 'baseIncome')) || 0; + const synergyCount = countSynergyTypes(row.synergyTypes); + const synergyCoinBonus = parseFloat(csvVal(row, 'synergyCoinBonus')) || 0; + const synergyRepBonus = parseFloat(csvVal(row, 'synergyRepBonus')) || 0; + const reputationPerTurn = parseFloat(csvVal(row, 'reputationPerTurn')) || 0; + const incomeBonus = parseFloat(csvVal(row, 'incomeBonus')) || 0; + const tier = parseInt(csvVal(row, 'tier'), 10) || 1; + + // Base cost from tier: 1→4, 2→5.5, 3→7, 4→9, 5→11 + // This ensures the existing tier assignments drive the cost spread + let cost = tier * 2 + 2; + + // Modifiers from card stats + cost += baseIncome * 4; + cost += synergyCount * 3; + cost += synergyCoinBonus * 2; + cost += synergyRepBonus * 2; + cost += reputationPerTurn * 30; + cost += incomeBonus * 3; + + // Pawn Shop special case: no synergy bonuses, reduce cost slightly + if (baseIncome === 0 && synergyCoinBonus === 0 && synergyRepBonus === 0 && reputationPerTurn === 0) { + cost -= 2; + } + + cost = Math.max(cost, 4); + return cost; +} + +// ── Curve-fitting: Investment Events ────────────────────────────────── + +export function computeEventExpectedCost(row: CsvRow): number { + const coinDelta = parseFloat(csvVal(row, 'coinDelta')) || 0; + const reputationDelta = parseFloat(csvVal(row, 'reputationDelta')) || 0; + const tier = parseInt(csvVal(row, 'tier'), 10) || 1; + + let cost = tier * 1.5 + 0.5; + + // Add modifiers from event deltas + cost += coinDelta * 1.5; + cost += reputationDelta * 2; + + // Scope multiplier: SpecificSynergy is worth 1.2x + const targetScopeMultiplier = (csvVal(row, 'targetSynergy') === 'All') ? 1.0 : 1.2; + cost *= targetScopeMultiplier; + + cost = Math.max(cost, 2); + return cost; +} + +// ── Curve-fitting: Upgrades ─────────────────────────────────────────── + +export function computeUpgradeExpectedCost(row: CsvRow): number { + const incomeBonus = parseFloat(csvVal(row, 'incomeBonus')) || 0; + const synergyRangeBonus = parseFloat(csvVal(row, 'synergyRangeBonus')) || 0; + const requiredLevel = parseFloat(csvVal(row, 'requiredLevel')) || 0; + const reputationBonus = parseFloat(csvVal(row, 'reputationBonus')) || 0; + const tier = parseInt(csvVal(row, 'tier'), 10) || 1; + + let cost = tier * 1.5 + 1; + cost += incomeBonus * 3; + cost += synergyRangeBonus * 3; + cost += requiredLevel * 1; + cost += reputationBonus * 10; + cost = Math.max(cost, 2); + return cost; +} + +// ── Curve-fitting: Staff ────────────────────────────────────────────── + +export function computeStaffExpectedCost(row: CsvRow): number { + const ongoingCost = parseFloat(csvVal(row, 'ongoingCost')) || 0; + const handSlotsAdded = parseFloat(csvVal(row, 'handSlotsAdded')) || 0; + return ongoingCost * 5 + handSlotsAdded * 5; +} + +// ── Tier band assignment ────────────────────────────────────────────── + +export function assignTierBands( + cards: { id: string; expectedCost: number; currentCost: number }[], + _family: string, +): Map { + const result = new Map(); + const sorted = [...cards].sort((a, b) => a.expectedCost - b.expectedCost); + const minCost = sorted.length > 0 ? Math.min(...sorted.map(c => c.expectedCost)) : 0; + const maxCost = sorted.length > 0 ? Math.max(...sorted.map(c => c.expectedCost)) : 0; + + for (const card of sorted) { + const percentile = maxCost === minCost ? 0 : (card.expectedCost - minCost) / (maxCost - minCost); + let band: string; + let adjustedCost: number; + let rationale: RationaleCode; + + if (percentile < 0.2) { + band = 'budget'; + adjustedCost = roundHalf(clamp(card.expectedCost, TIER_BANDS.budget.min, TIER_BANDS.budget.max)); + } else if (percentile < 0.4) { + band = 'economy'; + adjustedCost = roundHalf(clamp(card.expectedCost, TIER_BANDS.economy.min, TIER_BANDS.economy.max)); + } else if (percentile < 0.6) { + band = 'standard'; + adjustedCost = roundHalf(clamp(card.expectedCost, TIER_BANDS.standard.min, TIER_BANDS.standard.max)); + } else if (percentile < 0.8) { + band = 'premium'; + adjustedCost = roundHalf(clamp(card.expectedCost, TIER_BANDS.premium.min, TIER_BANDS.premium.max)); + } else { + band = 'flagship'; + adjustedCost = roundHalf(clamp(card.expectedCost, TIER_BANDS.flagship.min, TIER_BANDS.flagship.max)); + } + + rationale = card.currentCost !== adjustedCost ? 'TIER_REASSIGN' as RationaleCode : 'BAND_BALANCE' as RationaleCode; + result.set(card.id, { adjustedCost, band, rationale }); + } + + return result; +} + +// ── Cost spread enforcement ──────────────────────────────────────────── +// Ensures no single cost value exceeds 1/3 of cards in a family. + +function enforceCostSpread( + rows: CsvRow[], + family: string, + adjustments: Adjustment[], +): void { + const familyRows = rows.filter(r => r.family === family); + if (familyRows.length === 0) return; + + const threshold = Math.ceil(familyRows.length / 3); + + // Count cost frequencies + const freq = new Map(); + for (const row of familyRows) { + const cost = parseFloat(csvVal(row, 'cost')) || 0; + if (!freq.has(cost)) freq.set(cost, []); + freq.get(cost)!.push(row); + } + + // Find costs exceeding threshold, sorted by cost ascending + const overThreshold = [...freq.entries()] + .filter(([_, cards]) => cards.length > threshold) + .sort(([a], [b]) => a - b); + + for (const [clusteredCost, clusteredCards] of overThreshold) { + // Move excess cards to adjacent cost values (±1, ±2) + const excess = clusteredCards.slice(threshold); + for (const card of excess) { + // Try spreading upward first, then downward + for (const delta of [1, -1, 2, -2]) { + const newCost = Math.max(1, clusteredCost + delta); + const currentCount = [...freq.entries()] + .filter(([c]) => c === newCost) + .reduce((sum, [_, cards]) => sum + cards.length, 0); + if (currentCount < threshold || newCost >= clusteredCost + 2) { + const oldVal = parseFloat(csvVal(card, 'cost')) || 0; + (card as unknown as Record)['cost'] = String(newCost); + adjustments.push({ + cardId: card.id, cardName: card.name, family, + field: 'cost', oldValue: oldVal, newValue: newCost, + rationale: 'BAND_BALANCE' as RationaleCode, + }); + // Update frequency for subsequent iterations + if (!freq.has(newCost)) freq.set(newCost, []); + freq.get(newCost)!.push(card); + break; + } + } + } + } +} + +// ── Reward spread: Business/Community Space ─────────────────────────── + +export function computeBusinessRewardSpread( + row: CsvRow, + adjustedCost: number, + previousCost: number, +): { baseIncome?: number; synergyCoinBonus?: number; synergyRepBonus?: number; rationale: RationaleCode } { + const costRatio = adjustedCost / Math.max(previousCost, 1); + const baseIncome = parseFloat(csvVal(row, 'baseIncome')) || 0; + + const result: { baseIncome?: number; synergyCoinBonus?: number; synergyRepBonus?: number; rationale: RationaleCode } = { rationale: 'BAND_BALANCE' as RationaleCode }; + + if (costRatio > 1.1 && baseIncome > 0) { + result.baseIncome = roundHalf(baseIncome * costRatio * 0.7); + result.rationale = 'INCOME_ADJUST' as RationaleCode; + } else if (costRatio < 0.9 && baseIncome > 0) { + result.baseIncome = Math.max(0, roundHalf(baseIncome * costRatio)); + result.rationale = 'INCOME_ADJUST' as RationaleCode; + } + + const synergyCoin = parseFloat(csvVal(row, 'synergyCoinBonus')) || 0; + const synergyRep = parseFloat(csvVal(row, 'synergyRepBonus')) || 0; + + if (adjustedCost >= 8) { + if (synergyCoin === 0) { + result.synergyCoinBonus = 1; + result.rationale = 'SYNERGY_BONUS_ADJ' as RationaleCode; + } + if (synergyRep === 0) { + result.synergyRepBonus = 0.1; + result.rationale = 'REPUTATION_ADJ' as RationaleCode; + } + } + + return result; +} + +// ── Reward spread: Investment Events ────────────────────────────────── + +export function computeEventRewardSpread( + row: CsvRow, + adjustedCost: number, + previousCost: number, +): { coinDelta?: number; reputationDelta?: number; rationale: RationaleCode } { + const costRatio = adjustedCost / Math.max(previousCost, 1); + const coinDelta = parseFloat(csvVal(row, 'coinDelta')) || 0; + const reputationDelta = parseFloat(csvVal(row, 'reputationDelta')) || 0; + + const result: { coinDelta?: number; reputationDelta?: number; rationale: RationaleCode } = { rationale: 'BAND_BALANCE' as RationaleCode }; + + if (costRatio > 1.1 && coinDelta > 0) { + result.coinDelta = roundHalf(coinDelta * costRatio); + result.rationale = 'REWARD_SPREAD' as RationaleCode; + } + if (costRatio > 1.1 && reputationDelta > 0) { + result.reputationDelta = roundHalf(reputationDelta * costRatio); + result.rationale = 'REWARD_SPREAD' as RationaleCode; + } + if (costRatio < 0.9 && coinDelta > 0) { + result.coinDelta = Math.max(0, roundHalf(coinDelta * costRatio)); + result.rationale = 'REWARD_SPREAD' as RationaleCode; + } + + return result; +} + +// ── Reward spread: Upgrades ─────────────────────────────────────────── + +export function computeUpgradeRewardSpread( + row: CsvRow, + adjustedCost: number, + previousCost: number, +): { incomeBonus?: number; synergyRangeBonus?: number; reputationBonus?: number; rationale: RationaleCode } { + const costRatio = adjustedCost / Math.max(previousCost, 1); + const incomeBonus = parseFloat(csvVal(row, 'incomeBonus')) || 0; + + const result: { incomeBonus?: number; synergyRangeBonus?: number; reputationBonus?: number; rationale: RationaleCode } = { rationale: 'BAND_BALANCE' as RationaleCode }; + + if (costRatio > 1.1) { + if (incomeBonus > 0) { + result.incomeBonus = roundHalf(incomeBonus * costRatio); + result.rationale = 'REWARD_SPREAD' as RationaleCode; + } + if (adjustedCost >= 5) { + const synergyRB = parseFloat(csvVal(row, 'synergyRangeBonus')) || 0; + const repB = parseFloat(csvVal(row, 'reputationBonus')) || 0; + if (synergyRB === 0) { result.synergyRangeBonus = 1; result.rationale = 'SYNERGY_BONUS_ADJ' as RationaleCode; } + if (repB === 0) { result.reputationBonus = 1; result.rationale = 'REPUTATION_ADJ' as RationaleCode; } + } + } + + return result; +} + +// ── Reward spread: Staff ────────────────────────────────────────────── + +export function computeStaffRewardSpread( + _row: CsvRow, + adjustedCost: number, + previousCost: number, +): { ongoingCost?: number; handSlotsAdded?: number; rationale: RationaleCode } { + const costRatio = adjustedCost / Math.max(previousCost, 1); + const result: { ongoingCost?: number; handSlotsAdded?: number; rationale: RationaleCode } = { rationale: 'BAND_BALANCE' as RationaleCode }; + + // Staff: cost vs ongoingCost direct relationship + if (costRatio > 1.1 || costRatio < 0.9) { + const ongoingCost = parseFloat(csvVal(_row, 'ongoingCost')) || 0; + const newOngoing = roundHalf(ongoingCost * costRatio); + if (newOngoing !== ongoingCost && newOngoing >= 0) { + result.ongoingCost = newOngoing; + result.rationale = 'ONGOING_COST_ADJ' as RationaleCode; + } + } + + return result; +} + +// ── Helper to push an Adjustment ────────────────────────────────────── + +function pushAdj( + adjustments: Adjustment[], + cardId: string, cardName: string, family: string, + field: string, oldValue: number, newValue: number, + rationale: RationaleCode, +): void { + if (oldValue !== newValue) { + adjustments.push({ cardId, cardName, family, field, oldValue, newValue, rationale }); + } +} + +// ── Family configs ──────────────────────────────────────────────────── + +interface FamilyConfig { + computeExpectedCost: (row: CsvRow) => number; + computeRewardSpread: (row: CsvRow, adjCost: number, prevCost: number) => Record; + isExcluded: (row: CsvRow) => boolean; +} + +const FAMILY_CONFIGS: Record = { + business: { + computeExpectedCost: computeBusinessExpectedCost, + computeRewardSpread: computeBusinessRewardSpread, + isExcluded: () => false, + }, + 'community-space': { + computeExpectedCost: computeBusinessExpectedCost, + computeRewardSpread: computeBusinessRewardSpread, + isExcluded: () => false, + }, + event: { + computeExpectedCost: computeEventExpectedCost, + computeRewardSpread: computeEventRewardSpread, + isExcluded: (row: CsvRow) => csvVal(row, 'trigger') === 'Incident', + }, + upgrade: { + computeExpectedCost: computeUpgradeExpectedCost, + computeRewardSpread: computeUpgradeRewardSpread, + isExcluded: () => false, + }, + staff: { + computeExpectedCost: computeStaffExpectedCost, + computeRewardSpread: computeStaffRewardSpread, + isExcluded: () => false, + }, +}; + +// ── Reward field groups ─────────────────────────────────────────────── + +const REWARD_FIELDS: Record = { + business: ['baseIncome', 'synergyCoinBonus', 'synergyRepBonus'], + 'community-space': ['baseIncome', 'synergyCoinBonus', 'synergyRepBonus'], + event: ['coinDelta', 'reputationDelta'], + upgrade: ['incomeBonus', 'synergyRangeBonus', 'reputationBonus'], + staff: ['ongoingCost', 'handSlotsAdded'], +}; + +// ── Main balancing pipeline ─────────────────────────────────────────── + +export function runBalancingPass(rows: CsvRow[]): BalancingResult { + const adjustments: Adjustment[] = []; + const resultRows: CsvRow[] = []; + + const families = new Map(); + for (const row of rows) { + const fam = row.family; + if (!families.has(fam)) families.set(fam, []); + families.get(fam)!.push(row); + } + + const summaries: FamilySummary[] = []; + + for (const [family, familyRows] of families) { + const config = FAMILY_CONFIGS[family]; + if (!config) { + resultRows.push(...familyRows); + continue; + } + + const familyAdjustments: Adjustment[] = []; + const adjustedRows: CsvRow[] = []; + + // Phase 1: Compute expected costs + const costCards = familyRows + .filter(row => !config.isExcluded(row)) + .map(row => ({ + id: row.id, + expectedCost: config.computeExpectedCost(row), + currentCost: parseFloat(row.cost) || 0, + })); + + // Phase 2: Assign tier bands + const bandAssignments = assignTierBands(costCards, family); + + // Phase 3: Apply adjustments + for (const row of familyRows) { + const isExcluded = config.isExcluded(row); + const originalCost = parseFloat(csvVal(row, 'cost')) || 0; + let adjustedCost = originalCost; + let bandRationale: RationaleCode | undefined; + + if (!isExcluded && bandAssignments.has(row.id)) { + const assignment = bandAssignments.get(row.id)!; + adjustedCost = assignment.adjustedCost; + bandRationale = assignment.rationale; + } + + const newRow = { ...row } as CsvRow; + newRow.cost = String(adjustedCost); + + // Record cost adjustment + if (adjustedCost !== originalCost) { + pushAdj(familyAdjustments, row.id, row.name, family, + 'cost', originalCost, adjustedCost, bandRationale ?? 'COST_CURVE_FIT' as RationaleCode); + } + + // Phase 4: Apply reward spread + if (!isExcluded) { + const rewardSpread = config.computeRewardSpread(row, adjustedCost, originalCost); + const rewardKeys = REWARD_FIELDS[family] ?? []; + + for (const key of rewardKeys) { + const rawVal = (rewardSpread as Record)[key]; + if (typeof rawVal === 'number') { + const oldNum = parseFloat(csvVal(row, key)) || 0; + (newRow as unknown as Record)[key] = String(rawVal); + const rationale = (typeof rewardSpread.rationale === 'string' ? rewardSpread.rationale : 'BAND_BALANCE') as RationaleCode; + pushAdj(familyAdjustments, row.id, row.name, family, key, oldNum, rawVal, rationale); + } + } + } + + // Record exclusion for incidents + if (isExcluded) { + familyAdjustments.push({ + cardId: row.id, cardName: row.name, family, + field: 'cost', + oldValue: originalCost, + newValue: originalCost, + rationale: 'INCIDENT_FREE' as RationaleCode, + }); + } + + adjustedRows.push(newRow); + } + + // Compute summary + const oldCosts = familyRows.map(r => parseFloat(r.cost) || 0); + const newCosts = adjustedRows.map(r => parseFloat(csvVal(r, 'cost')) || 0); + const rewardFields = REWARD_FIELDS[family] ?? []; + + const oldRewardVals = familyRows.flatMap(r => rewardFields.map(f => parseFloat(csvVal(r, f)) || 0)); + const newRewardVals = adjustedRows.flatMap(r => rewardFields.map(f => parseFloat(csvVal(r, f)) || 0)); + + const oldRewardMin = oldRewardVals.length > 0 ? Math.min(...oldRewardVals) : 0; + const oldRewardMax = oldRewardVals.length > 0 ? Math.max(...oldRewardVals) : 0; + const newRewardMin = newRewardVals.length > 0 ? Math.min(...newRewardVals) : 0; + const newRewardMax = newRewardVals.length > 0 ? Math.max(...newRewardVals) : 0; + + summaries.push({ + family, + cardsAdjusted: familyAdjustments.filter(a => a.oldValue !== a.newValue && a.rationale !== 'INCIDENT_FREE').length, + totalCards: familyRows.length, + oldCostMin: Math.min(...oldCosts), + oldCostMax: Math.max(...oldCosts), + newCostMin: Math.min(...newCosts), + newCostMax: Math.max(...newCosts), + oldRewardMin, + oldRewardMax, + newRewardMin, + newRewardMax, + }); + + adjustments.push(...familyAdjustments); + resultRows.push(...adjustedRows); + } + + // Enforce cost spread for business and community-space families + // to prevent >1/3 of cards sharing the same cost + for (const fam of ['business', 'community-space', 'upgrade', 'event', 'staff'] as const) { + enforceCostSpread(resultRows, fam, adjustments); + } + + // Recompute summaries after spread enforcement + for (const summary of summaries) { + const famRows = resultRows.filter(r => r.family === summary.family); + const costs = famRows.map(r => parseFloat(csvVal(r, 'cost')) || 0); + summary.newCostMin = Math.min(...costs); + summary.newCostMax = Math.max(...costs); + summary.cardsAdjusted = adjustments.filter( + a => a.family === summary.family && a.oldValue !== a.newValue && a.rationale !== 'INCIDENT_FREE' + ).length; + } + + return { rows: resultRows, adjustments, summaries }; +} diff --git a/scripts/balance-cards/csv.ts b/scripts/balance-cards/csv.ts new file mode 100644 index 00000000..02f49282 --- /dev/null +++ b/scripts/balance-cards/csv.ts @@ -0,0 +1,166 @@ +import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; + +export interface CsvRow { + family: string; + id: string; + name: string; + cost: string; + baseIncome: string; + synergyTypes: string; + upgradePath: string; + maxLevel: string; + reputationPerTurn: string; + description: string; + tier: string; + trigger?: string; + effect?: string; + target?: string; + targetSynergy?: string; + coinDelta?: string; + reputationDelta?: string; + duration?: string; + effectType?: string; + multiplier?: string; + targetBusiness?: string; + incomeBonus?: string; + synergyRangeBonus?: string; + requiredLevel?: string; + reputationBonus?: string; + synergyCoinBonus?: string; + synergyRepBonus?: string; + ongoingCost?: string; + handSlotsAdded?: string; +} + +export const CSV_COLUMNS: readonly string[] = [ + 'family', 'id', 'name', 'cost', 'baseIncome', 'synergyTypes', 'upgradePath', + 'maxLevel', 'reputationPerTurn', 'synergyCoinBonus', 'synergyRepBonus', + 'description', 'tier', 'trigger', 'effect', 'target', + 'targetSynergy', 'coinDelta', 'reputationDelta', 'duration', 'effectType', + 'multiplier', 'targetBusiness', 'incomeBonus', 'synergyRangeBonus', + 'requiredLevel', 'reputationBonus', 'ongoingCost', 'handSlotsAdded', +]; + +export const NUMERIC_COLUMNS: readonly string[] = [ + 'cost', 'baseIncome', 'coinDelta', 'reputationDelta', 'incomeBonus', + 'ongoingCost', 'reputationPerTurn', 'reputationBonus', 'synergyCoinBonus', + 'synergyRepBonus', 'synergyRangeBonus', 'requiredLevel', 'handSlotsAdded', + 'maxLevel', 'duration', 'multiplier', +]; + +function parseCsvLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; } + else if (ch === '"') { inQuotes = false; } + else { current += ch; } + } else { + if (ch === '"') { inQuotes = true; } + else if (ch === ',') { fields.push(current); current = ''; } + else { current += ch; } + } + } + fields.push(current); + return fields; +} + +export function parseCsv(content: string): CsvRow[] { + const lines = content.split('\n').filter(l => l.trim() !== ''); + if (lines.length < 2) throw new Error('CSV file must have a header row and at least one data row'); + const headers = parseCsvLine(lines[0]).map(h => h.trim()); + if (headers.length !== CSV_COLUMNS.length) { + throw new Error(`CSV has ${headers.length} columns but expected ${CSV_COLUMNS.length}. Columns: ${headers.join(', ')}`); + } + const rows: CsvRow[] = []; + for (let i = 1; i < lines.length; i++) { + const values = parseCsvLine(lines[i]); + if (values.length === 0) continue; + const row: Record = {}; + for (let j = 0; j < headers.length; j++) row[headers[j]] = values[j] ?? ''; + rows.push(row as unknown as CsvRow); + } + return rows; +} + +export function toCsvString(rows: CsvRow[]): string { + const headerLine = CSV_COLUMNS.join(','); + const dataLines = rows.map(row => { + const fields: string[] = []; + for (const col of CSV_COLUMNS) { + const value = (row as unknown as Record)[col] ?? ''; + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + fields.push('"' + value.replace(/"/g, '""') + '"'); + } else { + fields.push(value); + } + } + return fields.join(','); + }); + return [headerLine, ...dataLines].join('\n') + '\n'; +} + +export function validateRow(row: CsvRow, rowIndex: number): string[] { + const errors: string[] = []; + const prefix = `Row ${rowIndex + 1} (${row.id || '(missing id)'})`; + if (!row.id) errors.push(`${prefix}: missing card id`); + if (!row.name) errors.push(`${prefix}: missing card name`); + if (!row.family) errors.push(`${prefix}: missing family`); + for (const col of NUMERIC_COLUMNS) { + const val = (row as unknown as Record)[col]; + if (val !== '' && val !== undefined && isNaN(parseFloat(val))) { + errors.push(`${prefix}: column '${col}' has non-numeric value '${val}'`); + } + } + const validFamilies = ['business', 'community-space', 'event', 'upgrade', 'staff']; + if (row.family && !validFamilies.includes(row.family)) { + errors.push(`${prefix}: unknown family '${row.family}'`); + } + return errors; +} + +export function validateCsvRows(rows: CsvRow[]): void { + const allErrors: string[] = []; + for (let i = 0; i < rows.length; i++) { + allErrors.push(...validateRow(rows[i], i)); + } + if (allErrors.length > 0) { + throw new Error(`CSV validation failed (${allErrors.length} errors):\n - ${allErrors.join('\n - ')}`); + } +} + +export function readCsvFile(filePath: string): CsvRow[] { + if (!existsSync(filePath)) throw new Error(`CSV file not found: ${filePath}`); + const content = readFileSync(filePath, 'utf-8'); + return parseCsv(content); +} + +export function writeCsvFile(filePath: string, rows: CsvRow[]): void { + const content = toCsvString(rows); + writeFileSync(filePath, content, 'utf-8'); +} + +const MAX_BACKUPS = 5; + +export function rotateBackups(filePath: string): void { + if (!existsSync(filePath)) return; + const oldestPath = `${filePath}.bak.${MAX_BACKUPS}`; + if (existsSync(oldestPath)) unlinkSync(oldestPath); + for (let i = MAX_BACKUPS - 1; i >= 1; i--) { + const from = `${filePath}.bak.${i}`; + const to = `${filePath}.bak.${i + 1}`; + if (existsSync(from)) renameSync(from, to); + } + copyFileSync(filePath, `${filePath}.bak.1`); +} + +export function listBackups(filePath: string): string[] { + const backups: string[] = []; + for (let i = 1; i <= MAX_BACKUPS; i++) { + if (existsSync(`${filePath}.bak.${i}`)) backups.push(`${filePath}.bak.${i}`); + } + return backups; +} diff --git a/scripts/balance-cards/index.ts b/scripts/balance-cards/index.ts new file mode 100644 index 00000000..fe272064 --- /dev/null +++ b/scripts/balance-cards/index.ts @@ -0,0 +1,37 @@ +export { + RationaleCode, + rationaleLabel, + isValidRationaleCode, + getAllRationaleCodes, +} from './rationale'; + +export { + runBalancingPass, + computeBusinessExpectedCost, + computeEventExpectedCost, + computeUpgradeExpectedCost, + computeStaffExpectedCost, + assignTierBands, + computeBusinessRewardSpread, + computeEventRewardSpread, + computeUpgradeRewardSpread, + computeStaffRewardSpread, + TIER_BANDS, +} from './algorithm'; +export type { Adjustment, FamilySummary, BalancingResult } from './algorithm'; + +export { + CSV_COLUMNS, + NUMERIC_COLUMNS, + parseCsv, + toCsvString, + validateRow, + validateCsvRows, + readCsvFile, + writeCsvFile, + rotateBackups, + listBackups, +} from './csv'; +export type { CsvRow } from './csv'; + +export { formatSummaryTable } from './summary'; diff --git a/scripts/balance-cards/rationale.ts b/scripts/balance-cards/rationale.ts new file mode 100644 index 00000000..443ad59b --- /dev/null +++ b/scripts/balance-cards/rationale.ts @@ -0,0 +1,52 @@ +/** + * Rationale code enum for balancing adjustments. + * Each code has a machine-readable key and a human-readable label. + */ + +export const RationaleCode = { + TIER_REASSIGN: 'TIER_REASSIGN', + COST_CURVE_FIT: 'COST_CURVE_FIT', + REWARD_SPREAD: 'REWARD_SPREAD', + BAND_BALANCE: 'BAND_BALANCE', + INCIDENT_FREE: 'INCIDENT_FREE', + MIN_COST_FLOOR: 'MIN_COST_FLOOR', + MAX_COST_CEIL: 'MAX_COST_CEIL', + SPECIAL_CASE: 'SPECIAL_CASE', + SYNERGY_BONUS_ADJ: 'SYNERGY_BONUS_ADJ', + INCOME_ADJUST: 'INCOME_ADJUST', + REPUTATION_ADJ: 'REPUTATION_ADJ', + ONGOING_COST_ADJ: 'ONGOING_COST_ADJ', + HAND_SLOT_ADJ: 'HAND_SLOT_ADJ', + SCOPE_ADJ: 'SCOPE_ADJ', +} as const; + +export type RationaleCode = (typeof RationaleCode)[keyof typeof RationaleCode]; + +export const RATIONALE_LABELS: Record = { + TIER_REASSIGN: 'Tier reassignment to better reflect card value', + COST_CURVE_FIT: 'Cost adjusted via curve-fitting to reward model', + REWARD_SPREAD: 'Reward fields adjusted to widen spread', + BAND_BALANCE: 'Band balance adjustment to improve cost distribution', + INCIDENT_FREE: 'Incident event kept at cost 0 (not adjusted)', + MIN_COST_FLOOR: 'Cost adjusted to meet minimum cost floor for tier', + MAX_COST_CEIL: 'Cost adjusted to meet maximum cost ceiling for tier', + SPECIAL_CASE: 'Special case handling (e.g., no-synergy cards)', + SYNERGY_BONUS_ADJ: 'Synergy bonus adjusted based on family heuristics', + INCOME_ADJUST: 'Income adjusted based on cost tier relationship', + REPUTATION_ADJ: 'Reputation bonus adjusted to reflect cost tier', + ONGOING_COST_ADJ: 'Ongoing cost adjusted for staff tier balance', + HAND_SLOT_ADJ: 'Hand slot bonus adjusted for staff tier balance', + SCOPE_ADJ: 'Event target scope multiplier applied to cost', +}; + +export function rationaleLabel(code: RationaleCode): string { + return RATIONALE_LABELS[code] ?? `Unknown rationale: ${code}`; +} + +export function isValidRationaleCode(code: string): code is RationaleCode { + return code in RationaleCode; +} + +export function getAllRationaleCodes(): RationaleCode[] { + return Object.values(RationaleCode); +} diff --git a/scripts/balance-cards/summary.ts b/scripts/balance-cards/summary.ts new file mode 100644 index 00000000..30ba66b2 --- /dev/null +++ b/scripts/balance-cards/summary.ts @@ -0,0 +1,89 @@ +import type { Adjustment, FamilySummary } from './algorithm'; +import { rationaleLabel } from './rationale'; + +export function formatSummaryTable( + adjustments: Adjustment[], + summaries: FamilySummary[], + totalCards: number, +): string { + const lines: string[] = []; + + lines.push(''); + lines.push('═'.repeat(80)); + lines.push(' BALANCING PASS SUMMARY'); + lines.push('═'.repeat(80)); + lines.push(''); + + lines.push('─── Per-Family Summary ───'); + lines.push(''); + + const familyHeader = + 'Family'.padEnd(18) + + 'Tot/Adj'.padEnd(10) + + 'Cost Range (old)'.padEnd(22) + + 'Cost Range (new)'.padEnd(22) + + 'Reward Range (old)'.padEnd(20) + + 'Reward Range (new)'; + lines.push(familyHeader); + lines.push('─'.repeat(familyHeader.length)); + + for (const s of summaries) { + const row = + s.family.padEnd(18) + + `${s.totalCards}/${s.cardsAdjusted}`.padEnd(10) + + `[${s.oldCostMin}-${s.oldCostMax}]`.padEnd(22) + + `[${s.newCostMin}-${s.newCostMax}]`.padEnd(22) + + `[${s.oldRewardMin}-${s.oldRewardMax}]`.padEnd(20) + + `[${s.newRewardMin}-${s.newRewardMax}]`; + lines.push(row); + } + + lines.push(''); + lines.push(`Total cards processed: ${totalCards}`); + const realAdjustments = adjustments.filter(a => a.oldValue !== a.newValue && a.rationale !== 'INCIDENT_FREE'); + lines.push(`Total adjustments: ${realAdjustments.length}`); + lines.push(''); + + if (realAdjustments.length > 0) { + lines.push('─── Detailed Adjustments ───'); + lines.push(''); + + const detailHeader = + 'Card'.padEnd(24) + + 'Family'.padEnd(16) + + 'Field'.padEnd(18) + + 'Old'.padEnd(10) + + 'New'.padEnd(10) + + 'Rationale'; + lines.push(detailHeader); + lines.push('─'.repeat(detailHeader.length)); + + const byCard = new Map(); + for (const a of realAdjustments) { + if (!byCard.has(a.cardId)) byCard.set(a.cardId, []); + byCard.get(a.cardId)!.push(a); + } + + for (const [_cardId, cardAdj] of byCard) { + const first = cardAdj[0]; + for (let i = 0; i < cardAdj.length; i++) { + const a = cardAdj[i]; + const prefix = i > 0 ? ' '.repeat(24 + 16) : `${first.cardName} (${a.cardId})`.padEnd(24) + first.family.padEnd(16); + const rationale = rationaleLabel(a.rationale).split(' ').slice(0, 4).join(' '); + lines.push(prefix + a.field.padEnd(18) + String(a.oldValue).padEnd(10) + String(a.newValue).padEnd(10) + rationale); + } + } + + lines.push(''); + } + + lines.push('─── Rationale Codes ───'); + lines.push(''); + const usedCodes = new Set(realAdjustments.map(a => a.rationale)); + for (const code of usedCodes) { + lines.push(` ${code}: ${rationaleLabel(code)}`); + } + lines.push(''); + + return lines.join('\n'); +} diff --git a/scripts/generate-card-csv.ts b/scripts/generate-card-csv.ts new file mode 100644 index 00000000..56602664 --- /dev/null +++ b/scripts/generate-card-csv.ts @@ -0,0 +1,228 @@ +/** + * Generates card-data.csv from MainStreetCards.ts template data. + * Run with: npx tsx scripts/generate-card-csv.ts [ts-file-path] + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const tsPath = process.argv[2] || resolve(process.cwd(), 'example-games/main-street/MainStreetCards.ts'); +const ts = readFileSync(tsPath, 'utf8'); + +function escapeCsvField(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return '"' + value.replace(/"/g, '""') + '"'; + } + return value; +} + +function csvRow(fields: (string | number | null | undefined)[]): string { + return fields.map(f => escapeCsvField(String(f ?? ''))).join(','); +} + +function extractArrayObjects(text: string, marker: string): string[] { + const markerPos = text.indexOf(marker); + if (markerPos === -1) return []; + // Find the first '[' that is part of the array initializer (= [...]) + const afterMarker = text.substring(markerPos); + const eqMatch = afterMarker.match(/=\s*\[/); + if (!eqMatch) return []; + const bracketPos = markerPos + eqMatch.index! + 1; // position of '[' after '=' + + // Find the matching ] for this [ + let depth = 0; + let arrayEnd = bracketPos; + for (let i = bracketPos; i < text.length; i++) { + if (text[i] === '[') depth++; + if (text[i] === ']') { depth--; if (depth === 0) { arrayEnd = i; break; } } + } + + const arrayBlock = text.substring(bracketPos, arrayEnd + 1); + + // Extract each { } object from the array block + const objects: string[] = []; + depth = 0; + let objStart = -1; + for (let i = 0; i < arrayBlock.length; i++) { + if (arrayBlock[i] === '{') { + if (depth === 0) objStart = i; + depth++; + } else if (arrayBlock[i] === '}') { + depth--; + if (depth === 0 && objStart !== -1) { + objects.push(arrayBlock.substring(objStart, i + 1)); + objStart = -1; + } + } + } + + return objects; +} + +function parseStringProp(block: string, prop: string): string | null { + // Find 'prop: ' in the block + const prefix = prop + ':'; + const idx = block.indexOf(prefix); + if (idx === -1) return null; + // Skip whitespace after colon + let pos = idx + prefix.length; + while (pos < block.length && block[pos] === ' ') pos++; + if (pos >= block.length) return null; + const quoteChar = block[pos]; + if (quoteChar !== "'" && quoteChar !== '"') return null; + // Find closing quote, handling escapes + let value = ''; + pos++; + while (pos < block.length) { + if (block[pos] === '\\' && pos + 1 < block.length) { + value += block[pos + 1]; + pos += 2; + } else if (block[pos] === quoteChar) { + break; + } else { + value += block[pos]; + pos++; + } + } + return value || null; +} + +function parseNumberProp(block: string, prop: string): number | null { + const m = block.match(new RegExp(prop + ':\\s*(-?[0-9.]+)')); + return m ? parseFloat(m[1]) : null; +} + +function parseArrayProp(block: string, prop: string): string[] { + const m = block.match(new RegExp(prop + ':\\s*\\[([^\\]]*)\\]')); + if (!m) return []; + return m[1].split(',').map(s => s.replace(/['"\s]/g, '')).filter(Boolean); +} + +// CSV columns +const COLS = [ + 'family', 'id', 'name', 'cost', 'baseIncome', 'synergyTypes', 'upgradePath', + 'maxLevel', 'reputationPerTurn', 'description', 'trigger', 'effect', 'target', + 'targetSynergy', 'coinDelta', 'reputationDelta', 'duration', 'effectType', + 'multiplier', 'targetBusiness', 'incomeBonus', 'synergyRangeBonus', + 'requiredLevel', 'reputationBonus', 'ongoingCost', 'handSlotsAdded', +]; + +function buildCsvRow(card: Record): string { + const fields: (string | number)[] = []; + for (const col of COLS) { + const v = card[col]; + fields.push(v ?? ''); + } + return csvRow(fields); +} + +// ── Parse ──────────────────────────────────────────────────── + +const businessObjs = extractArrayObjects(ts, 'BUSINESS_TEMPLATES'); +const csObjs = extractArrayObjects(ts, 'COMMUNITY_SPACE_TEMPLATES'); +const eventObjs = extractArrayObjects(ts, 'EVENT_TEMPLATES'); +const upgradeObjs = extractArrayObjects(ts, 'UPGRADE_TEMPLATES'); +const staffObjs = extractArrayObjects(ts, 'STAFF_CARD_TEMPLATES'); + +console.log(`Parsed ${businessObjs.length} business templates`); +console.log(`Parsed ${csObjs.length} community space templates`); +console.log(`Parsed ${eventObjs.length} event templates`); +console.log(`Parsed ${upgradeObjs.length} upgrade templates`); +console.log(`Parsed ${staffObjs.length} staff templates`); + +const rows: string[] = []; + +// Business +for (const obj of businessObjs) { + rows.push(buildCsvRow({ + family: 'business', + id: parseStringProp(obj, 'id') || '', + name: parseStringProp(obj, 'name') || '', + cost: parseNumberProp(obj, 'cost') ?? '', + baseIncome: parseNumberProp(obj, 'baseIncome') ?? '', + synergyTypes: parseArrayProp(obj, 'synergyTypes').join('|'), + upgradePath: parseStringProp(obj, 'upgradePath') || '', + maxLevel: parseNumberProp(obj, 'maxLevel') ?? '', + reputationPerTurn: parseNumberProp(obj, 'reputationPerTurn') ?? '', + description: parseStringProp(obj, 'description') || '', + })); +} + +// Community Space +for (const obj of csObjs) { + rows.push(buildCsvRow({ + family: 'community-space', + id: parseStringProp(obj, 'id') || '', + name: parseStringProp(obj, 'name') || '', + cost: parseNumberProp(obj, 'cost') ?? '', + baseIncome: parseNumberProp(obj, 'baseIncome') ?? '', + synergyTypes: parseArrayProp(obj, 'synergyTypes').join('|'), + upgradePath: parseStringProp(obj, 'upgradePath') || '', + maxLevel: parseNumberProp(obj, 'maxLevel') ?? '', + reputationPerTurn: parseNumberProp(obj, 'reputationPerTurn') ?? '', + description: parseStringProp(obj, 'description') || '', + })); +} + +// Event +for (const obj of eventObjs) { + rows.push(buildCsvRow({ + family: 'event', + id: parseStringProp(obj, 'id') || '', + name: parseStringProp(obj, 'name') || '', + cost: parseNumberProp(obj, 'cost') ?? '', + trigger: parseStringProp(obj, 'trigger') || '', + effect: parseStringProp(obj, 'effect') || '', + target: parseStringProp(obj, 'target') || '', + targetSynergy: parseStringProp(obj, 'targetSynergy') || '', + coinDelta: parseNumberProp(obj, 'coinDelta') ?? '', + reputationDelta: parseNumberProp(obj, 'reputationDelta') ?? '', + duration: parseNumberProp(obj, 'duration') ?? '', + effectType: parseStringProp(obj, 'effectType') || '', + multiplier: parseNumberProp(obj, 'multiplier') ?? '', + })); +} + +// Upgrade +for (const obj of upgradeObjs) { + rows.push(buildCsvRow({ + family: 'upgrade', + id: parseStringProp(obj, 'id') || '', + name: parseStringProp(obj, 'name') || '', + cost: parseNumberProp(obj, 'cost') ?? '', + targetBusiness: parseStringProp(obj, 'targetBusiness') || '', + incomeBonus: parseNumberProp(obj, 'incomeBonus') ?? '', + synergyRangeBonus: parseNumberProp(obj, 'synergyRangeBonus') ?? '', + requiredLevel: parseNumberProp(obj, 'requiredLevel') ?? '', + reputationBonus: parseNumberProp(obj, 'reputationBonus') ?? '', + description: parseStringProp(obj, 'description') || '', + })); +} + +// Staff +for (const obj of staffObjs) { + rows.push(buildCsvRow({ + family: 'staff', + id: parseStringProp(obj, 'id') || '', + name: parseStringProp(obj, 'name') || '', + cost: parseNumberProp(obj, 'cost') ?? '', + ongoingCost: parseNumberProp(obj, 'ongoingCost') ?? '', + handSlotsAdded: parseNumberProp(obj, 'handSlotsAdded') ?? '', + description: parseStringProp(obj, 'description') || '', + })); +} + +// ── Write ──────────────────────────────────────────────────── + +const csv = [COLS.join(','), ...rows].join('\n') + '\n'; +const outPath = resolve(process.cwd(), 'example-games/main-street/card-data.csv'); +writeFileSync(outPath, csv, 'utf8'); +console.log(`Wrote ${outPath} (${rows.length} data rows)`); + +// Verify specific cards +const readersCafe = rows.find(r => r.includes('readers-cafe')); +console.log('\nReader\'s Café row:'); +console.log(readersCafe || 'NOT FOUND'); + +const festival = rows.find(r => r.includes('evt-festival')); +console.log('\nLocal Festival row:'); +console.log(festival || 'NOT FOUND'); diff --git a/scripts/generate-main-street-card-svgs.mjs b/scripts/generate-main-street-card-svgs.mjs index 9e32f037..cb988760 100644 --- a/scripts/generate-main-street-card-svgs.mjs +++ b/scripts/generate-main-street-card-svgs.mjs @@ -1,41 +1,69 @@ import fs from 'fs'; import path from 'path'; -const src = fs.readFileSync(path.resolve('example-games/main-street/MainStreetCards.ts'), 'utf8'); +// --------------------------------------------------------------------------- +// Read card data from CSV (source of truth — CG-0MR6ZR23J006ZDNZ) +// --------------------------------------------------------------------------- + +function parseCsvLine(line) { + const fields = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += ch; + } + } else { + if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + } + fields.push(current); + return fields; +} -// Regex to find template objects in the file. We'll look for occurrences of "id: 'id'" and then -// extract nearby properties (name, cost, family, synergyTypes, trigger). -const idRe = /\b(id):\s*'([a-z0-9-]+)'/g; -let match; -const templates = []; +const csvPath = path.resolve('example-games/main-street/card-data.csv'); +const csvText = fs.readFileSync(csvPath, 'utf8'); +const lines = csvText.trim().split('\n'); +const headers = parseCsvLine(lines[0]); -while ((match = idRe.exec(src)) !== null) { - const idx = match.index; - // find the enclosing object braces by scanning backwards to previous '{' and forward to next '}' - const before = src.lastIndexOf('{', idx); - const after = src.indexOf('}', idx); - if (before === -1 || after === -1) continue; - const block = src.substring(before, after + 1); - - const id = match[2]; - const nameMatch = block.match(/name:\s*'([^']+)'/); - const costMatch = block.match(/cost:\s*([0-9]+)/); - const familyMatch = block.match(/family:\s*'([^']+)'/); - const triggerMatch = block.match(/trigger:\s*'([^']+)'/); - const synergyMatch = block.match(/synergyTypes:\s*\[([^\]]*)\]/); - - const name = nameMatch ? nameMatch[1] : id; - const cost = costMatch ? Number(costMatch[1]) : null; - const family = familyMatch ? familyMatch[1] : (id.startsWith('biz-') ? 'business' : id.startsWith('evt-') ? 'event' : 'upgrade'); - const trigger = triggerMatch ? triggerMatch[1] : null; - let synergies = []; - if (synergyMatch) { - synergies = synergyMatch[1].split(',').map(s => s.replace(/['"\s]/g, '')).filter(Boolean); +const templates = []; +for (let i = 1; i < lines.length; i++) { + const values = parseCsvLine(lines[i]); + const card = {}; + for (let j = 0; j < headers.length; j++) { + card[headers[j]] = values[j] !== undefined ? values[j] : ''; } - templates.push({ id, name, cost, family, trigger, synergies }); + // Determine family from prefix if not in CSV + const family = card.family || (card.id.startsWith('biz-') ? 'business' : card.id.startsWith('evt-') ? 'event' : card.id.startsWith('cs-') ? 'community-space' : card.id.startsWith('staff-') ? 'staff' : 'upgrade'); + const synergies = card.synergyTypes ? card.synergyTypes.split('|').filter(Boolean) : []; + const cost = card.cost ? Number(card.cost) : null; + const trigger = card.trigger || null; + + templates.push({ id: card.id, name: card.name, cost, family, trigger, synergies }); } +// --------------------------------------------------------------------------- +// SVG generation (unchanged) +// --------------------------------------------------------------------------- + // Color map const synergyColor = { Food: '#E67E22', @@ -50,6 +78,8 @@ function familyColor(family, trigger) { if (family === 'business') return '#2f2f2f'; if (family === 'upgrade') return '#6B4C9A'; if (family === 'event') return trigger === 'Incident' ? '#2B3A67' : '#8B4513'; + if (family === 'community-space') return '#2f2f2f'; + if (family === 'staff') return '#555555'; return '#333333'; } @@ -87,10 +117,10 @@ for (const t of templates) { } } - const priceBadge = displayCost + const priceBadge = displayCost ? `` : ''; - const priceText = displayCost + const priceText = displayCost ? `${t.cost}` : ''; @@ -100,4 +130,4 @@ for (const t of templates) { fs.writeFileSync(outPath, svg, 'utf8'); } -console.log('Generated', templates.length, 'card SVGs into', outDir); \ No newline at end of file +console.log('Generated', templates.length, 'card SVGs into', outDir); diff --git a/scripts/generate-mind-cards.ts b/scripts/generate-mind-cards.ts deleted file mode 100644 index 9e64373f..00000000 --- a/scripts/generate-mind-cards.ts +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * generate-mind-cards.ts - * - * Generates 101 SVG card images for The Mind: - * - 100 numbered cards (values 1-100) - * - 1 card back - * - * Output: public/assets/cards/the-mind/{assetKey}.svg - * Card size: 140x190px - * - * Usage: - * npx tsx scripts/generate-mind-cards.ts - */ - -import { mkdirSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// ── Constants ────────────────────────────────────────────── - -const CARD_W = 140; -const CARD_H = 190; -const CORNER_R = 10; -const OUT_DIR = join(__dirname, '..', 'public', 'assets', 'cards', 'the-mind'); - -/** Card face background — deep teal/blue-green. */ -const BG_COLOR = '#1a2a3a'; - -/** Accent color — warm gold for borders and decoration. */ -const ACCENT_COLOR = '#d4a843'; - -/** Number text color — bright white for legibility. */ -const TEXT_COLOR = '#ffffff'; - -/** Card back background. */ -const BACK_BG = '#1a2a3a'; - -/** Card back accent. */ -const BACK_ACCENT = '#d4a843'; - -// ── SVG template helpers ─────────────────────────────────── - -function svgHeader(): string { - return ``; -} - -function cardBackground(fill: string): string { - return ` `; -} - -function cardBorder(stroke: string): string { - return ` `; -} - -function innerFrame(stroke: string): string { - return ` `; -} - -// ── Card generators ──────────────────────────────────────── - -/** - * Generate a numbered Mind card (1-100). - * - * Layout: - * - Dark background with gold border and inner frame - * - Large centered number for quick readability - * - Small corner numbers (top-left, bottom-right rotated) - * - Subtle concentric circle decoration behind the number - */ -function generateNumberedCard(value: number): string { - // Scale font size: 1-digit (56px), 2-digit (52px), 3-digit (44px) - const digits = String(value).length; - const mainFontSize = digits === 1 ? 56 : digits === 2 ? 52 : 44; - const cornerFontSize = 18; - - // Subtle radial decoration behind the number - const decoration = ` - `; - - // Corner numbers - const corners = ` ${value} - ${value}`; - - // Main centered number - const mainNumber = ` ${value}`; - - return `${svgHeader()} -${cardBackground(BG_COLOR)} -${cardBorder(ACCENT_COLOR)} -${innerFrame(ACCENT_COLOR)} -${decoration} -${corners} -${mainNumber} -`; -} - -/** - * Generate the Mind card back. - * - * Design: Dark background with gold accents, concentric circles, - * and a central "?" symbol representing the unknown. - */ -function generateCardBack(): string { - // Radiating lines pattern - const lines: string[] = []; - for (let angle = 0; angle < 360; angle += 30) { - const rad = (angle * Math.PI) / 180; - const cx = CARD_W / 2; - const cy = CARD_H / 2; - const x1 = cx + Math.cos(rad) * 20; - const y1 = cy + Math.sin(rad) * 20; - const x2 = cx + Math.cos(rad) * 55; - const y2 = cy + Math.sin(rad) * 55; - lines.push( - ` `, - ); - } - - return `${svgHeader()} -${cardBackground(BACK_BG)} -${cardBorder(BACK_ACCENT)} - -${lines.join('\n')} - - - ? -`; -} - -// ── Main ─────────────────────────────────────────────────── - -function main(): void { - // Ensure output directory exists - mkdirSync(OUT_DIR, { recursive: true }); - - let count = 0; - - // Generate numbered cards (1-100) - for (let value = 1; value <= 100; value++) { - const key = `mind-${value}`; - const svg = generateNumberedCard(value); - writeFileSync(join(OUT_DIR, `${key}.svg`), svg); - count++; - } - - // Generate card back (1 card) - const backSvg = generateCardBack(); - writeFileSync(join(OUT_DIR, 'mind-back.svg'), backSvg); - count++; - - console.log(`Generated ${count} SVG card images in ${OUT_DIR}`); -} - -main(); diff --git a/scripts/generate-mind-fixture-transcript.ts b/scripts/generate-mind-fixture-transcript.ts deleted file mode 100644 index 3c9c0a2d..00000000 --- a/scripts/generate-mind-fixture-transcript.ts +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env node -/** - * Generate a deterministic fixture transcript for The Mind replay testing. - * - * Runs a headless AI-vs-AI game using the `runGame()` runner and writes - * the resulting transcript JSON to - * tests/fixtures/transcripts/the-mind/fixture-game.json. - * - * Usage: - * npx tsx scripts/generate-mind-fixture-transcript.ts - */ - -import { runGame } from '../example-games/the-mind/headlessGame'; -import { writeFileSync, mkdirSync } from 'fs'; -import { dirname, resolve } from 'path'; - -const result = runGame({ - seed: 42, - player0AiSeed: 43, - player1AiSeed: 44, - playerNames: ['AI-0', 'AI-1'], -}); - -const transcript = result.transcript; - -// Override timestamps for reproducibility -transcript.startedAt = '2026-01-01T00:00:00.000Z'; -transcript.endedAt = '2026-01-01T00:10:00.000Z'; - -const outPath = resolve('tests/fixtures/transcripts/the-mind/fixture-game.json'); -mkdirSync(dirname(outPath), { recursive: true }); -writeFileSync(outPath, JSON.stringify(transcript, null, 2) + '\n'); - -console.log(`Fixture transcript written to ${outPath}`); -console.log(` Version: ${transcript.version}`); -console.log(` Events: ${transcript.events.length}`); -console.log(` Outcome: ${result.outcome}`); -console.log(` Final level: ${result.finalLevel}`); -console.log(` Final lives: ${result.finalLives}`); -console.log(` Total plays: ${result.totalPlays}`); -console.log(` Total penalties: ${result.totalPenalties}`); diff --git a/scripts/run-balance-cards.ts b/scripts/run-balance-cards.ts new file mode 100644 index 00000000..e809e3d0 --- /dev/null +++ b/scripts/run-balance-cards.ts @@ -0,0 +1,88 @@ +/** + * balance-cards.ts — CLI entry point for the Main Street card balancing tool. + * + * Usage: npx tsx scripts/balance-cards.ts [--input ] [--output ] + * + * Reads the Main Street card CSV, performs a hybrid curve-fitting + tier-band + * balancing pass on all 5 card families, prints a summary table to stdout, + * and writes the balanced CSV to the output path (defaults to overwriting + * the input; the original is preserved via rotating backups). + */ + +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; + +import { + readCsvFile, + writeCsvFile, + rotateBackups, + validateCsvRows, + runBalancingPass, + formatSummaryTable, +} from './balance-cards'; + +// ── Default paths ───────────────────────────────────────────────────── + +const DEFAULT_INPUT = resolve(process.cwd(), 'example-games/main-street/card-data.csv'); +const DEFAULT_OUTPUT = DEFAULT_INPUT; + +// ── Argument parsing ────────────────────────────────────────────────── + +function parseArgs(): { input: string; output: string } { + const args = process.argv.slice(2); + let input = DEFAULT_INPUT; + let output = DEFAULT_OUTPUT; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--input' && i + 1 < args.length) { + input = resolve(process.cwd(), args[++i]); + } else if (args[i] === '--output' && i + 1 < args.length) { + output = resolve(process.cwd(), args[++i]); + } else if (args[i] === '--help') { + console.log('Usage: npx tsx scripts/balance-cards.ts [--input ] [--output ]'); + process.exit(0); + } + } + + return { input, output }; +} + +// ── Main ────────────────────────────────────────────────────────────── + +function main(): void { + const { input, output } = parseArgs(); + + // Verify input exists + if (!existsSync(input)) { + console.error(`Error: Input CSV not found: ${input}`); + process.exit(1); + } + + // Read and validate CSV + console.error(`Reading: ${input}`); + const rows = readCsvFile(input); + validateCsvRows(rows); + console.error(`Validated ${rows.length} card rows`); + + // Run balancing pass + console.error('Running balancing pass...'); + const result = runBalancingPass(rows); + + // Create rotating backups before writing + console.error('Creating rotating backups...'); + rotateBackups(output); + + // Write the balanced CSV + writeCsvFile(output, result.rows); + console.error(`Written: ${output}`); + + // Print summary table to stdout + const summary = formatSummaryTable( + result.adjustments, + result.summaries, + result.rows.length, + ); + console.log(summary); +} + +main(); diff --git a/src/core-engine/ActiveEffect.ts b/src/core-engine/ActiveEffect.ts index 9d0f8212..c50d2acc 100644 --- a/src/core-engine/ActiveEffect.ts +++ b/src/core-engine/ActiveEffect.ts @@ -114,7 +114,11 @@ export function decayActiveEffects(effects: ActiveEffect[]): DecayResult { * @param effects Array of active effects to check. * @param effectType The type of effects to apply. * @param baseValue The value to apply multipliers to. - * @returns The modified value (rounded to nearest integer). + * @returns The modified value (may be fractional). + * + * CG-0MRER3RE300418SG: Removed Math.round to preserve fractional income + * values instead of rounding to nearest integer. The coins field (number) + * handles fractional values correctly; all comparisons work with fractions. */ export function applyActiveEffectMultiplier( effects: ActiveEffect[], @@ -127,7 +131,7 @@ export function applyActiveEffectMultiplier( multiplier *= effect.multiplier; } } - return Math.round(baseValue * multiplier); + return baseValue * multiplier; } // ── Type Check ────────────────────────────────────────────── diff --git a/src/core-engine/CsvLoader.ts b/src/core-engine/CsvLoader.ts new file mode 100644 index 00000000..7fd0ce83 --- /dev/null +++ b/src/core-engine/CsvLoader.ts @@ -0,0 +1,134 @@ +/** + * Lightweight CSV parser — build-time data loading utility. + * + * Parses a CSV string into an array of objects, one per data row. + * The first row is treated as the header. Quoted fields (double-quoted + * with internal commas, newlines, or escaped quotes) are supported. + * + * This is used at module load time to import CSV-based game data that + * is bundled via Vite's `?raw` import suffix. + * + * @module + */ + +/** + * Parse a CSV string into an array of string-keyed row objects. + * + * @param csv The full CSV text (header + data rows, newline-separated). + * @param headers Optional pre-defined header array. If omitted, the first + * row of the CSV is used as the header. + * @returns Array of objects, one per data row. + */ +export function parseCsv(csv: string, headers?: string[]): Record[] { + if (!csv || csv.trim() === '') return []; + const lines = splitLines(csv); + const headerLine = headers ?? lines[0]; + const cols = Array.isArray(headerLine) ? headerLine : parseLine(headerLine); + const rows: Record[] = []; + + for (let i = (headers ? 0 : 1); i < lines.length; i++) { + const line = lines[i].trim(); + if (line === '') continue; // skip blank lines + const values = parseLine(line); + const obj: Record = {}; + for (let j = 0; j < cols.length; j++) { + obj[cols[j]] = values[j] !== undefined ? values[j].trim() : ''; + } + rows.push(obj); + } + + return rows; +} + +/** + * Split a CSV string into individual lines, handling quoted newlines. + * Within double-quoted fields, literal newlines are preserved and the + * field is treated as a single logical line. + */ +function splitLines(csv: string): string[] { + const lines: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < csv.length; i++) { + const ch = csv[i]; + + if (ch === '"' && !inQuotes) { + inQuotes = true; + current += ch; + } else if (ch === '"' && inQuotes) { + // Check for escaped quote "" + if (csv[i + 1] === '"') { + current += '""'; + i++; + } else { + inQuotes = false; + current += ch; + } + } else if (ch === '\n' && !inQuotes) { + lines.push(current); + current = ''; + } else if (ch === '\r' && !inQuotes) { + // Skip \r\n — the \n will handle the line break + if (csv[i + 1] !== '\n') { + current += ch; + } + } else { + current += ch; + } + } + + // Push the last line (no trailing newline) + if (current !== '') { + lines.push(current); + } + + return lines; +} + +/** + * Parse a single CSV line into an array of field values. + * Handles: + * - Quoted fields (double-quoted with escaped inner quotes) + * - Fields with embedded commas and newlines + * - Empty fields + * - Escaped quotes (double-double-quote "") + */ +function parseLine(line: string): string[] { + const fields: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + + if (inQuotes) { + if (ch === '"') { + // Check for escaped quote "" + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + // End of quoted field + inQuotes = false; + } + } else { + current += ch; + } + } else { + if (ch === '"') { + inQuotes = true; + } else if (ch === ',') { + fields.push(current); + current = ''; + } else { + current += ch; + } + } + } + + // Push the last field + fields.push(current); + + return fields; +} diff --git a/src/core-engine/index.ts b/src/core-engine/index.ts index 855579dd..6cd7a48a 100644 --- a/src/core-engine/index.ts +++ b/src/core-engine/index.ts @@ -191,6 +191,9 @@ export { computeAdjacencyBonus, } from './SpatialRules'; +// CSV data loader (CG-0MR6ZR23J006ZDNZ) +export { parseCsv } from './CsvLoader'; + // Shared SVG rasterisation helpers (CG-0MOZNXU4Y0043NR3) export { markSceneValid, diff --git a/src/ui/GameOverOverlay.ts b/src/ui/GameOverOverlay.ts new file mode 100644 index 00000000..142c383f --- /dev/null +++ b/src/ui/GameOverOverlay.ts @@ -0,0 +1,319 @@ +/** + * Shared GameOverOverlay component for the Tableau Card Engine. + * + * Provides a consistent game-over overlay that all games can use, + * with a semi-transparent full-screen backdrop, title, auto-scaling + * summary text, optional extra buttons, and bottom-row buttons + * ([Play Again] and [Menu]). + */ + +import { GAME_W, GAME_H, FONT_FAMILY } from './constants'; +import { + createOverlayBackground, + dismissOverlay, + type OverlayBackgroundOptions, + type OverlayBoxOptions, +} from './Overlay'; +import { createOverlayButton } from './OverlayButton'; + +// ── Types ─────────────────────────────────────────────────── + +/** A game-specific button shown above the bottom button row. */ +export interface GameOverExtraButton { + readonly label: string; + readonly onClick: () => void; +} + +/** Configuration for creating a shared game-over overlay. */ +export interface GameOverOverlayConfig { + /** + * Game-specific summary text displayed in the text area. + * Supports newline-separated lines. + */ + readonly summaryText: string; + + /** Callback when the [Play Again] button is clicked. */ + readonly onPlayAgain: () => void; + + /** + * Callback when the [Menu] button is clicked. + * Defaults to navigating to `GameSelectorScene`. + */ + readonly onMenu?: () => void; + + /** + * Optional extra buttons shown in a row above the bottom + * button row (e.g., [Share Score], [Save Replay]). + */ + readonly extraButtons?: ReadonlyArray; + + /** Label for the Play Again button (default: 'Play Again'). */ + readonly playAgainLabel?: string; + + /** Label for the Menu button (default: 'Menu'). */ + readonly menuLabel?: string; + + /** Custom title text shown at the top (default: 'Game Over'). */ + readonly title?: string; + + /** Title text color (default: '#ffcc88'). */ + readonly titleColor?: string; + + /** Override the overlay background options. */ + readonly background?: OverlayBackgroundOptions; + + /** Override the overlay box options. */ + readonly box?: OverlayBoxOptions; +} + +/** Result of creating a game-over overlay. */ +export interface GameOverOverlayResult { + /** The full-screen input-blocking background. */ + readonly background: Phaser.GameObjects.Rectangle; + /** The visible overlay box, if created. */ + readonly box: Phaser.GameObjects.Rectangle | null; + /** The title text element. */ + readonly title: Phaser.GameObjects.Text; + /** The summary text element (auto-scaling). */ + readonly summary: Phaser.GameObjects.Text; + /** All created game objects for lifecycle management. */ + readonly objects: Phaser.GameObjects.GameObject[]; + /** Destroy all objects and dismiss the overlay. */ + readonly dismiss: () => void; +} + +// ── Default layout constants ─────────────────────────────── + +const DEFAULT_BOX_WIDTH = 540; +const DEFAULT_BOX_HEIGHT = 420; +const DEFAULT_TITLE_COLOR = '#ffcc88'; +const DEFAULT_SUMMARY_COLOR = '#ffffff'; +const DEFAULT_BUTTON_DEPTH = 11; +const DEFAULT_TITLE_DEPTH = 11; +const DEFAULT_SUMMARY_DEPTH = 11; + +/** Padding from the box top to the title. */ +const TITLE_TOP_PADDING = 30; +/** Font size of the title. */ +const TITLE_FONT_SIZE = '28px'; +/** Maximum font size for summary text. */ +const SUMMARY_FONT_MAX = 22; +/** Minimum font size for summary text. */ +const SUMMARY_FONT_MIN = 12; + +/** Height reserved for the extra buttons row (including padding around it). */ +const EXTRA_ROW_HEIGHT = 50; +/** Gap between the title and the summary text area. */ +const TITLE_SUMMARY_GAP = 8; +/** Gap between the summary text area and the buttons. */ +const SUMMARY_BUTTONS_GAP = 8; +/** Padding inside the box from left/right edges. */ +const BOX_HORIZONTAL_PADDING = 30; + +// ── Factory ───────────────────────────────────────────────── + +/** + * Create a shared game-over overlay with: + * - Full-screen semi-transparent backdrop + * - Centered overlay box + * - Title (default: "Game Over") + * - Auto-scaling summary text area + * - Optional extra button row (game-specific actions) + * - Bottom button row ([Play Again] and [Menu]) + * + * @param scene - The Phaser scene to add the overlay to. + * @param config - Configuration for the game-over overlay. + * @returns A GameOverOverlayResult with all created objects. + */ +export function createGameOverOverlay( + scene: Phaser.Scene, + config: GameOverOverlayConfig, +): GameOverOverlayResult { + const title = config.title ?? 'Game Over'; + const titleColor = config.titleColor ?? DEFAULT_TITLE_COLOR; + const playAgainLabel = config.playAgainLabel ?? 'Play Again'; + const menuLabel = config.menuLabel ?? 'Menu'; + const extraButtons = config.extraButtons ?? []; + + // Overlay background + const boxWidth = config.box?.width ?? DEFAULT_BOX_WIDTH; + const boxHeight = config.box?.height ?? DEFAULT_BOX_HEIGHT; + + const overlay = createOverlayBackground( + scene, + config.background ?? { depth: 10, alpha: 0.75 }, + config.box ?? { width: boxWidth, height: boxHeight, alpha: 0.9 }, + ); + + const objects: Phaser.GameObjects.GameObject[] = [...overlay.objects]; + const boxCenterX = GAME_W / 2; + const boxTop = (GAME_H / 2) - (boxHeight / 2); + + // Track whether we have extra buttons to know if we need the extra row + const hasExtraButtons = extraButtons.length > 0; + + // ── Title ────────────────────────────────────────────────── + const titleObj = scene.add + .text(boxCenterX, boxTop + TITLE_TOP_PADDING, title, { + fontSize: TITLE_FONT_SIZE, + color: titleColor, + fontFamily: FONT_FAMILY, + }) + .setOrigin(0.5) + .setDepth(DEFAULT_TITLE_DEPTH); + objects.push(titleObj); + + // ── Calculate layout positions ───────────────────────────── + const titleBottom = boxTop + TITLE_TOP_PADDING + 34; // approx title height + const contentAreaTop = titleBottom + TITLE_SUMMARY_GAP; + + // Bottom buttons Y: bottom of box - padding + const bottomButtonsY = boxTop + boxHeight - 30; + + // Extra buttons Y: above bottom buttons + const extraButtonsY = bottomButtonsY - (hasExtraButtons ? EXTRA_ROW_HEIGHT : 0); + + // Summary text area bottom (above buttons) + const summaryAreaBottom = extraButtonsY - SUMMARY_BUTTONS_GAP; + const summaryAreaTop = contentAreaTop; + const summaryAvailableHeight = summaryAreaBottom - summaryAreaTop; + const summaryMaxWidth = boxWidth - (BOX_HORIZONTAL_PADDING * 2); + + // ── Summary text with auto-scaling font ──────────────────── + const summaryFontSize = computeAutoScaleFontSize( + config.summaryText, + summaryMaxWidth, + summaryAvailableHeight, + ); + + const summaryObj = scene.add + .text(boxCenterX, summaryAreaTop, config.summaryText, { + fontSize: `${summaryFontSize}px`, + color: DEFAULT_SUMMARY_COLOR, + fontFamily: FONT_FAMILY, + align: 'center', + wordWrap: { width: summaryMaxWidth, useAdvancedWrap: true }, + }) + .setOrigin(0.5, 0) + .setDepth(DEFAULT_SUMMARY_DEPTH); + objects.push(summaryObj); + + // ── Extra buttons (game-specific, optional) ──────────────── + if (hasExtraButtons) { + const extraBtnSpacing = Math.min(boxWidth / (extraButtons.length + 1), 160); + const extraStartX = boxCenterX - ((extraButtons.length - 1) * extraBtnSpacing) / 2; + + extraButtons.forEach((btn, idx) => { + const btnX = extraStartX + idx * extraBtnSpacing; + const btnObj = createOverlayButton( + scene, + btnX, + extraButtonsY, + btn.label, + DEFAULT_BUTTON_DEPTH, + ); + btnObj.on('pointerdown', btn.onClick); + objects.push(btnObj); + }); + } + + // ── Bottom row: [Play Again] and [Menu] ─────────────────── + const bottomBtnSpacing = 180; + const playAgainX = boxCenterX - bottomBtnSpacing / 2; + const menuX = boxCenterX + bottomBtnSpacing / 2; + + const playAgainBtn = createOverlayButton( + scene, + playAgainX, + bottomButtonsY, + `[ ${playAgainLabel} ]`, + DEFAULT_BUTTON_DEPTH, + ); + playAgainBtn.on('pointerdown', config.onPlayAgain); + objects.push(playAgainBtn); + + const menuCallback = config.onMenu ?? (() => { + scene.scene.start('GameSelectorScene'); + }); + const menuBtn = createOverlayButton( + scene, + menuX, + bottomButtonsY, + `[ ${menuLabel} ]`, + DEFAULT_BUTTON_DEPTH, + ); + menuBtn.on('pointerdown', menuCallback); + objects.push(menuBtn); + + // ── Result ───────────────────────────────────────────────── + return { + background: overlay.background, + box: overlay.box, + title: titleObj, + summary: summaryObj, + objects, + dismiss: () => dismissOverlay(objects), + }; +} + +// ── Helpers ───────────────────────────────────────────────── + +/** + * Compute an optimal font size for summary text so that it fits + * within the available width and height. + * + * Uses a binary search between SUMMARY_FONT_MIN and + * SUMMARY_FONT_MAX to find the largest font size where the + * estimated text height fits within the available space. + * + * Estimation assumes each line is approximately lineHeight + * tall, and that word-wrapping may occur. + * + * @param text - The summary text to display. + * @param maxWidth - Maximum width available for the text. + * @param maxHeight - Maximum height available for the text. + * @returns Optimal font size in pixels. + */ +export function computeAutoScaleFontSize( + text: string, + maxWidth: number, + maxHeight: number, +): number { + // Estimate average character width at a given font size + const avgCharWidthRatio = 0.6; // approximate ratio of font size to char width + const lineHeightRatio = 1.4; // approximate line height ratio + + if (!text || maxHeight <= 0) { + return SUMMARY_FONT_MIN; + } + + // Binary search for the optimal font size + let low = SUMMARY_FONT_MIN; + let high = SUMMARY_FONT_MAX; + let best = SUMMARY_FONT_MIN; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const charWidth = mid * avgCharWidthRatio; + const lineHeight = mid * lineHeightRatio; + + // Estimate lines needed + const charsPerLine = Math.max(1, Math.floor(maxWidth / charWidth)); + const lines = text.split('\n').reduce((total, paragraph) => { + // Wrap long paragraphs + const wrappedLines = Math.max(1, Math.ceil(paragraph.length / charsPerLine)); + return total + wrappedLines; + }, 0); + + const estimatedHeight = lines * lineHeight; + + if (estimatedHeight <= maxHeight) { + best = mid; + low = mid + 1; // try larger + } else { + high = mid - 1; // try smaller + } + } + + return best; +} diff --git a/src/ui/HandView.ts b/src/ui/HandView.ts index d8dd4765..83ba9a5e 100644 --- a/src/ui/HandView.ts +++ b/src/ui/HandView.ts @@ -23,10 +23,10 @@ import { GameEventEmitter } from '../core-engine'; * Custom card texture resolver for non-standard card models. * * Used by {@link HandView} when the card type does not have `rank`/`suit` - * properties (e.g. The Mind's `MindCard` with a numeric `value`). + * properties (e.g. a custom card with a numeric `value`). * * The `card` parameter is typed as `any` to allow resolvers for arbitrary - * card-like types (MindCard, etc.) without requiring casts at the call site. + * card-like types without requiring casts at the call site. * * @param card - The card object to resolve a texture for. * @param index - The card's index in the hand (useful for back-face cards). @@ -138,7 +138,7 @@ export interface HandViewOptions { layoutDirection?: 'horizontal' | 'vertical'; /** - * Custom texture resolver for non-standard card models (e.g. MindCard + * Custom texture resolver for non-standard card models (e.g. a card * with numeric `value` instead of `rank`/`suit`). When provided, * this function is called instead of `getCardTexture()` to determine * the texture key for each card. @@ -275,6 +275,24 @@ export interface RemoveCardOptions { duration?: number; } +/** + * Animation options for {@link HandView.sortCards}. + * + * When `animate: true`, existing card sprites tween smoothly from their + * current positions to their new sorted positions instead of being + * destroyed and recreated. + */ +export interface AnimatedSortOptions { + /** Whether to animate card positions. @default false */ + animate?: boolean; + + /** Duration in ms for the sort animation. @default 300 */ + duration?: number; + + /** Easing function string for the tween. @default 'Quad.easeOut' */ + ease?: string; +} + /** Source range for a drag operation (inclusive card indices). */ export interface DragSourceRange { from: number; @@ -403,7 +421,7 @@ export class HandView { // Display objects private sprites: Phaser.GameObjects.GameObject[] = []; private labels: Phaser.GameObjects.Text[] = []; - /** Custom texture function (used for non-standard card models like MindCard). */ + /** Custom texture function for non-standard card models. */ private _customTextureFn: CardTextureResolver | undefined; /** Custom card renderer (used for non-standard card visuals). */ private _renderCardFn: RenderCardFn | undefined; @@ -483,7 +501,7 @@ export class HandView { /** * Update the custom texture resolver at runtime (e.g. when switching - * from standard cards to MindCard rendering mid-game). + * from standard cards to a custom card model mid-game). */ setCardTextureFn(fn: CardTextureResolver): void { this._customTextureFn = fn; @@ -657,6 +675,11 @@ export class HandView { * Sort the hand cards in-place using the provided comparison function, * then rebuild the display to reflect the new order. * + * When `opts.animate` is `true`, existing card sprites tween smoothly + * from their current positions to their new sorted positions instead of + * being destroyed and recreated. When `opts.animate` is `false` or + * omitted, the current snap-behaviour is preserved (backwards compatible). + * * Clears the current selection. * * @param compareFn - A comparison function following the same contract as @@ -664,12 +687,20 @@ export class HandView { * returns a negative number if `a` should come before `b`, * a positive number if `a` should come after `b`, or 0 if * they are considered equal. + * @param opts - Optional animation configuration. * * @example * ```ts - * // Sort by rank ascending + * // Snap sort (default, backwards compatible) * handView.sortCards((a, b) => a.rank - b.rank); * + * // Animated sort with custom duration and easing + * handView.sortCards((a, b) => a.rank - b.rank, { + * animate: true, + * duration: 400, + * ease: 'Quad.easeOut', + * }); + * * // Sort by suit then rank * handView.sortCards((a, b) => { * if (a.suit !== b.suit) return a.suit.localeCompare(b.suit); @@ -677,13 +708,135 @@ export class HandView { * }); * ``` */ - sortCards(compareFn: (a: Card, b: Card) => number): void { + sortCards( + compareFn: (a: Card, b: Card) => number, + opts?: AnimatedSortOptions, + ): void { + const animate = opts?.animate ?? false; + const duration = opts?.duration ?? 300; + const ease = opts?.ease ?? 'Quad.easeOut'; + + if (!animate || this._reducedMotion || this.sprites.length === 0) { + // Non-animated path: existing snap behaviour. + this.cards.sort(compareFn); + this.selectedIndex = null; + this.rebuildDisplay(); + this.emit('selectionchange', this.selectedIndex); + return; + } + + // ── Animated sort path ────────────────────────────────────── + + // 1. Before sorting, record which sprite index each card currently occupies. + // This mapping uses object references so it remains valid after sorting. + const spriteIndexForCard = new Map(); + for (let i = 0; i < this.sprites.length; i++) { + spriteIndexForCard.set(this.cards[i], i); + } + + // 2. Sort the cards array. this.cards.sort(compareFn); this.selectedIndex = null; - this.rebuildDisplay(); + + // 3. Reorder the sprites array to match the new card order. + // The sprites retain their current (old) positions at this point; + // tweens will animate them to the computed new positions below. + this.reorderSpritesForNewCardOrder(spriteIndexForCard); + + // 4. Compute new target positions. + const newPositions = this.computeCardPositions(); + + // Precompute rotation helpers (mirrors applyLayout logic). + let arcCenterX = 0; + let halfSpan = 1; + if (this.layoutDirection === 'horizontal' && newPositions.length >= 2) { + const firstX = newPositions[0].x; + const lastX = newPositions[newPositions.length - 1].x; + arcCenterX = (firstX + lastX) / 2; + halfSpan = Math.max((lastX - firstX) / 2, 1); + } + + // 5. Update z-ordering to match the sorted card order. + // Sprites later in the array render on top. + for (let i = 0; i < this.sprites.length; i++) { + const sprite = this.sprites[i]; + if (typeof (sprite as any).setDepth === 'function') { + (sprite as any).setDepth(i); + } + } + + // 6. Animate each sprite from its old position and rotation to new. + for (let i = 0; i < this.sprites.length && i < newPositions.length; i++) { + const sprite = this.sprites[i]; + const target = newPositions[i]; + + // Compute target rotation (same formula as applyLayout). + let targetRotation = 0; + if (this.layoutDirection === 'horizontal' && this.maxRotationDegrees !== 0) { + const normalized = (target.x - arcCenterX) / halfSpan; + targetRotation = (this.maxRotationDegrees * normalized * Math.PI) / 180; + } + + // Skip sprites whose position and rotation haven't changed. + if ( + Math.abs((sprite as any).x - target.x) < 0.5 && + Math.abs((sprite as any).y - target.y) < 0.5 && + Math.abs((sprite as any).rotation - targetRotation) < 0.005 + ) { + continue; + } + + this.scene.tweens.add({ + targets: sprite as any, + x: target.x, + y: target.y, + rotation: targetRotation, + duration, + ease, + }); + } + + // 7. Emit selection change immediately (selection is cleared). this.emit('selectionchange', this.selectedIndex); } + /** + * Reorder the internal `sprites` array so that it matches the order of + * the (already sorted) `cards` array. + * + * This method uses the pre-built mapping (card → original sprite index) + * captured before sorting. It does NOT destroy or recreate any display + * objects — only the array order is changed. + * + * @param spriteIndexForCard - Map from card reference to its original + * position in the sprites array (built + * before sorting the cards array). + */ + private reorderSpritesForNewCardOrder( + spriteIndexForCard: Map, + ): void { + const n = this.cards.length; + if (n !== this.sprites.length) { + // Count mismatch — rebuild to stay safe. + this.rebuildDisplay(); + return; + } + + // For each position in the sorted cards array, look up the original + // sprite index for that card and place the sprite at the new position. + const reordered: Phaser.GameObjects.GameObject[] = new Array(n); + for (let i = 0; i < n; i++) { + const originalIndex = spriteIndexForCard.get(this.cards[i]); + if (originalIndex === undefined) { + // Card not found in mapping — fall back to rebuild. + this.rebuildDisplay(); + return; + } + reordered[i] = this.sprites[originalIndex]; + } + this.sprites = reordered; + } + /** * Set the selected card index. * diff --git a/src/ui/Renderer/adapters/MindAdapter.ts b/src/ui/Renderer/adapters/MindAdapter.ts deleted file mode 100644 index e7d64038..00000000 --- a/src/ui/Renderer/adapters/MindAdapter.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * The Mind Adapter – bridges The Mind scene code to the shared Renderer API. - * - * This module re-exports shared Renderer helpers with The Mind–specific - * defaults so that MindRenderer can use engine-standard patterns for HUD - * text, containers, and card rendering without duplicating styling logic. - * - * @module MindAdapter - */ - -import Phaser from 'phaser'; -import { - createHudText, - createHudContainer, - renderCardSvg, - type HudTextOptions, - type RenderCardSvgOptions, -} from '../index'; -import { FONT_FAMILY } from '../../../ui/constants'; - -/** Default depth for UI elements in The Mind (matches MindConstants.DEPTH_UI). */ -const MIND_DEPTH_UI = 5; - -// Re-export shared helpers so callers can import from a single adapter module. -export { createHudContainer, renderCardSvg }; -export type { HudTextOptions, RenderCardSvgOptions }; - -/** - * Create a status-display HUD text element styled for The Mind. - * - * This is a thin wrapper around `createHudText` that applies The Mind's - * default font family, depth, and origin conventions so that MindRenderer - * can create level/lives text without repeating styling parameters. - * - * @param scene - The Phaser scene. - * @param x - X position (right-aligned by convention). - * @param y - Y position. - * @param text - Initial text content. - * @param color - CSS colour string. - * @param options - Optional overrides (font size, origin, etc.). - * @returns A Phaser.Text object with depth DEPTH_UI. - */ -export function createMindHudText( - scene: Phaser.Scene, - x: number, - y: number, - text: string, - color: string, - options?: { fontSize?: string } & HudTextOptions, -): Phaser.GameObjects.Text { - const textObj = createHudText(scene, x, y, text, color, { - fontFamily: FONT_FAMILY, - originX: 0.5, - originY: 0, - ...options, - }); - try { - textObj.setDepth(MIND_DEPTH_UI); - } catch { - // Depth may not be available in headless / test environments. - } - return textObj; -} - -/** - * Create a card-rendering helper pre-configured for The Mind. - * - * Wraps the shared `renderCardSvg` with The Mind's default dimensions - * and fallback styling so that card rendering callers don't need to - * repeat configuration. - * - * @param scene - The Phaser scene. - * @param parentContainer - Container to add the card to. - * @param templateId - Logical identifier for the card template. - * @param options - Optional overrides. - * @returns The created game object. - */ -export function mindRenderCardSvg( - scene: Phaser.Scene, - parentContainer: Phaser.GameObjects.Container, - templateId: string, - options?: RenderCardSvgOptions, -): Phaser.GameObjects.Image | Phaser.GameObjects.Rectangle { - return renderCardSvg(scene, parentContainer, templateId, 120, 164, options); -} - -export const MIND_ADAPTER_VERSION = '1.0.0'; diff --git a/src/ui/index.ts b/src/ui/index.ts index 4ec5f778..8a0ed3c7 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -179,6 +179,16 @@ export type { ParameterizedOverlayButton, } from './ParameterizedOverlay'; +export { + createGameOverOverlay, + computeAutoScaleFontSize, +} from './GameOverOverlay'; +export type { + GameOverExtraButton, + GameOverOverlayConfig, + GameOverOverlayResult, +} from './GameOverOverlay'; + // HandView – reusable hand-of-cards display component export { HandView } from './HandView'; export type { @@ -186,6 +196,7 @@ export type { AddCardOptions, AnimateAddCardOptions, RemoveCardOptions, + AnimatedSortOptions, HandViewEvents, CardTextureResolver, RenderCardFn, @@ -271,3 +282,20 @@ export type { // HighlightManager – reusable highlight zone manager export { HighlightManager } from './HighlightManager'; export type { HighlightZoneConfig, HighlightStyle } from './HighlightManager'; + +// Layout ownership / visibility controller +// Re-exported from core-engine for convenience: SLL consumers can import +// ownership helpers directly from the UI barrel without reaching into +// src/core-engine/. +export { + VisibilityOwnershipController, +} from '../core-engine/VisibilityOwnership'; +export type { + VisibilityMode, + VisibilityModeRuleSet, + VisibilityTarget, + VisibilityOwnershipIssue, + VisibilityOwnershipIssueCode, + VisibilityOwnershipIssueReporter, + VisibilityOwnershipControllerOptions, +} from '../core-engine/VisibilityOwnership'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..ed772106 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// + diff --git a/tests/core-engine/ActiveEffect.test.ts b/tests/core-engine/ActiveEffect.test.ts index 35864795..6508f317 100644 --- a/tests/core-engine/ActiveEffect.test.ts +++ b/tests/core-engine/ActiveEffect.test.ts @@ -126,7 +126,8 @@ describe('ActiveEffect', () => { const result = applyActiveEffectMultiplier(effects, 'income-multiplier', 100); - expect(result).toBe(64); // 100 * 0.8 * 0.8 + // CG-0MRER3RE300418SG: Math.round removed; use toBeCloseTo for floating point + expect(result).toBeCloseTo(64); // 100 * 0.8 * 0.8 }); it('does not apply effects of a different type', () => { @@ -146,14 +147,15 @@ describe('ActiveEffect', () => { expect(result).toBe(100); }); - it('rounds to nearest integer', () => { + it('preserves fractional results (no longer rounds)', () => { + // CG-0MRER3RE300418SG: Math.round removed; fractional values preserved const effects: ActiveEffect[] = [ createActiveEffect('income-multiplier', 0.8, 5, 'evt-flu', 'Flu'), ]; const result = applyActiveEffectMultiplier(effects, 'income-multiplier', 3); - expect(result).toBe(2); // 3 * 0.8 = 2.4 -> 2 + expect(result).toBeCloseTo(2.4); // 3 * 0.8 = 2.4 (was 2 before fix) }); it('handles base value of 0', () => { @@ -215,7 +217,7 @@ describe('ActiveEffect', () => { // During active effect: income reduced const reducedIncome = applyActiveEffectMultiplier([effect], 'income-multiplier', income); - expect(reducedIncome).toBe(80); + expect(reducedIncome).toBe(80); // 100 * 0.8 = 80 (integer, unchanged) // Decay turn 1: 3 -> 2, still active let r1 = decayActiveEffects([effect]); diff --git a/tests/core-engine/CsvLoader.test.ts b/tests/core-engine/CsvLoader.test.ts new file mode 100644 index 00000000..696fced5 --- /dev/null +++ b/tests/core-engine/CsvLoader.test.ts @@ -0,0 +1,55 @@ +/// + +import { describe, it, expect } from 'vitest'; +import { parseCsv } from '../../src/core-engine/CsvLoader'; + +describe('CSV Loader', () => { + it('should parse simple CSV rows', () => { + const csv = 'a,b,c\n1,2,3\n4,5,6'; + const rows = parseCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ a: '1', b: '2', c: '3' }); + expect(rows[1]).toEqual({ a: '4', b: '5', c: '6' }); + }); + + it('should handle quoted fields with commas', () => { + const csv = 'name,desc\n"Smith, John","A, B, C"'; + const rows = parseCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Smith, John'); + expect(rows[0].desc).toBe('A, B, C'); + }); + + it('should handle escaped quotes within quoted fields (CSV double-quote style)', () => { + const csv = 'name,desc\n"Reader\'s Café","A ""great"" place"'; + const rows = parseCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe("Reader's Café"); + expect(rows[0].desc).toBe('A "great" place'); + }); + + it('should handle empty fields', () => { + const csv = 'a,b,c\n1,,3\n,,\n'; + const rows = parseCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ a: '1', b: '', c: '3' }); + expect(rows[1]).toEqual({ a: '', b: '', c: '' }); + }); + + it('should handle empty fields in quoted values', () => { + const csv = 'a,b,c\n"","",""\n'; + const rows = parseCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ a: '', b: '', c: '' }); + }); + + it('should return empty array for empty string', () => { + const rows = parseCsv(''); + expect(rows).toHaveLength(0); + }); + + it('should return empty array for header-only CSV', () => { + const rows = parseCsv('a,b,c'); + expect(rows).toHaveLength(0); + }); +}); diff --git a/tests/fixtures/transcripts/the-mind/fixture-game.json b/tests/fixtures/transcripts/the-mind/fixture-game.json deleted file mode 100644 index 1e1065b8..00000000 --- a/tests/fixtures/transcripts/the-mind/fixture-game.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "version": 2, - "gameType": "the-mind", - "startedAt": "2026-01-01T00:00:00.000Z", - "endedAt": "2026-01-01T00:10:00.000Z", - "initialState": { - "playerNames": [ - "AI-0", - "AI-1" - ], - "isAI": [ - true, - true - ], - "startingLives": 2, - "startingLevel": 1, - "hands": [ - [ - 3 - ], - [ - 45 - ] - ] - }, - "events": [ - { - "type": "card-played", - "timestamp": 1500, - "playerId": 0, - "cardValue": 3, - "pileTopAfter": 3, - "pileSizeAfter": 1 - }, - { - "type": "card-played", - "timestamp": 4533.737022429705, - "playerId": 1, - "cardValue": 45, - "pileTopAfter": 0, - "pileSizeAfter": 0 - }, - { - "type": "level-complete", - "timestamp": 4533.737022429705, - "level": 1, - "bonusLifeAwarded": false, - "livesAfter": 2, - "handsDealt": [ - [ - 20, - 52 - ], - [ - 26, - 32 - ] - ] - }, - { - "type": "card-played", - "timestamp": 2294.9685607105494, - "playerId": 1, - "cardValue": 26, - "pileTopAfter": 26, - "pileSizeAfter": 1 - }, - { - "type": "penalty", - "timestamp": 2294.9685607105494, - "livesRemaining": 1, - "discardedCards": [ - { - "playerId": 0, - "cardValue": 20 - } - ] - }, - { - "type": "card-played", - "timestamp": 3921.225483715534, - "playerId": 1, - "cardValue": 32, - "pileTopAfter": 32, - "pileSizeAfter": 2 - }, - { - "type": "card-played", - "timestamp": 4455.380744859576, - "playerId": 0, - "cardValue": 52, - "pileTopAfter": 0, - "pileSizeAfter": 0 - }, - { - "type": "level-complete", - "timestamp": 4455.380744859576, - "level": 2, - "bonusLifeAwarded": false, - "livesAfter": 1, - "handsDealt": [ - [ - 35, - 68, - 83 - ], - [ - 29, - 42, - 70 - ] - ] - }, - { - "type": "card-played", - "timestamp": 3312.046142667532, - "playerId": 0, - "cardValue": 35, - "pileTopAfter": 35, - "pileSizeAfter": 1 - }, - { - "type": "penalty", - "timestamp": 3312.046142667532, - "livesRemaining": 0, - "discardedCards": [ - { - "playerId": 1, - "cardValue": 29 - } - ] - }, - { - "type": "game-over", - "timestamp": 1771972865799, - "outcome": "loss", - "finalLevel": 3, - "finalLives": 0 - } - ], - "results": { - "outcome": "loss", - "finalLevel": 3, - "finalLives": 0, - "totalCardsPlayed": 6, - "totalPenalties": 2 - } -} diff --git a/tests/golf/AiStrategy.test.ts b/tests/golf/AiStrategy.test.ts index 8f333af4..16a4363b 100644 --- a/tests/golf/AiStrategy.test.ts +++ b/tests/golf/AiStrategy.test.ts @@ -14,7 +14,9 @@ import { chooseMoveForCard, countVisibleRanks, computeColumnBonus, + DEFAULT_GREEDY_CONFIG, } from '../../example-games/golf/AiStrategy'; +import type { GreedyStrategyConfig } from '../../example-games/golf/AiStrategy'; import { setupGolfGame, executeTurn, @@ -172,7 +174,7 @@ describe('GreedyStrategy', () => { createCard('A', 'hearts', true), // 1 pt createCard('2', 'spades', true), // -2 pts createCard('A', 'diamonds', true),// 1 pt - createCard('A', 'spades', true), // 1 pt -- col 1 has A,A but needs face-down to flip + createCard('A', 'spades', true), // 1 pt createCard('2', 'clubs', true), // -2 pts createCard('3', 'clubs', false), // face-down createCard('3', 'hearts', false), // face-down @@ -275,6 +277,18 @@ describe('AiPlayer', () => { const move = ai.chooseMoveForCard(aiPs.grid, drawnCard); expect(isLegalMove(ps.grid, move)).toBe(true); }); + + it('accepts a custom config and passes it through', () => { + const config: GreedyStrategyConfig = { columnWeight: 0.2 }; + const ai = new AiPlayer(GreedyStrategy, createTestRng(), config); + const session = setupGolfGame({ rng: createTestRng(1) }); + const ps = session.gameState.playerStates[0]; + const aiPs = createAiVisiblePlayerState(ps); + const aiShared = createAiVisibleSharedState(session.shared); + + const source = ai.chooseDrawSource(aiPs, aiShared); + expect(['stock', 'discard']).toContain(source); + }); }); describe('Full game simulation', () => { @@ -573,23 +587,26 @@ describe('countVisibleRanks', () => { }); }); +// ── ComputeColumnBonus tests ──────────────────────────────── + describe('computeColumnBonus', () => { /** - * Helper: create a grid where column 0 builds toward a column match. + * Helper: create a grid where column 0 builds toward a column match + * with high-value cards (Queens = 10 pts each). * - * Column 0 has: [K♣(face-up), A♠(face-up, non-matching), ?(face-down)]. - * Drawing a K and swapping into the A♠ position (1,0) creates: - * [K♣, K♥, ?] → 2 matching face-up + 1 unknown → bonus eligible. + * Column 0 has: [Q♣(face-up), A♠(face-up, non-matching), ?(face-down)]. + * Drawing a Q and swapping into the A♠ position (1,0) creates: + * [Q♣, Q♥, ?] → 2 matching face-up (10+10=20 pts) + 1 unknown → bonus eligible. */ function buildBuildableGrid(): [AiVisibleGrid, AiVisiblePlayerState] { const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K, matching - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-matching swap target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down, remains unknown + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts), matching + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-matching swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down, remains unknown createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; @@ -599,13 +616,13 @@ describe('computeColumnBonus', () => { } const buildMove = { kind: 'swap' as const, row: 1, col: 0 }; - const drawnKing = createCard('K', 'hearts', true); + const drawnQueen = createCard('Q', 'hearts', true); it('returns 0 for discard-and-flip moves', () => { const [grid] = buildBuildableGrid(); const bonus = computeColumnBonus( grid, - drawnKing, + drawnQueen, { kind: 'discard-and-flip', row: 0, col: 0 }, {}, ); @@ -614,8 +631,8 @@ describe('computeColumnBonus', () => { it('returns 0 when no matching cards in column', () => { const [grid] = buildBuildableGrid(); - // Drawing an Ace and swapping into (1,0) where A♠ is won't build toward - // any column because Ace doesn't match the King in column 0 + // Drawing an Ace (doesn't match Queen in column 0) into (1,0) keeps column as + // [Q♣, A♦, ?] → only 1 matching, no bonus const drawnAce = createCard('A', 'diamonds', true); const bonus = computeColumnBonus( grid, @@ -628,107 +645,265 @@ describe('computeColumnBonus', () => { it('returns 0 when all copies of target rank are visible', () => { const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-K swap target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('Q', 'clubs', true), // (0,0) -- Q + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; - // Adding 3 more visible Kings to fill all 4 copies const visibleRanks: Record = { - K: 4, A: 2, '2': 1, '3': 1, '4': 1, + Q: 4, A: 2, '2': 1, '3': 1, '4': 1, }; const grid = createGolfGrid(cards); const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); const bonus = computeColumnBonus( ps.grid, - drawnKing, + drawnQueen, buildMove, visibleRanks, ); - // 0 unknown / 4 max = 0, weight = 2, bonus = 0 + // 0 unknown / 4 max = 0, columnWeight = 0.5, total card value = 20 + // bonus = -20 * 0 * 0.5 = 0 expect(bonus).toBeCloseTo(0); }); it('returns negative bonus when unknown copies of target rank remain', () => { const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-K swap target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; const grid = createGolfGrid(cards); const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); const visibleRanks: Record = { - K: 1, A: 2, '2': 1, '3': 1, '4': 1, + Q: 1, A: 2, '2': 1, '3': 1, '4': 1, }; const bonus = computeColumnBonus( ps.grid, - drawnKing, + drawnQueen, buildMove, visibleRanks, ); - // 3 unknown / 4 max = 0.75, weight = 2, bonus = -0.75 * 2 = -1.5 - expect(bonus).toBe(-1.5); + // 2 matching face-up Queens = 10+10 = 20 pts + // 3 unknown / 4 max = 0.75 feasibility + // columnWeight = 0.5 (default) + // bonus = -20 * 0.75 * 0.5 = -7.5 + expect(bonus).toBe(-7.5); }); it('bonus is proportional to remaining unknown copies', () => { const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-K swap target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('6', 'hearts', false), + createCard('7', 'spades', false), + ]; + const grid = createGolfGrid(cards); + const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); + + // 2 matching face-up Queens = 20 pts, columnWeight = 0.5 + // 1 Q visible → 3 unknown → -20 * 0.75 * 0.5 = -7.5 + expect(computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 1 })).toBe(-7.5); + // 2 Q visible → 2 unknown → -20 * 0.5 * 0.5 = -5.0 + expect(computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 2 })).toBe(-5.0); + // 3 Q visible → 1 unknown → -20 * 0.25 * 0.5 = -2.5 + expect(computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 3 })).toBe(-2.5); + // 4 Q visible → 0 unknown → -20 * 0 * 0.5 = 0 + expect(computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 4 })).toBeCloseTo(0); + }); + + it('returns larger bonus for higher card point values', () => { + const cards = [ + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-matching swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; const grid = createGolfGrid(cards); const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); - // 1 K visible → 3 unknown → -1.5 - expect(computeColumnBonus(ps.grid, drawnKing, buildMove, { K: 1 })).toBe(-1.5); - // 2 K visible → 2 unknown → -1.0 - expect(computeColumnBonus(ps.grid, drawnKing, buildMove, { K: 2 })).toBe(-1.0); - // 3 K visible → 1 unknown → -0.5 - expect(computeColumnBonus(ps.grid, drawnKing, buildMove, { K: 3 })).toBe(-0.5); - // 4 K visible → 0 unknown → 0 (use toBeCloseTo to handle -0 vs +0) - expect(computeColumnBonus(ps.grid, drawnKing, buildMove, { K: 4 })).toBeCloseTo(0); + // Build a column with low-value cards (2s = -2 pts each) + const cardsLow = [ + createCard('2', 'clubs', true), // (0,0) -- 2 (-2 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('3', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-matching swap target + createCard('4', 'clubs', true), // (1,1) + createCard('5', 'hearts', true), // (1,2) + createCard('6', 'clubs', false), // (2,0) -- face-down + createCard('7', 'hearts', false), + createCard('8', 'spades', false), + ]; + const gridLow = createGolfGrid(cardsLow); + const psLow: AiVisiblePlayerState = createAiVisiblePlayerState({ grid: gridLow }); + const drawnTwo = createCard('2', 'diamonds', true); + + // High-value column (Queens): bonus should be larger in magnitude + const bonusHigh = computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 1 }); + // Low-value column (2s): bonus should be smaller in magnitude + const bonusLow = computeColumnBonus(psLow.grid, drawnTwo, buildMove, { '2': 1 }); + + // High-value cards (Queens, 10 pts each) get a larger negative bonus + // than low-value cards (2s, -2 pts each). Zeroing a Queen column saves + // 30 points; zeroing a 2s column costs 6 points. + // The algorithm uses Math.max(0, cardValueSum) so negative values produce 0 bonus. + expect(bonusHigh).toBeLessThan(0); + expect(bonusLow).toBe(0); + expect(bonusHigh).toBeLessThan(bonusLow); + }); + + it('returns 0 for low-value cards (Kings, 0 pts) since zeroing saves nothing', () => { + const cards = [ + createCard('K', 'clubs', true), // (0,0) -- K (0 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-K swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('6', 'hearts', false), + createCard('7', 'spades', false), + ]; + const grid = createGolfGrid(cards); + const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); + const drawnKing = createCard('K', 'hearts', true); + + const bonus = computeColumnBonus(ps.grid, drawnKing, buildMove, { K: 1 }); + // K = 0 pts × 2 matching × 0.75 × 0.5 = 0 + expect(bonus).toBe(0); + }); +}); + +// ── Configurable columnWeight tests ───────────────────────── + +describe('GreedyStrategyConfig', () => { + it('default config has columnWeight = 0.5', () => { + expect(DEFAULT_GREEDY_CONFIG.columnWeight).toBe(0.5); + }); + + it('columnWeight = 0 disables column bonus entirely', () => { + // Build a high-value column scenario where bonus would normally apply + const cards = [ + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('6', 'hearts', false), + createCard('7', 'spades', false), + ]; + const grid = createGolfGrid(cards); + const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); + const drawnQueen = createCard('Q', 'hearts', true); + const buildMove = { kind: 'swap' as const, row: 1, col: 0 }; + + const config: GreedyStrategyConfig = { columnWeight: 0 }; + const bonus = computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 1 }, config); + expect(bonus).toBe(0); + }); + + it('columnWeight = 1 gives full card-value bonus', () => { + const cards = [ + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('6', 'hearts', false), + createCard('7', 'spades', false), + ]; + const grid = createGolfGrid(cards); + const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); + const drawnQueen = createCard('Q', 'hearts', true); + const buildMove = { kind: 'swap' as const, row: 1, col: 0 }; + + const config: GreedyStrategyConfig = { columnWeight: 1 }; + const bonus = computeColumnBonus(ps.grid, drawnQueen, buildMove, { Q: 1 }, config); + // 2 matching face-up Queens = 20 pts, 3 unknown / 4 = 0.75 + // bonus = -20 * 0.75 * 1.0 = -15 + expect(bonus).toBe(-15); + }); + + it('AiPlayer passes custom config through to chooseDrawSource', () => { + // With columnWeight = 0, the AI should NOT prefer discard for column building + // because the bonus is disabled. Test that the config is actually passed through. + const cards = [ + createCard('Q', 'clubs', true), // (0,0) -- Q + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q swap target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('6', 'hearts', false), + createCard('7', 'spades', false), + ]; + const grid = createGolfGrid(cards); + const ps = { grid }; + const aiPs = createAiVisiblePlayerState(ps); + + // Discard has a Queen that helps column 0, stock available + const shared: GolfSharedState = { + stockPile: [createCard('A', 'diamonds')], + discardPile: new Pile([createCard('Q', 'hearts', true)]), + roundEnd: createRoundEndState(2), + }; + const aiShared = createAiVisibleSharedState(shared); + + // With columnWeight = 0, the AI should NOT prefer discard + const ai = new AiPlayer(GreedyStrategy, createTestRng(), { columnWeight: 0 }); + const source = ai.chooseDrawSource(aiPs, aiShared); + // We can't predict the exact choice, but it should work without errors + expect(['stock', 'discard']).toContain(source); }); }); describe('chooseMoveForCard with visible rank weighting', () => { it('applies column bonus when visibleRanks is provided (lowers score for build move)', () => { - // Column 0: [K♣, A♠, ?] -- drawing K and swapping into A♠ position builds column + // Column 0: [Q♣, A♠, ?] -- drawing Q and swapping into A♠ position builds column const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-K, build target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('Q', 'clubs', true), // (0,0) -- Q (10 pts) + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q, build target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; const grid = createGolfGrid(cards); const ps: AiVisiblePlayerState = createAiVisiblePlayerState({ grid }); - const drawnCard = createCard('K', 'hearts', true); + const drawnCard = createCard('Q', 'hearts', true); // Choose the best move with bonus - const visibleRanks: Record = { K: 1 }; + const visibleRanks: Record = { Q: 1 }; const moveWithBonus = chooseMoveForCard( ps.grid, drawnCard, @@ -753,17 +928,17 @@ describe('chooseMoveForCard with visible rank weighting', () => { describe('chooseDrawSource with visible rank weighting', () => { it('prefers discard when it helps build a column and unknown copies remain', () => { - // Column 0: [K♣, A♠, ?(face-down)] - // Discard has a King (helps column 0), stock available - // Only 1 King visible in grid (no K on discard before evaluating) + // Column 0: [Q♣, A♠, ?(face-down)] + // Discard has a Queen (helps column 0), stock available + // Only 1 Queen visible in grid (no Q on discard before evaluating) const cards = [ - createCard('K', 'clubs', true), // (0,0) -- K - createCard('A', 'hearts', true), // (0,1) - createCard('2', 'spades', true), // (0,2) - createCard('A', 'spades', true), // (1,0) -- non-K, build target - createCard('3', 'clubs', true), // (1,1) - createCard('4', 'hearts', true), // (1,2) - createCard('5', 'clubs', false), // (2,0) -- face-down + createCard('Q', 'clubs', true), // (0,0) -- Q + createCard('A', 'hearts', true), // (0,1) + createCard('2', 'spades', true), // (0,2) + createCard('A', 'spades', true), // (1,0) -- non-Q, build target + createCard('3', 'clubs', true), // (1,1) + createCard('4', 'hearts', true), // (1,2) + createCard('5', 'clubs', false), // (2,0) -- face-down createCard('6', 'hearts', false), createCard('7', 'spades', false), ]; @@ -771,16 +946,16 @@ describe('chooseDrawSource with visible rank weighting', () => { const rawPs = { grid }; const aiPs: AiVisiblePlayerState = createAiVisiblePlayerState(rawPs); - // Discard has a King (helps column 0), stock available + // Discard has a Queen (helps column 0), stock available const shared: GolfSharedState = { - stockPile: [createCard('Q', 'diamonds')], - discardPile: new Pile([createCard('K', 'spades', true)]), // King on discard top + stockPile: [createCard('A', 'diamonds')], + discardPile: new Pile([createCard('Q', 'hearts', true)]), // Queen on discard top roundEnd: createRoundEndState(2), }; const aiShared = createAiVisibleSharedState(shared); - // GreedyStrategy should prefer discard because King helps column 0 - // and unknown Kings remain (only 1 King visible in grid currently) + // GreedyStrategy should prefer discard because Queen helps column 0 + // and unknown Queens remain (only 1 Queen visible in grid currently) const rng = createTestRng(); const action = GreedyStrategy.chooseAction(aiPs, aiShared, rng); expect(action.drawSource).toBe('discard'); diff --git a/tests/gym/GymLayoutOwnershipScene.browser.test.ts b/tests/gym/GymLayoutOwnershipScene.browser.test.ts new file mode 100644 index 00000000..99f6db29 --- /dev/null +++ b/tests/gym/GymLayoutOwnershipScene.browser.test.ts @@ -0,0 +1,230 @@ +/** + * GymLayoutOwnershipScene Browser Integration Tests + * + * Validates that the layout ownership demo scene boots correctly and + * demonstrates the visibility ownership runtime behavior: + * - Mode switching changes target visibility + * - Group toggles modify visibility rules + * - Status text updates reflect the current state + * + * @module tests/gym/GymLayoutOwnershipScene.browser + */ + +import { describe, expect, it, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { GymLayoutOwnershipScene } from '../../example-games/gym/scenes/GymLayoutOwnershipScene'; +import { GYM_LAYOUT_OWNERSHIP_KEY } from '../../example-games/gym/GymRegistry'; +import { waitForScene } from '../helpers/waitForScene'; + +function findTextObject(scene: Phaser.Scene, textMatch: (text: string) => boolean): Phaser.GameObjects.Text | null { + return ( + scene.children.list.find( + (child): child is Phaser.GameObjects.Text => + child instanceof Phaser.GameObjects.Text && textMatch(String(child.text)), + ) ?? null + ); +} + +describe('GymLayoutOwnershipScene browser integration', () => { + 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(); + }); + + it('boots and shows the initial composed mode state', async () => { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a1420', + scene: [GymLayoutOwnershipScene], + }); + + await waitForScene(game, GYM_LAYOUT_OWNERSHIP_KEY); + const scene = game.scene.getScene(GYM_LAYOUT_OWNERSHIP_KEY) as Phaser.Scene; + + // Verify scene header is visible + const headerText = findTextObject(scene, text => text === 'Layout Ownership Runtime'); + expect(headerText).toBeTruthy(); + expect(headerText?.visible).toBe(true); + + // Mode buttons should be visible + const shellBtn = findTextObject(scene, text => text.includes('[ Mode: Shell ]') || text.includes('▶ Shell')); + expect(shellBtn).toBeTruthy(); + + // Status line should show composed mode + const statusText = findTextObject(scene, text => text.includes('Mode: composed')); + expect(statusText).toBeTruthy(); + + // All 6 demo objects (shell title, shell menu, scene card 1, scene card 2, + // shared action, shared help) should be visible in composed mode + const demoLabels = [ + 'Shell Title', + 'Shell Menu', + 'Scene Card 1', + 'Scene Card 2', + 'Shared Action', + 'Shared Help', + ]; + for (const label of demoLabels) { + const obj = findTextObject(scene, text => text === label); + expect(obj).toBeTruthy(); + expect(obj?.visible).toBe(true); + } + }); + + it('switches to shell-only mode (scene objects hidden)', async () => { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a1420', + scene: [GymLayoutOwnershipScene], + }); + + await waitForScene(game, GYM_LAYOUT_OWNERSHIP_KEY); + const scene = game.scene.getScene(GYM_LAYOUT_OWNERSHIP_KEY) as Phaser.Scene; + + // Find and click the shell mode button (use '[ Mode:' prefix to distinguish from demo objects) + const shellBtn = findTextObject(scene, text => text.startsWith('[ Mode: Shell ]')); + expect(shellBtn).toBeTruthy(); + shellBtn?.emit('pointerdown'); + + // Shell and shared should be visible, scene objects hidden + expect(findTextObject(scene, text => text === 'Shell Title')?.visible).toBe(true); + expect(findTextObject(scene, text => text === 'Shell Menu')?.visible).toBe(true); + expect(findTextObject(scene, text => text === 'Scene Card 1')?.visible).toBe(false); + expect(findTextObject(scene, text => text === 'Scene Card 2')?.visible).toBe(false); + expect(findTextObject(scene, text => text === 'Shared Action')?.visible).toBe(true); + expect(findTextObject(scene, text => text === 'Shared Help')?.visible).toBe(true); + + // Status should reflect shell-only mode + const statusText = findTextObject(scene, text => text.includes('Mode: shell-only')); + expect(statusText).toBeTruthy(); + }); + + it('switches to scene-only mode (shell objects hidden)', async () => { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a1420', + scene: [GymLayoutOwnershipScene], + }); + + await waitForScene(game, GYM_LAYOUT_OWNERSHIP_KEY); + const scene = game.scene.getScene(GYM_LAYOUT_OWNERSHIP_KEY) as Phaser.Scene; + + // Find and click the scene mode button (use '[ Mode:' prefix to distinguish from demo objects) + const sceneBtn = findTextObject(scene, text => text.startsWith('[ Mode: Scene ]')); + expect(sceneBtn).toBeTruthy(); + sceneBtn?.emit('pointerdown'); + + // Shell objects hidden, scene objects visible + expect(findTextObject(scene, text => text === 'Shell Title')?.visible).toBe(false); + expect(findTextObject(scene, text => text === 'Shell Menu')?.visible).toBe(false); + expect(findTextObject(scene, text => text === 'Scene Card 1')?.visible).toBe(true); + expect(findTextObject(scene, text => text === 'Scene Card 2')?.visible).toBe(true); + + // Shared still visible in scene-only + expect(findTextObject(scene, text => text === 'Shared Action')?.visible).toBe(true); + expect(findTextObject(scene, text => text === 'Shared Help')?.visible).toBe(true); + + // Status should reflect scene-only mode + const statusText = findTextObject(scene, text => text.includes('Mode: scene-only')); + expect(statusText).toBeTruthy(); + }); + + it('registers ungrouped targets with diagnostic warning', async () => { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a1420', + scene: [GymLayoutOwnershipScene], + }); + + await waitForScene(game, GYM_LAYOUT_OWNERSHIP_KEY); + const scene = game.scene.getScene(GYM_LAYOUT_OWNERSHIP_KEY) as Phaser.Scene; + + // Click the "+ Ungrouped" button to add an ungrouped target + const addBtn = findTextObject(scene, text => text.includes('+ Ungrouped')); + expect(addBtn).toBeTruthy(); + + addBtn?.emit('pointerdown'); + + // An ungrouped card should now be visible in the scene area + // (idx = registeredTargets.length + 1; there are 6 demo cards, so ungrouped #7) + const ungroupedCard = findTextObject(scene, text => text === 'Ungrouped #7'); + expect(ungroupedCard).toBeTruthy(); + + // In composed mode, ungrouped targets should be hidden by default + expect(ungroupedCard?.visible).toBe(false); + + // Diagnostic warning should be displayed + const issueText = findTextObject(scene, text => text.startsWith('⚠')); + expect(issueText).toBeTruthy(); + expect(issueText?.text).toContain('ungrouped'); + }); + + it('supports mode cycling back to composed', async () => { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#0a1420', + scene: [GymLayoutOwnershipScene], + }); + + await waitForScene(game, GYM_LAYOUT_OWNERSHIP_KEY); + const scene = game.scene.getScene(GYM_LAYOUT_OWNERSHIP_KEY) as Phaser.Scene; + + // Switch to shell-only, then scene-only, then back to composed + const shellBtn = findTextObject(scene, text => text.startsWith('[ Mode: Shell ]')); + const sceneBtn = findTextObject(scene, text => text.startsWith('[ Mode: Scene ]')); + const composedBtn = findTextObject(scene, text => text.startsWith('[ Mode: Composed ]')); + + expect(shellBtn).toBeTruthy(); + expect(sceneBtn).toBeTruthy(); + expect(composedBtn).toBeTruthy(); + + shellBtn?.emit('pointerdown'); + expect(findTextObject(scene, text => text.includes('Mode: shell-only'))?.visible).toBe(true); + + sceneBtn?.emit('pointerdown'); + expect(findTextObject(scene, text => text.includes('Mode: scene-only'))?.visible).toBe(true); + + composedBtn?.emit('pointerdown'); + expect(findTextObject(scene, text => text.includes('Mode: composed'))?.visible).toBe(true); + }); +}); diff --git a/tests/gym/GymRegistry.test.ts b/tests/gym/GymRegistry.test.ts index 5f37427a..5b562731 100644 --- a/tests/gym/GymRegistry.test.ts +++ b/tests/gym/GymRegistry.test.ts @@ -18,6 +18,7 @@ import { GYM_SAVE_LOAD_KEY, GYM_AUDIO_FEEDBACK_KEY, GYM_SLL_KEY, + GYM_LAYOUT_OWNERSHIP_KEY, GYM_SCENE_CATALOGUE, } from '../../example-games/gym/GymRegistry'; import type { GymSceneEntry } from '../../example-games/gym/GymRegistry'; @@ -33,6 +34,7 @@ describe('GymRegistry', () => { expect(GYM_SAVE_LOAD_KEY).toBe('GymSaveLoadScene'); expect(GYM_AUDIO_FEEDBACK_KEY).toBe('GymAudioFeedbackScene'); expect(GYM_SLL_KEY).toBe('GymSllScene'); + expect(GYM_LAYOUT_OWNERSHIP_KEY).toBe('GymLayoutOwnershipScene'); }); it('catalogue contains all demo scene entries', () => { @@ -45,6 +47,7 @@ describe('GymRegistry', () => { expect(catalogueKeys).toContain(GYM_SAVE_LOAD_KEY); expect(catalogueKeys).toContain(GYM_AUDIO_FEEDBACK_KEY); expect(catalogueKeys).toContain(GYM_SLL_KEY); + expect(catalogueKeys).toContain(GYM_LAYOUT_OWNERSHIP_KEY); }); it('every catalogue entry has a non-empty title and description', () => { diff --git a/tests/gym/GymRouterScene.browser.test.ts b/tests/gym/GymRouterScene.browser.test.ts index 1b72d0de..c32336f5 100644 --- a/tests/gym/GymRouterScene.browser.test.ts +++ b/tests/gym/GymRouterScene.browser.test.ts @@ -20,6 +20,7 @@ import { GymGraphicsShaderSpikeScene, GymGraphicsLightingSpikeScene, GymSllScene, + GymLayoutOwnershipScene, } from '../../example-games/gym'; import { waitForScene } from '../helpers/waitForScene'; import { @@ -34,6 +35,7 @@ import { GYM_GRAPHICS_SHADER_SPIKE_KEY, GYM_GRAPHICS_LIGHTING_SPIKE_KEY, GYM_SLL_KEY, + GYM_LAYOUT_OWNERSHIP_KEY, GYM_SCENE_CATALOGUE, } from '../../example-games/gym/GymRegistry'; @@ -69,10 +71,11 @@ describe('GymRouterScene browser smoke', () => { GymGraphicsShaderSpikeScene, GymGraphicsLightingSpikeScene, GymSllScene, + GymLayoutOwnershipScene, ], }); - await waitForScene(game, 'GymRouterScene'); + await waitForScene(game, 'GymRouterScene') const activeScene = game.scene.getScene('GymRouterScene'); expect(activeScene).toBeTruthy(); @@ -121,6 +124,7 @@ describe('GymRouterScene browser smoke', () => { GYM_GRAPHICS_SHADER_SPIKE_KEY, GYM_GRAPHICS_LIGHTING_SPIKE_KEY, GYM_SLL_KEY, + GYM_LAYOUT_OWNERSHIP_KEY, ]; game = new Phaser.Game({ type: Phaser.CANVAS, @@ -140,6 +144,7 @@ describe('GymRouterScene browser smoke', () => { GymGraphicsShaderSpikeScene, GymGraphicsLightingSpikeScene, GymSllScene, + GymLayoutOwnershipScene, ], }); diff --git a/tests/gym/GymSceneSmoke.browser.test.ts b/tests/gym/GymSceneSmoke.browser.test.ts index 58cbfc88..4ad4f5ae 100644 --- a/tests/gym/GymSceneSmoke.browser.test.ts +++ b/tests/gym/GymSceneSmoke.browser.test.ts @@ -16,6 +16,7 @@ import { GymAudioFeedbackScene } from '../../example-games/gym/scenes/GymAudioFe import { GymGraphicsShaderSpikeScene } from '../../example-games/gym/scenes/GymGraphicsShaderSpikeScene'; import { GymGraphicsLightingSpikeScene } from '../../example-games/gym/scenes/GymGraphicsLightingSpikeScene'; import { GymSllScene } from '../../example-games/gym/scenes/GymSllScene'; +import { GymLayoutOwnershipScene } from '../../example-games/gym/scenes/GymLayoutOwnershipScene'; import { waitForScene } from '../helpers/waitForScene'; import { GYM_DECK_RNG_KEY, @@ -28,6 +29,7 @@ import { GYM_GRAPHICS_SHADER_SPIKE_KEY, GYM_GRAPHICS_LIGHTING_SPIKE_KEY, GYM_SLL_KEY, + GYM_LAYOUT_OWNERSHIP_KEY, } from '../../example-games/gym/GymRegistry'; const SCENES = [ @@ -41,6 +43,7 @@ const SCENES = [ { key: GYM_GRAPHICS_SHADER_SPIKE_KEY, Scene: GymGraphicsShaderSpikeScene }, { key: GYM_GRAPHICS_LIGHTING_SPIKE_KEY, Scene: GymGraphicsLightingSpikeScene }, { key: GYM_SLL_KEY, Scene: GymSllScene }, + { key: GYM_LAYOUT_OWNERSHIP_KEY, Scene: GymLayoutOwnershipScene }, ]; describe('Gym individual scene smoke', () => { diff --git a/tests/gym/GymTooltipLayout.test.ts b/tests/gym/GymTooltipLayout.test.ts new file mode 100644 index 00000000..4e394e1c --- /dev/null +++ b/tests/gym/GymTooltipLayout.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import tooltipLayoutJson from '../../example-games/gym/layouts/gym-tooltip.layout.json'; +import type { ScreenLayoutDocument } from '../../src/ui/screen-layout-schema'; +import { + parseScreenLayoutDocument, + validateScreenLayoutDocument, +} from '../../src/ui/screen-layout-schema'; +import { anchorPoint } from '../../src/ui/screen-layout'; + +const tooltipLayout = tooltipLayoutJson as ScreenLayoutDocument; + +describe('GymTooltipScene SLL layout', () => { + it('validates the tooltip layout against the SLL schema', () => { + const validation = validateScreenLayoutDocument(tooltipLayout); + expect(validation.valid).toBe(true); + + const parsed = parseScreenLayoutDocument(tooltipLayout); + expect(parsed.valid).toBe(true); + }); + + it('maps header zone at the expected button-row position (y ≈ 60 at 1280x720)', () => { + const headerAnchor = anchorPoint(tooltipLayout, 'header', 'center', { width: 1280, height: 720 }, 1); + // 0.08333 * 720 ≈ 60.0 + expect(headerAnchor.x).toBeCloseTo(640, 6); + expect(headerAnchor.y).toBeCloseTo(60, 1); + }); + + it('maps label zone at the expected mode-label position (y ≈ 100 at 1280x720)', () => { + const labelAnchor = anchorPoint(tooltipLayout, 'label', 'center', { width: 1280, height: 720 }, 1); + // 0.13889 * 720 ≈ 100.0 + expect(labelAnchor.x).toBeCloseTo(640, 6); + expect(labelAnchor.y).toBeCloseTo(100, 1); + }); + + it('maps content zone at the expected demo-cards position (y ≈ 200 at 1280x720)', () => { + const contentAnchor = anchorPoint(tooltipLayout, 'content', 'center', { width: 1280, height: 720 }, 1); + // 0.27778 * 720 ≈ 200.0 + expect(contentAnchor.x).toBeCloseTo(640, 6); + expect(contentAnchor.y).toBeCloseTo(200, 1); + }); + + it('maps log zone at the expected event-log position (y ≈ 380 at 1280x720)', () => { + const logAnchor = anchorPoint(tooltipLayout, 'log', 'center', { width: 1280, height: 720 }, 1); + // 0.52778 * 720 ≈ 380.0 + expect(logAnchor.x).toBeCloseTo(640, 6); + expect(logAnchor.y).toBeCloseTo(380, 1); + }); + + it('maps zones at 800x600 viewport (browser test dimensions)', () => { + const viewport = { width: 800, height: 600 }; + const headerAnchor = anchorPoint(tooltipLayout, 'header', 'center', viewport, 1); + const labelAnchor = anchorPoint(tooltipLayout, 'label', 'center', viewport, 1); + const contentAnchor = anchorPoint(tooltipLayout, 'content', 'center', viewport, 1); + const logAnchor = anchorPoint(tooltipLayout, 'log', 'center', viewport, 1); + + // All anchors should maintain center x = 400 (800/2) + expect(headerAnchor.x).toBeCloseTo(400, 6); + expect(labelAnchor.x).toBeCloseTo(400, 6); + expect(contentAnchor.x).toBeCloseTo(400, 6); + expect(logAnchor.x).toBeCloseTo(400, 6); + + // Y positions should scale proportionally (tolerance 0.5 for non-integer results) + expect(headerAnchor.y).toBeCloseTo(50, 1); // 0.08333 * 600 ≈ 50.0 + expect(labelAnchor.y).toBeCloseTo(83.3, 0); // 0.13889 * 600 ≈ 83.3 + expect(contentAnchor.y).toBeCloseTo(166.7, 0); // 0.27778 * 600 ≈ 166.7 + expect(logAnchor.y).toBeCloseTo(316.7, 0); // 0.52778 * 600 ≈ 316.7 + }); +}); diff --git a/tests/gym/__screenshots__/GymHandPileHighlights.browser.test.ts b/tests/gym/__screenshots__/GymHandPileHighlights.browser.test.ts deleted file mode 100644 index 630d09d9..00000000 --- a/tests/gym/__screenshots__/GymHandPileHighlights.browser.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * GymHandPile highlight-zone regression test. - * - * Boots the GymHandPile scene, triggers "Show Valid" highlights, - * captures a screenshot, and verifies that the highlight zones - * are centred on the deck and discard pile graphics. - */ -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { GymHandPileScene } from '../../../example-games/gym/scenes/GymHandPileScene'; -import { waitForScene } from '../../helpers/waitForScene'; -import { CARD_W, CARD_H } from '../../../src/ui/constants'; - -async function bootGymHandPile(): Promise { - let container = document.getElementById('game-container'); - if (container) container.remove(); - container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - const game = new Phaser.Game({ - type: Phaser.AUTO, - width: 1280, - height: 720, - parent: 'game-container', - backgroundColor: '#1a2a1a', - scene: [GymHandPileScene], - }); - await waitForScene(game, 'GymHandPileScene'); - return game; -} - -function destroyGame(game: Phaser.Game | null): void { - if (game) { - game.destroy(true, false); - } - const container = document.getElementById('game-container'); - if (container) container.remove(); -} - -describe('GymHandPile highlight-zone regression', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - destroyGame(game); - game = null; - }); - - it('captures screenshot with highlight zones visible', async () => { - game = await bootGymHandPile(); - const scene = game.scene.getScene('GymHandPileScene') as Phaser.Scene; - - // Wait for scene to be ready - await new Promise((r) => setTimeout(r, 200)); - - // Trigger the "Show Valid Moves" highlight - (scene as any).showValidMoves(); - - // Wait for the delayed clear to NOT fire — capture immediately - await new Promise((r) => setTimeout(r, 100)); - - // Get the canvas and verify it has content - const canvas = scene.game.canvas; - expect(canvas).toBeDefined(); - expect(canvas.width).toBeGreaterThan(0); - expect(canvas.height).toBeGreaterThan(0); - - // Verify canvas has been drawn to (non-transparent pixels) - const ctx = canvas.getContext('2d'); - expect(ctx).toBeDefined(); - if (ctx) { - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - // Check that not all pixels are transparent (indicating something was drawn) - let hasContent = false; - for (let i = 3; i < imageData.data.length; i += 4) { - if (imageData.data[i] > 0) { - hasContent = true; - break; - } - } - expect(hasContent).toBe(true); - } - }); - - it('highlight zones overlap deck and discard pile positions', async () => { - game = await bootGymHandPile(); - const scene = game.scene.getScene('GymHandPileScene') as Phaser.Scene; - - // Wait for scene to be ready - await new Promise((r) => setTimeout(r, 200)); - - // Trigger highlights - (scene as any).showValidMoves(); - await new Promise((r) => setTimeout(r, 100)); - - const DECK_X = (scene as any).DECK_X as number; - const DISCARD_X = (scene as any).DISCARD_X as number; - const PILE_Y = (scene as any).PILE_Y as number; - const highlightW = CARD_W + 16; - const highlightH = CARD_H + 16; - - // Get deck and discard pile sprites from PileView - const deckView = (scene as any).deckView as { getSprite: () => Phaser.GameObjects.Image }; - const discardView = (scene as any).discardView as { getSprite: () => Phaser.GameObjects.Image }; - - expect(deckView).toBeDefined(); - expect(discardView).toBeDefined(); - - const deckSprite = deckView.getSprite(); - const discardSprite = discardView.getSprite(); - - expect(deckSprite).toBeDefined(); - expect(discardSprite).toBeDefined(); - - // Verify sprites are at expected positions using getBounds (Image has GetBounds mixin) - const deckBounds = deckSprite.getBounds(); - const discardBounds = discardSprite.getBounds(); - - // Sprites should be centred at DECK_X/DISCARD_X, PILE_Y - const tolerance = 5; - expect(Math.abs(deckBounds.x + deckBounds.width / 2 - DECK_X)).toBeLessThan(tolerance); - expect(Math.abs(deckBounds.y + deckBounds.height / 2 - PILE_Y)).toBeLessThan(tolerance); - expect(Math.abs(discardBounds.x + discardBounds.width / 2 - DISCARD_X)).toBeLessThan(tolerance); - expect(Math.abs(discardBounds.y + discardBounds.height / 2 - PILE_Y)).toBeLessThan(tolerance); - - // Verify highlight manager exists and its graphics have drawing commands - const highlightManager = (scene as any).highlightManager as any; - expect(highlightManager).toBeDefined(); - expect(highlightManager.graphics).toBeDefined(); - - const commandBuffer = (highlightManager.graphics as any).commandBuffer as unknown[]; - expect(Array.isArray(commandBuffer)).toBe(true); - expect(commandBuffer.length).toBeGreaterThan(0); - - // Highlight zone centres should match pile sprite centres - const expectedDeckZone = { - x: DECK_X - highlightW / 2, - y: PILE_Y - highlightH / 2, - width: highlightW, - height: highlightH, - }; - - const expectedDiscardZone = { - x: DISCARD_X - highlightW / 2, - y: PILE_Y - highlightH / 2, - width: highlightW, - height: highlightH, - }; - - // Verify the zone centres match the sprite centres (within tolerance) - const deckZoneCentreX = expectedDeckZone.x + expectedDeckZone.width / 2; - const deckZoneCentreY = expectedDeckZone.y + expectedDeckZone.height / 2; - const discardZoneCentreX = expectedDiscardZone.x + expectedDiscardZone.width / 2; - const discardZoneCentreY = expectedDiscardZone.y + expectedDiscardZone.height / 2; - - expect(Math.abs(deckZoneCentreX - (deckBounds.x + deckBounds.width / 2))).toBeLessThan(tolerance); - expect(Math.abs(deckZoneCentreY - (deckBounds.y + deckBounds.height / 2))).toBeLessThan(tolerance); - expect(Math.abs(discardZoneCentreX - (discardBounds.x + discardBounds.width / 2))).toBeLessThan(tolerance); - expect(Math.abs(discardZoneCentreY - (discardBounds.y + discardBounds.height / 2))).toBeLessThan(tolerance); - }); - - it('highlight labels are cleared when highlights are removed', async () => { - game = await bootGymHandPile(); - const scene = game.scene.getScene('GymHandPileScene') as Phaser.Scene; - - // Wait for scene to be ready - await new Promise((r) => setTimeout(r, 200)); - - // Trigger highlights - (scene as any).showValidMoves(); - await new Promise((r) => setTimeout(r, 100)); - - // Clear highlights (this is what happens after 3s delay or on next showValidMoves) - (scene as any).clearHighlights(); - await new Promise((r) => setTimeout(r, 50)); - - // Verify no orphan text objects were left behind by highlight code - const allTexts = scene.children.list.filter( - (c) => c instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; - - // Scene should still have its normal text objects (title, labels, log, buttons) - expect(allTexts.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/main-street/MainStreetHandSynergy.test.ts b/tests/main-street/MainStreetHandSynergy.test.ts index e0791e13..9a8e2260 100644 --- a/tests/main-street/MainStreetHandSynergy.test.ts +++ b/tests/main-street/MainStreetHandSynergy.test.ts @@ -47,13 +47,17 @@ function createTestState(seed: string = 'hand-synergy-test'): MainStreetState { * Creates a minimal BusinessCard for testing. */ function makeBiz(overrides: Partial = {}): BusinessCard { + const id = overrides.id ?? 'test-biz'; + const isPawnShop = id.startsWith('biz-pawnshop-'); return { family: 'business', - id: overrides.id ?? 'test-biz', + id, name: overrides.name ?? 'Test Biz', cost: overrides.cost ?? 3, baseIncome: overrides.baseIncome ?? 2, synergyTypes: overrides.synergyTypes ?? ['Food'], + synergyCoinBonus: overrides.synergyCoinBonus ?? (isPawnShop ? 0 : 1), + synergyRepBonus: overrides.synergyRepBonus ?? (isPawnShop ? 0 : 0), maxLevel: overrides.maxLevel ?? 1, description: overrides.description ?? 'A test business', level: overrides.level ?? 0, diff --git a/tests/main-street/activity-log.test.ts b/tests/main-street/activity-log.test.ts index 07487535..731c1662 100644 --- a/tests/main-street/activity-log.test.ts +++ b/tests/main-street/activity-log.test.ts @@ -282,7 +282,8 @@ describe('Activity Log', () => { const entry = state.activityLog[logBefore]; expect(entry.type).toBe('gain'); expect(entry.text).toContain('Income'); - expect(entry.text).toContain('+5 coins'); + // CG-0MREYZO7E00729S0: fractional coin format uses 3 decimal places + expect(entry.text).toMatch(/\+\d+\.\d{3} coins/); }); it('should log neutral when income is zero', () => { @@ -296,7 +297,8 @@ describe('Activity Log', () => { const entry = state.activityLog[logBefore]; expect(entry.type).toBe('neutral'); - expect(entry.text).toContain('+0 coins'); + // CG-0MREYZO7E00729S0: zero income logged as +0.000 coins + expect(entry.text).toContain('0.000 coins'); }); }); diff --git a/tests/main-street/adjacency.test.ts b/tests/main-street/adjacency.test.ts index de29028e..169b112f 100644 --- a/tests/main-street/adjacency.test.ts +++ b/tests/main-street/adjacency.test.ts @@ -33,6 +33,7 @@ function makeBiz(overrides: Partial = {}): BusinessCard { incomeBonus: overrides.incomeBonus ?? 0, synergyRangeBonus: overrides.synergyRangeBonus ?? 0, reputationBonus: overrides.reputationBonus ?? 0, + ...overrides, }; } @@ -131,7 +132,7 @@ describe('MainStreetAdjacency (2x5 grid)', () => { const grid = emptyGrid(); // Hardware Store (Commerce) adjacent to Pawn Shop (Commerce) grid[0] = makeBiz({ id: 'biz-hardware-0', name: 'Hardware Store', synergyTypes: ['Commerce'] }); - grid[1] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'] }); + grid[1] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); // Hardware Store should NOT receive synergy from Pawn Shop expect(computeSynergyBonus(grid, 0)).toBe(0); }); @@ -139,7 +140,7 @@ describe('MainStreetAdjacency (2x5 grid)', () => { it('Pawn Shop does not receive synergy from adjacent Commerce businesses', () => { const grid = emptyGrid(); // Pawn Shop next to Hardware Store - grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'] }); + grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); grid[1] = makeBiz({ id: 'biz-hardware-0', name: 'Hardware Store', synergyTypes: ['Commerce'] }); // Pawn Shop should NOT receive synergy from Hardware Store expect(computeSynergyBonus(grid, 0)).toBe(0); @@ -147,8 +148,8 @@ describe('MainStreetAdjacency (2x5 grid)', () => { it('multiple Pawn Shops do not contribute synergy to each other', () => { const grid = emptyGrid(); - grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'] }); - grid[1] = makeBiz({ id: 'biz-pawnshop-1', name: 'Pawn Shop', synergyTypes: ['Commerce'] }); + grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); + grid[1] = makeBiz({ id: 'biz-pawnshop-1', name: 'Pawn Shop', synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); // Neither Pawn Shop contributes synergy to the other expect(computeSynergyBonus(grid, 0)).toBe(0); expect(computeSynergyBonus(grid, 1)).toBe(0); @@ -161,6 +162,7 @@ describe('MainStreetAdjacency (2x5 grid)', () => { id: 'biz-pawnshop-0', name: 'Pawn Shop', synergyTypes: ['Commerce'], + synergyCoinBonus: 0, level: 1, incomeBonus: 1, appliedUpgrades: ['upg-vintage-shop-0'], @@ -191,7 +193,7 @@ describe('MainStreetAdjacency (2x5 grid)', () => { it('Pawn Shop generates only base income with no synergy', () => { const grid = emptyGrid(); // Pawn Shop with Commerce neighbors — should only get base income - grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', baseIncome: 1, synergyTypes: ['Commerce'] }); + grid[0] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', baseIncome: 1, synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); grid[1] = makeBiz({ id: 'biz-hardware-0', name: 'Hardware Store', baseIncome: 1, synergyTypes: ['Commerce'] }); expect(computeBusinessIncome(grid, 0)).toBe(1); // base only, no synergy }); @@ -223,7 +225,7 @@ describe('MainStreetAdjacency (2x5 grid)', () => { it('Pawn Shop shows 0 synergy bonus in income breakdown', () => { const grid = emptyGrid(); // Pawn Shop adjacent to two Commerce businesses - grid[5] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', baseIncome: 1, synergyTypes: ['Commerce'] }); + grid[5] = makeBiz({ id: 'biz-pawnshop-0', name: 'Pawn Shop', baseIncome: 1, synergyTypes: ['Commerce'], synergyCoinBonus: 0 }); grid[4] = makeBiz({ id: 'biz-hardware-0', name: 'Hardware Store', baseIncome: 1, synergyTypes: ['Commerce'] }); grid[6] = makeBiz({ id: 'biz-boutique-0', name: 'Boutique', baseIncome: 1, synergyTypes: ['Commerce'] }); @@ -246,7 +248,9 @@ describe('MainStreetAdjacency (2x5 grid)', () => { const result = applyIncome(state); expect(result.total).toBe(7); // 4 + 3 pre-multiplier - expect(state.resourceBank.coins).toBe(coinsBefore + 8); // medium default multiplier floor(7 * 1.15) + // CG-0MRER3RE300418SG: Math.floor removed; fractional values preserved. + // 7 * 1.15 = 8.05 (was floor(8.05)=8 before fix) + expect(state.resourceBank.coins).toBeCloseTo(coinsBefore + 8.05); }); }); }); diff --git a/tests/main-street/balance-cards.test.ts b/tests/main-street/balance-cards.test.ts new file mode 100644 index 00000000..e6ad518d --- /dev/null +++ b/tests/main-street/balance-cards.test.ts @@ -0,0 +1,575 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + RationaleCode, + rationaleLabel, + isValidRationaleCode, + getAllRationaleCodes, + computeBusinessExpectedCost, + computeEventExpectedCost, + computeUpgradeExpectedCost, + computeStaffExpectedCost, + assignTierBands, + computeBusinessRewardSpread, + computeEventRewardSpread, + computeUpgradeRewardSpread, + computeStaffRewardSpread, + runBalancingPass, + TIER_BANDS, + parseCsv, + validateRow, + validateCsvRows, + rotateBackups, + listBackups, + formatSummaryTable, + toCsvString, + type CsvRow, +} from '../../scripts/balance-cards'; + +// ── Test helpers ────────────────────────────────────────────────────── + +function makeBusinessRow(overrides: Partial> = {}): CsvRow { + return { + family: 'business', + id: 'test-biz', + name: 'Test Business', + cost: '6', + baseIncome: '1', + synergyTypes: 'Food', + upgradePath: '', + maxLevel: '1', + reputationPerTurn: '', + description: '', + tier: '1', + ...overrides, + }; +} + +function makeEventRow(overrides: Partial> = {}): CsvRow { + return { + family: 'event', + id: 'test-event', + name: 'Test Event', + cost: '2', + baseIncome: '', + synergyTypes: '', + upgradePath: '', + maxLevel: '', + reputationPerTurn: '', + description: '', + trigger: 'Investment', + effect: '', + target: '', + targetSynergy: '', + coinDelta: '2', + reputationDelta: '1', + tier: '1', + ...overrides, + }; +} + +function makeUpgradeRow(overrides: Partial> = {}): CsvRow { + return { + family: 'upgrade', + id: 'test-upgrade', + name: 'Test Upgrade', + cost: '3', + baseIncome: '', + synergyTypes: '', + upgradePath: '', + maxLevel: '', + reputationPerTurn: '', + description: '', + targetBusiness: 'Bakery', + incomeBonus: '1', + synergyRangeBonus: '1', + requiredLevel: '0', + reputationBonus: '', + tier: '1', + ...overrides, + }; +} + +function makeStaffRow(overrides: Partial> = {}): CsvRow { + return { + family: 'staff', + id: 'test-staff', + name: 'Test Staff', + cost: '3', + baseIncome: '', + synergyTypes: '', + upgradePath: '', + maxLevel: '', + reputationPerTurn: '', + description: '', + ongoingCost: '1', + handSlotsAdded: '1', + tier: '1', + ...overrides, + }; +} + +// ── Rationale code tests (AC2) ────────────────────────────────────── + +describe('Rationale Code Enum', () => { + it('contains all expected rationale codes', () => { + const expected = [ + 'TIER_REASSIGN', 'COST_CURVE_FIT', 'REWARD_SPREAD', 'BAND_BALANCE', + 'INCIDENT_FREE', 'MIN_COST_FLOOR', 'MAX_COST_CEIL', 'SPECIAL_CASE', + 'SYNERGY_BONUS_ADJ', 'INCOME_ADJUST', 'REPUTATION_ADJ', + 'ONGOING_COST_ADJ', 'HAND_SLOT_ADJ', 'SCOPE_ADJ', + ]; + const actual = Object.values(RationaleCode); + expect(actual.sort()).toEqual(expected.sort()); + }); + + it('has a human-readable label for every code', () => { + for (const code of getAllRationaleCodes()) { + const label = rationaleLabel(code); + expect(label).toBeDefined(); + expect(label.length).toBeGreaterThan(0); + expect(label).not.toContain('Unknown'); + } + }); + + it('isValidRationaleCode returns true for known codes', () => { + for (const code of getAllRationaleCodes()) { + expect(isValidRationaleCode(code)).toBe(true); + } + }); + + it('isValidRationaleCode returns false for unknown codes', () => { + expect(isValidRationaleCode('UNKNOWN_CODE')).toBe(false); + expect(isValidRationaleCode('')).toBe(false); + expect(isValidRationaleCode('tier_reassign')).toBe(false); + }); +}); + +// ── Cost curve tests (AC1a) ───────────────────────────────────────── + +describe('Cost Curve: Business', () => { + it('returns a positive cost for a basic business', () => { + const row = makeBusinessRow({ cost: '6', baseIncome: '1' }); + expect(computeBusinessExpectedCost(row)).toBeGreaterThan(0); + }); + + it('gives higher expected cost for higher-income business', () => { + const low = makeBusinessRow({ baseIncome: '0' }); + const high = makeBusinessRow({ baseIncome: '2' }); + expect(computeBusinessExpectedCost(high)).toBeGreaterThan( + computeBusinessExpectedCost(low), + ); + }); + + it('accounts for multi-synergy cards', () => { + const single = makeBusinessRow({ synergyTypes: 'Food' }); + const dual = makeBusinessRow({ synergyTypes: 'Food|Culture' }); + expect(computeBusinessExpectedCost(dual)).toBeGreaterThan( + computeBusinessExpectedCost(single), + ); + }); + + it('accounts for synergy coin/rep bonuses', () => { + const noBonus = makeBusinessRow({}); + const withBonus = makeBusinessRow({ synergyCoinBonus: '1', synergyRepBonus: '0.1' }); + expect(computeBusinessExpectedCost(withBonus)).toBeGreaterThan( + computeBusinessExpectedCost(noBonus), + ); + }); + + it('accounts for reputation per turn', () => { + const noRep = makeBusinessRow({ reputationPerTurn: '' }); + const withRep = makeBusinessRow({ reputationPerTurn: '0.2' }); + expect(computeBusinessExpectedCost(withRep)).toBeGreaterThan( + computeBusinessExpectedCost(noRep), + ); + }); + + it('is deterministic for identical inputs', () => { + const row = makeBusinessRow({ cost: '8', baseIncome: '1', synergyTypes: 'Commerce|Culture' }); + for (let i = 0; i < 10; i++) { + expect(computeBusinessExpectedCost(row)).toBe(computeBusinessExpectedCost(row)); + } + }); +}); + +describe('Cost Curve: Community Space', () => { + it('applies the same curve as businesses', () => { + const cs = makeBusinessRow({ family: 'community-space', baseIncome: '1' }); + expect(computeBusinessExpectedCost(cs)).toBeGreaterThan(0); + }); +}); + +describe('Cost Curve: Investment Events', () => { + it('returns a positive cost for an investment event', () => { + const row = makeEventRow({ coinDelta: '2', reputationDelta: '1' }); + expect(computeEventExpectedCost(row)).toBeGreaterThan(0); + }); + + it('gives higher cost for larger coin delta', () => { + const small = makeEventRow({ coinDelta: '1', reputationDelta: '0' }); + const large = makeEventRow({ coinDelta: '3', reputationDelta: '0' }); + expect(computeEventExpectedCost(large)).toBeGreaterThan( + computeEventExpectedCost(small), + ); + }); + + it('applies scope multiplier: SpecificSynergy costs more', () => { + const allScope = makeEventRow({ coinDelta: '2', reputationDelta: '0', targetSynergy: 'All' }); + const specific = makeEventRow({ coinDelta: '2', reputationDelta: '0', targetSynergy: 'Culture' }); + expect(computeEventExpectedCost(specific)).toBeGreaterThan( + computeEventExpectedCost(allScope), + ); + }); +}); + +describe('Cost Curve: Upgrades', () => { + it('returns a positive cost for a basic upgrade', () => { + expect(computeUpgradeExpectedCost(makeUpgradeRow({ incomeBonus: '1' }))).toBeGreaterThan(0); + }); + + it('accounts for income bonus', () => { + const low = makeUpgradeRow({ incomeBonus: '0' }); + const high = makeUpgradeRow({ incomeBonus: '2' }); + expect(computeUpgradeExpectedCost(high)).toBeGreaterThan(computeUpgradeExpectedCost(low)); + }); + + it('accounts for synergy range bonus', () => { + const noRange = makeUpgradeRow({ synergyRangeBonus: '0' }); + const withRange = makeUpgradeRow({ synergyRangeBonus: '1' }); + expect(computeUpgradeExpectedCost(withRange)).toBeGreaterThan(computeUpgradeExpectedCost(noRange)); + }); + + it('accounts for reputation bonus', () => { + const noRep = makeUpgradeRow({ reputationBonus: '' }); + const withRep = makeUpgradeRow({ reputationBonus: '0.1' }); + expect(computeUpgradeExpectedCost(withRep)).toBeGreaterThan(computeUpgradeExpectedCost(noRep)); + }); +}); + +describe('Cost Curve: Staff', () => { + it('returns a positive cost for basic staff', () => { + expect(computeStaffExpectedCost(makeStaffRow({ ongoingCost: '1', handSlotsAdded: '1' }))).toBeGreaterThan(0); + }); + + it('gives higher cost for more hand slots', () => { + const low = makeStaffRow({ handSlotsAdded: '1', ongoingCost: '1' }); + const high = makeStaffRow({ handSlotsAdded: '3', ongoingCost: '3' }); + expect(computeStaffExpectedCost(high)).toBeGreaterThan(computeStaffExpectedCost(low)); + }); +}); + +// ── Tier band assignment (AC1b) ───────────────────────────────────── + +describe('Tier Band Assignment', () => { + it('assigns cards to bands based on cost percentile', () => { + const cards = [ + { id: 'c1', expectedCost: 2, currentCost: 4 }, + { id: 'c2', expectedCost: 8, currentCost: 8 }, + { id: 'c3', expectedCost: 12, currentCost: 10 }, + ]; + const result = assignTierBands(cards, 'business'); + expect(result.has('c1')).toBe(true); + expect(result.has('c3')).toBe(true); + const budget = result.get('c1')!; + const flagship = result.get('c3')!; + expect(budget.adjustedCost).toBeLessThanOrEqual(TIER_BANDS.budget.max); + expect(flagship.adjustedCost).toBeGreaterThanOrEqual(TIER_BANDS.flagship.min); + }); + + it('produces costs within band limits', () => { + const cards = [ + { id: 'c1', expectedCost: 0, currentCost: 2 }, + { id: 'c2', expectedCost: 15, currentCost: 10 }, + ]; + const result = assignTierBands(cards, 'business'); + for (const [, a] of result) { + expect(a.adjustedCost).toBeGreaterThanOrEqual(TIER_BANDS[a.band].min); + expect(a.adjustedCost).toBeLessThanOrEqual(TIER_BANDS[a.band].max); + } + }); + + it('is deterministic', () => { + const cards = [ + { id: 'a', expectedCost: 3, currentCost: 4 }, + { id: 'b', expectedCost: 7, currentCost: 8 }, + ]; + const r1 = assignTierBands(cards, 'business'); + const r2 = assignTierBands(cards, 'business'); + expect(r1.get('a')).toEqual(r2.get('a')); + expect(r1.get('b')).toEqual(r2.get('b')); + }); +}); + +// ── Reward spread (AC1c) ───────────────────────────────────────────── + +describe('Reward Spread', () => { + it('Business: adjusts income when cost increases', () => { + const row = makeBusinessRow({ baseIncome: '2' }); + const result = computeBusinessRewardSpread(row, 8, 4); + if (result.baseIncome) expect(result.baseIncome).toBeGreaterThan(2); + }); + + it('Event: adjusts coinDelta when cost increases', () => { + const row = makeEventRow({ coinDelta: '2', reputationDelta: '1' }); + const result = computeEventRewardSpread(row, 4, 2); + if (result.coinDelta) expect(result.coinDelta).toBeGreaterThan(2); + }); + + it('Upgrade: adjusts incomeBonus when cost increases', () => { + const row = makeUpgradeRow({ incomeBonus: '1' }); + const result = computeUpgradeRewardSpread(row, 5, 3); + if (result.incomeBonus) expect(result.incomeBonus).toBeGreaterThan(1); + }); + + it('Staff: returns BAND_BALANCE when no change', () => { + const row = makeStaffRow({ ongoingCost: '1' }); + const result = computeStaffRewardSpread(row, 3, 3); + expect(result.rationale).toBe('BAND_BALANCE'); + }); + + it('all families return a valid rationale', () => { + const biz = computeBusinessRewardSpread(makeBusinessRow({ baseIncome: '1' }), 6, 6); + expect(biz.rationale).toBeDefined(); + + const evt = computeEventRewardSpread(makeEventRow({ coinDelta: '2' }), 2, 2); + expect(evt.rationale).toBeDefined(); + + const upg = computeUpgradeRewardSpread(makeUpgradeRow({ incomeBonus: '1' }), 3, 3); + expect(upg.rationale).toBeDefined(); + + const stf = computeStaffRewardSpread(makeStaffRow({ ongoingCost: '1' }), 3, 3); + expect(stf.rationale).toBeDefined(); + }); +}); + +// ── CSV validation (AC4) ──────────────────────────────────────────── + +describe('CSV Validation', () => { + it('rejects rows missing card id', () => { + const errors = validateRow(makeBusinessRow({ id: '' }), 1); + expect(errors.some(e => e.includes('missing card id'))).toBe(true); + }); + + it('rejects rows with non-numeric cost', () => { + const errors = validateRow(makeBusinessRow({ cost: 'abc' }), 1); + expect(errors.some(e => e.includes('non-numeric'))).toBe(true); + }); + + it('rejects rows with unknown family', () => { + const errors = validateRow(makeBusinessRow({ family: 'unknown' }), 1); + expect(errors.some(e => e.includes('unknown family'))).toBe(true); + }); + + it('accepts a well-formed row', () => { + expect(validateRow(makeBusinessRow({ id: 'v', cost: '6', baseIncome: '1' }), 1)).toEqual([]); + }); + + it('validateCsvRows throws for invalid rows', () => { + expect(() => validateCsvRows([makeBusinessRow({ id: '' })])).toThrow('CSV validation failed'); + }); + + it('parseCsv throws for empty content', () => { + expect(() => parseCsv('')).toThrow('must have a header row'); + }); + + it('parseCsv correctly parses a real CSV row', () => { + const csv = 'family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,ongoingCost,handSlotsAdded\nbusiness,biz-test,Test,6,1,Food,,1,,,,,,,,,,,,,,,,,'; + const rows = parseCsv(csv); + expect(rows.length).toBe(1); + expect(rows[0].id).toBe('biz-test'); + expect(rows[0].cost).toBe('6'); + }); + + it('toCsvString round-trips correctly', () => { + const csv = 'family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,ongoingCost,handSlotsAdded\nbusiness,biz-test,Test,6,1,Food,,1,,,,,,,,,,,,,,,,'; + const rows = parseCsv(csv); + const output = toCsvString(rows); + const reparsed = parseCsv(output); + expect(reparsed.length).toBe(1); + expect(reparsed[0].id).toBe(rows[0].id); + expect(reparsed[0].cost).toBe(rows[0].cost); + }); +}); + +// ── Backup rotation (AC5) ─────────────────────────────────────────── + +describe('Backup Rotation', () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = join(tmpdir(), `balance-bak-test-${Date.now()}`); + try { mkdirSync(tmpDir, { recursive: true }); } catch { /* ok */ } + }); + + afterAll(() => { + try { + const { readdirSync, rmdirSync } = require('node:fs'); + const files = readdirSync(tmpDir); + for (const f of files) try { unlinkSync(join(tmpDir, f)); } catch { /* ok */ } + try { rmdirSync(tmpDir); } catch { /* ok */ } + } catch { /* ok */ } + }); + + function createFile(name: string, content: string): string { + const p = join(tmpDir, name); + writeFileSync(p, content, 'utf-8'); + return p; + } + + it('creates a .bak.1 file', () => { + const path = createFile('r1.csv', 'a'); + rotateBackups(path); + expect(existsSync(`${path}.bak.1`)).toBe(true); + }); + + it('shifts existing backups', () => { + const path = createFile('r2.csv', 'v1'); + rotateBackups(path); + writeFileSync(path, 'v2'); + rotateBackups(path); + expect(existsSync(`${path}.bak.2`)).toBe(true); + expect(readFileSync(`${path}.bak.2`, 'utf-8')).toBe('v1'); + }); + + it('handles non-existent file gracefully', () => { + const path = join(tmpDir, 'nonexist.csv'); + expect(() => rotateBackups(path)).not.toThrow(); + expect(listBackups(path)).toEqual([]); + }); + + it('listBackups finds existing backups', () => { + const path = createFile('list.csv', 'x'); + writeFileSync(`${path}.bak.1`, '1'); + writeFileSync(`${path}.bak.3`, '3'); + const backups = listBackups(path); + expect(backups).toContain(`${path}.bak.1`); + expect(backups).toContain(`${path}.bak.3`); + expect(backups.length).toBe(2); + }); +}); + +// ── Summary table formatting (AC6) ──────────────────────────────────── + +describe('Summary Table Formatting', () => { + it('includes header text', () => { + const s = formatSummaryTable([], [], 18); + expect(s).toContain('BALANCING PASS SUMMARY'); + }); + + it('includes per-family ranges', () => { + const s = formatSummaryTable([], [ + { family: 'business', cardsAdjusted: 5, totalCards: 18, + oldCostMin: 4, oldCostMax: 10, newCostMin: 2, newCostMax: 12, + oldRewardMin: 0, oldRewardMax: 2, newRewardMin: 0, newRewardMax: 3 }, + ], 18); + expect(s).toContain('business'); + expect(s).toContain('[4-10]'); + expect(s).toContain('[2-12]'); + }); + + it('includes rationale codes when adjustments exist', () => { + const s = formatSummaryTable([ + { cardId: 'biz-1', cardName: 'Shop', family: 'business', + field: 'cost', oldValue: 4, newValue: 6, rationale: 'TIER_REASSIGN' }, + ], [], 1); + expect(s).toContain('─── Rationale Codes ───'); + expect(s).toContain('TIER_REASSIGN'); + }); +}); + +// ── Deterministic output (AC3) ────────────────────────────────────── + +describe('Deterministic Output', () => { + it('produces identical results on repeated calls', () => { + const rows: CsvRow[] = [ + makeBusinessRow({ id: 'b1', name: 'Bakery', cost: '6', baseIncome: '1' }), + makeBusinessRow({ id: 'b2', name: 'Diner', cost: '8', baseIncome: '1' }), + makeEventRow({ id: 'e1', name: 'Fest', trigger: 'Investment', coinDelta: '2', reputationDelta: '1' }), + makeEventRow({ id: 'e2', name: 'Rain', trigger: 'Incident', coinDelta: '-1', reputationDelta: '0' }), + makeUpgradeRow({ id: 'u1', name: 'Patisserie', incomeBonus: '1' }), + makeStaffRow({ id: 's1', name: 'Asst', ongoingCost: '1', handSlotsAdded: '1' }), + ]; + const r1 = runBalancingPass(rows); + const r2 = runBalancingPass(rows); + expect(r1.rows.length).toBe(r2.rows.length); + expect(r1.adjustments.length).toBe(r2.adjustments.length); + expect(r1.summaries.length).toBe(r2.summaries.length); + expect(toCsvString(r1.rows)).toBe(toCsvString(r2.rows)); + }); + + it('preserves row count and order', () => { + const rows: CsvRow[] = [ + makeBusinessRow({ id: 'a', name: 'A', cost: '4' }), + makeBusinessRow({ id: 'b', name: 'B', cost: '8' }), + makeEventRow({ id: 'c', name: 'C', trigger: 'Investment' }), + ]; + const result = runBalancingPass(rows); + expect(result.rows.length).toBe(3); + expect(result.rows[0].id).toBe('a'); + expect(result.rows[1].id).toBe('b'); + expect(result.rows[2].id).toBe('c'); + }); +}); + +// ── All 5 families (integration) ────────────────────────────────────── + +describe('All 5 Card Families', () => { + it('processes Business cards', () => { + const rows = [ + makeBusinessRow({ id: 'b1', name: 'B1', cost: '4', baseIncome: '0' }), + ]; + const r = runBalancingPass(rows); + expect(r.summaries.find(s => s.family === 'business')).toBeDefined(); + }); + + it('processes Community Space cards', () => { + const rows = [ + makeBusinessRow({ family: 'community-space', id: 'cs1', name: 'P', cost: '4', baseIncome: '0' }), + ]; + const r = runBalancingPass(rows); + expect(r.summaries.find(s => s.family === 'community-space')).toBeDefined(); + }); + + it('processes Events (Investment + Incident)', () => { + const rows = [ + makeEventRow({ id: 'i', name: 'I', trigger: 'Investment', cost: '3', coinDelta: '2' }), + makeEventRow({ id: 'c', name: 'Inc', trigger: 'Incident', cost: '0' }), + ]; + const r = runBalancingPass(rows); + expect(r.summaries.find(s => s.family === 'event')).toBeDefined(); + expect(r.rows.find(r => r.id === 'c')?.cost).toBe('0'); + expect(r.adjustments.some(a => a.rationale === 'INCIDENT_FREE')).toBe(true); + }); + + it('processes Upgrade cards', () => { + const rows = [ + makeUpgradeRow({ id: 'u1', name: 'U1', cost: '4', incomeBonus: '1' }), + ]; + const r = runBalancingPass(rows); + expect(r.summaries.find(s => s.family === 'upgrade')).toBeDefined(); + }); + + it('processes Staff cards', () => { + const rows = [ + makeStaffRow({ id: 's1', name: 'S1', cost: '3', ongoingCost: '1', handSlotsAdded: '1' }), + ]; + const r = runBalancingPass(rows); + expect(r.summaries.find(s => s.family === 'staff')).toBeDefined(); + }); +}); + +// ── Incident events remain free ────────────────────────────────────── + +describe('Incident Events', () => { + it('remain at cost 0 through balancing pass', () => { + const rows = [ + makeEventRow({ id: 'incident', name: 'Rainy', trigger: 'Incident', cost: '0', coinDelta: '-2' }), + ]; + const r = runBalancingPass(rows); + expect(r.rows[0].cost).toBe('0'); + }); +}); diff --git a/tests/main-street/card-svg-generator.test.ts b/tests/main-street/card-svg-generator.test.ts new file mode 100644 index 00000000..839dff72 --- /dev/null +++ b/tests/main-street/card-svg-generator.test.ts @@ -0,0 +1,192 @@ +/** + * Unit tests for MainStreetCardSvgGenerator — generates SVG strings + * dynamically for BusinessCard and CommunitySpaceCard based on state. + * + * These tests run in any JS environment (no Phaser dependency). + */ + +import { describe, it, expect } from 'vitest'; +import { generateBusinessCardSvg } from '../../example-games/main-street/scenes/MainStreetCardSvgGenerator'; +import type { BusinessCard, CommunitySpaceCard } from '../../example-games/main-street/MainStreetCards'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const CARD_W = 140; +const CARD_H = 80; + +function makeBiz(overrides: Partial = {}): BusinessCard { + return { + family: 'business', + id: 'biz-test-0', + name: 'Test Business', + cost: 6, + baseIncome: 1, + synergyTypes: ['Food'], + maxLevel: 2, + description: 'A test business', + level: 0, + incomeBonus: 0, + synergyRangeBonus: 0, + reputationBonus: 0, + appliedUpgrades: [], + ...overrides, + }; +} + +function makeCommunitySpace(overrides: Partial = {}): CommunitySpaceCard { + return { + family: 'community-space', + id: 'cs-test-0', + name: 'Test Community Space', + cost: 4, + baseIncome: 0, + synergyTypes: ['Culture'], + maxLevel: 1, + description: 'A test community space', + level: 0, + incomeBonus: 0, + synergyRangeBonus: 0, + reputationBonus: 0, + appliedUpgrades: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('generateBusinessCardSvg - base cards (level === 0)', () => { + it('should include the card name as title', () => { + const biz = makeBiz({ name: 'Bakery', level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Bakery'); + }); + + it('should show income text with Income: prefix when baseIncome > 0', () => { + const biz = makeBiz({ baseIncome: 2, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Income: +2/turn'); + }); + + it('should omit income text when total income is 0', () => { + const biz = makeBiz({ baseIncome: 0, incomeBonus: 0, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).not.toContain('+0/turn'); + expect(svg).not.toContain('Income: +0/turn'); + }); + + it('should show reputation text when reputationPerTurn > 0', () => { + const biz = makeBiz({ reputationPerTurn: 0.2, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('+0.2/turn'); + }); + + it('should omit reputation text when total reputation is 0', () => { + const biz = makeBiz({ baseIncome: 1, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).not.toMatch(/reputation.*0\.0\/turn/); + expect(svg).not.toMatch(/\+0\.0\/turn/); + }); + + it('should not show level badge for base cards', () => { + const biz = makeBiz({ level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).not.toContain('Lvl 0'); + }); + + it('should include cost value', () => { + const biz = makeBiz({ cost: 6, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('>6<'); + }); + + it('should include synergy icon', () => { + const biz = makeBiz({ synergyTypes: ['Food'], level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Food icon'); + }); +}); + +describe('generateBusinessCardSvg - upgraded cards (level > 0)', () => { + it('should show updated name on upgrade', () => { + const biz = makeBiz({ name: 'Patisserie', level: 1 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Patisserie'); + }); + + it('should show level badge for upgraded cards', () => { + const biz = makeBiz({ level: 2 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Lvl 2'); + }); + + it('should show combined income (base + bonus)', () => { + const biz = makeBiz({ baseIncome: 1, incomeBonus: 2, level: 1 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('Income: +3/turn'); + }); + + it('should show combined reputation (base + bonus)', () => { + const biz = makeBiz({ reputationPerTurn: 0.2, reputationBonus: 0.1, level: 1 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('+0.3/turn'); + }); + + it('should show cost value', () => { + const biz = makeBiz({ cost: 4, level: 1 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('>4<'); + }); +}); + +describe('generateBusinessCardSvg - community space cards', () => { + it('should include community space name', () => { + const cs = makeCommunitySpace({ name: 'Park', level: 0 }); + const svg = generateBusinessCardSvg(cs, CARD_W, CARD_H); + expect(svg).toContain('Park'); + }); + + it('should show income for community spaces with income', () => { + const cs = makeCommunitySpace({ baseIncome: 1, level: 0 }); + const svg = generateBusinessCardSvg(cs, CARD_W, CARD_H); + expect(svg).toContain('Income: +1/turn'); + }); + + it('should show reputation for community spaces with reputation', () => { + const cs = makeCommunitySpace({ reputationPerTurn: 0.1, level: 0 }); + const svg = generateBusinessCardSvg(cs, CARD_W, CARD_H); + expect(svg).toContain('+0.1/turn'); + }); + + it('should show level badge when upgraded', () => { + const cs = makeCommunitySpace({ level: 1, baseIncome: 1 }); + const svg = generateBusinessCardSvg(cs, CARD_W, CARD_H); + expect(svg).toContain('Lvl 1'); + }); +}); + +describe('generateBusinessCardSvg - formatting', () => { + it('should format integer reputation without decimal', () => { + const biz = makeBiz({ reputationPerTurn: 1.0, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('+1/turn'); + expect(svg).not.toContain('+1.0/turn'); + }); + + it('should format fractional reputation with one decimal', () => { + const biz = makeBiz({ reputationPerTurn: 0.3, level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain('+0.3/turn'); + }); + + it('should produce valid SVG with proper XML structure', () => { + const biz = makeBiz({ level: 0 }); + const svg = generateBusinessCardSvg(biz, CARD_W, CARD_H); + expect(svg).toContain(''); + }); +}); diff --git a/tests/main-street/community-space-tooltip.test.ts b/tests/main-street/community-space-tooltip.test.ts index 45ddb8b2..abb51305 100644 --- a/tests/main-street/community-space-tooltip.test.ts +++ b/tests/main-street/community-space-tooltip.test.ts @@ -19,7 +19,6 @@ import { createBusinessDeck, type CommunitySpaceCard, type BusinessCard, - isPawnShopCard, } from '../../example-games/main-street/MainStreetCards'; // ── Deck Data ──────────────────────────────────────────────── @@ -34,13 +33,12 @@ const businessDeck = createBusinessDeck(1); * format used in MainStreetRenderer.drawMarketCard for business cards. */ function buildCommunitySpaceTooltip(card: CommunitySpaceCard): string { - const synergyNote = isPawnShopCard(card) ? ' (excluded from synergy)' : ''; const income = card.baseIncome + (card.incomeBonus || 0); return [ `Community Space: ${card.name}`, `Cost: ${card.cost}`, `Income: +${income}/turn`, - `Synergy: ${card.synergyTypes.join('/')}${synergyNote}`, + `Synergy: ${card.synergyTypes.join('/')}`, card.description ?? '', ].join('\n'); } @@ -50,13 +48,12 @@ function buildCommunitySpaceTooltip(card: CommunitySpaceCard): string { * in MainStreetRenderer.drawMarketCard. */ function buildBusinessTooltip(card: BusinessCard): string { - const synergyNote = isPawnShopCard(card) ? ' (excluded from synergy)' : ''; const income = card.baseIncome + (card.incomeBonus || 0); return [ `Business: ${card.name}`, `Cost: ${card.cost}`, `Income: +${income}/turn`, - `Synergy: ${card.synergyTypes.join('/')}${synergyNote}`, + `Synergy: ${card.synergyTypes.join('/')}`, card.description ?? '', ].join('\n'); } @@ -198,9 +195,8 @@ describe('Community space card tooltip content (AC2)', () => { } }); - it('tooltip does not reference isPawnShopCard exclusion for community spaces', () => { + it('tooltip does not reference synergy exclusion message for community spaces', () => { const park = communitySpaceDeck.find(c => c.name === 'Park')!; - expect(isPawnShopCard(park)).toBe(false); const tooltip = buildCommunitySpaceTooltip(park); expect(tooltip).not.toContain('excluded from synergy'); diff --git a/tests/main-street/community-space-types.test.ts b/tests/main-street/community-space-types.test.ts index d954f54e..371d93c2 100644 --- a/tests/main-street/community-space-types.test.ts +++ b/tests/main-street/community-space-types.test.ts @@ -66,6 +66,8 @@ function createCommunitySpaceFixture(overrides?: Record): Recor cost: 5, baseIncome: 1, synergyTypes: ['Culture'] as readonly SynergyType[], + synergyCoinBonus: undefined, + synergyRepBonus: undefined, upgradePath: 'Test Path', maxLevel: 1, description: 'A test community space card.', @@ -91,11 +93,14 @@ function makeCommunitySpaceBiz(overrides?: Record): BusinessCar cost: 5, baseIncome: 1, synergyTypes: ['Culture'] as readonly SynergyType[], + synergyCoinBonus: 1, + synergyRepBonus: 0, maxLevel: 1, description: 'A community space card on the grid.', level: 0, incomeBonus: 0, synergyRangeBonus: 0, + reputationBonus: 0, ...overrides, } as unknown as BusinessCard; } @@ -103,7 +108,8 @@ function makeCommunitySpaceBiz(overrides?: Record): BusinessCar /** Expected fields that CommunitySpaceCard should share with BusinessCard (excluding family). */ const BUSINESS_CARD_FIELDS = [ 'id', 'name', 'cost', 'baseIncome', 'synergyTypes', 'upgradePath', - 'maxLevel', 'description', 'level', 'incomeBonus', 'synergyRangeBonus', 'appliedUpgrades', + 'maxLevel', 'description', 'level', 'incomeBonus', 'synergyRangeBonus', + 'synergyCoinBonus', 'synergyRepBonus', 'appliedUpgrades', ] as const; // ── AC1: CardFamily type union includes 'community-space' ──── diff --git a/tests/main-street/incident-queue-card-aspect.test.ts b/tests/main-street/incident-queue-card-aspect.test.ts new file mode 100644 index 00000000..98e9e616 --- /dev/null +++ b/tests/main-street/incident-queue-card-aspect.test.ts @@ -0,0 +1,68 @@ +/** + * Tests for incident queue card aspect ratio fix. + * + * Verifies that: + * 1. BASE_QUEUE_CARD_W and BASE_QUEUE_CARD_H are set to 120×69 + * 2. The layout adapter returns correct queueCardW/queueCardH dimensions + * 3. The renderer's refreshIncidentQueue uses layout-provided dimensions (not hardcoded values) + * 4. The SVG texture manager prewarms queue card textures + */ + +import { describe, expect, it } from 'vitest'; + +import { + BASE_QUEUE_CARD_W, + BASE_QUEUE_CARD_H, +} from '../../example-games/main-street/scenes/MainStreetConstants'; +import { computeMainStreetLayoutWithSll } from '../../example-games/main-street/scenes/MainStreetLayoutAdapter'; + +describe('Incident queue card aspect ratio', () => { + it('BASE_QUEUE_CARD_W is 120', () => { + expect(BASE_QUEUE_CARD_W).toBe(120); + }); + + it('BASE_QUEUE_CARD_H is 69', () => { + expect(BASE_QUEUE_CARD_H).toBe(69); + }); + + it('queue card dimensions preserve 7:4 aspect ratio', () => { + // 120/69 ≈ 1.739, 7/4 = 1.75 — close enough for display purposes + const ratio = BASE_QUEUE_CARD_W / BASE_QUEUE_CARD_H; + expect(ratio).toBeCloseTo(7 / 4, 1); + }); + + it('layout adapter returns queueCardW=120 and queueCardH=69', () => { + const layout = computeMainStreetLayoutWithSll(); + expect(layout.queueCardW).toBe(120); + expect(layout.queueCardH).toBe(69); + }); + + it('queue card dimensions differ from market card dimensions (custom size)', () => { + const layout = computeMainStreetLayoutWithSll(); + // Market cards remain at 140×80 + expect(layout.marketCardW).toBe(140); + expect(layout.marketCardH).toBe(80); + // Queue cards are now a different custom size + expect(layout.queueCardW).not.toBe(layout.marketCardW); + expect(layout.queueCardH).not.toBe(layout.marketCardH); + }); + + it('two queue cards at 120×69 fit within the 300px wide panel', () => { + const cardW = 120; + const gap = 6; + // Panel width is ~300px, card width is 120px, so card fits with room to spare + expect(cardW).toBeLessThanOrEqual(300); + // Two cards stacked vertically: 2 * 69 + gap = 144px panel height for cards + const twoCardsHeight = 2 * 69 + gap; + // Panel height should accommodate this + expect(twoCardsHeight).toBeLessThan(300); // well within any reasonable panel + }); + + it('queue cards are smaller than market cards (14% reduction)', () => { + const layout = computeMainStreetLayoutWithSll(); + const marketArea = layout.marketCardW * layout.marketCardH; // 140*80 = 11200 + const queueArea = layout.queueCardW * layout.queueCardH; // 120*69 = 8280 + const reduction = 1 - queueArea / marketArea; + expect(reduction).toBeCloseTo(0.26, 0); // ~26% area reduction + }); +}); diff --git a/tests/main-street/integration.test.ts b/tests/main-street/integration.test.ts index 51ce7a07..71d6929b 100644 --- a/tests/main-street/integration.test.ts +++ b/tests/main-street/integration.test.ts @@ -537,8 +537,8 @@ describe('Integration: Held Investment Event', () => { const coinsAfterPlay = state.resourceBank.coins; // Reputation multiplier: rep=5, divisor=20 → 1 + 5/20 = 1.25 - // floor(5 * 1.25) = floor(6.25) = 6 - expect(coinsAfterPlay).toBe(50 + 6); // Event delta scaled by reputation multiplier + // CG-0MRER3RE300418SG: Math.floor removed; 5 * 1.25 = 6.25 (was 6 before fix) + expect(coinsAfterPlay).toBeCloseTo(50 + 6.25); // Event delta scaled by reputation multiplier // End turn — InvestmentResolution should have nothing to auto-resolve const result = processEndOfTurn(state); diff --git a/tests/main-street/reputation-coin-multiplier.test.ts b/tests/main-street/reputation-coin-multiplier.test.ts index 7c3cbdf5..7f0a8d70 100644 --- a/tests/main-street/reputation-coin-multiplier.test.ts +++ b/tests/main-street/reputation-coin-multiplier.test.ts @@ -6,6 +6,10 @@ * reputation during income and event resolution. * * Work item: CG-0MMLR38NJ1N11DOS + * + * CG-0MRER3RE300418SG: Removed Math.floor from applyReputationMultiplier + * so fractional income values (e.g. 0.5 baseIncome) are preserved instead + * of being silently truncated. Added fractional-income accumulation tests. */ import { describe, it, expect } from 'vitest'; @@ -97,17 +101,18 @@ describe('reputationCoinMultiplier', () => { describe('applyReputationMultiplier', () => { it('scales positive coin deltas', () => { - // delta=10, rep=10 → multiplier=1.5 → floor(15) = 15 + // delta=10, rep=10 → multiplier=1.5 → 15 expect(applyReputationMultiplier(10, 10, DEFAULT_CFG)).toBe(15); }); - it('floors the result for non-integer products', () => { - // delta=7, rep=3 → multiplier=1.15 → 7*1.15=8.05 → floor=8 - expect(applyReputationMultiplier(7, 3, DEFAULT_CFG)).toBe(8); - // delta=5, rep=5 → multiplier=1.25 → 5*1.25=6.25 → floor=6 - expect(applyReputationMultiplier(5, 5, DEFAULT_CFG)).toBe(6); - // delta=3, rep=7 → multiplier=1.35 → 3*1.35=4.05 → floor=4 - expect(applyReputationMultiplier(3, 7, DEFAULT_CFG)).toBe(4); + it('preserves fractional products (no longer floors)', () => { + // CG-0MRER3RE300418SG: Math.floor removed; fractional values preserved. + // delta=7, rep=3 → multiplier=1.15 → 7*1.15=8.05 + expect(applyReputationMultiplier(7, 3, DEFAULT_CFG)).toBeCloseTo(8.05); + // delta=5, rep=5 → multiplier=1.25 → 5*1.25=6.25 + expect(applyReputationMultiplier(5, 5, DEFAULT_CFG)).toBeCloseTo(6.25); + // delta=3, rep=7 → multiplier=1.35 → 3*1.35=4.05 + expect(applyReputationMultiplier(3, 7, DEFAULT_CFG)).toBeCloseTo(4.05); }); it('does not scale negative coin deltas', () => { @@ -134,6 +139,46 @@ describe('applyReputationMultiplier', () => { // delta=10, rep=100 → multiplier capped at 3.0 → 10*3=30 expect(applyReputationMultiplier(10, 100, DEFAULT_CFG)).toBe(30); }); + + // ── Fractional income tests (CG-0MRER3RE300418SG) ────────────── + + it('preserves fractional baseIncome with multiplier=1.0', () => { + // baseIncome=0.5, rep=0 → multiplier=1.0 → 0.5 (no loss) + expect(applyReputationMultiplier(0.5, 0, DEFAULT_CFG)).toBeCloseTo(0.5); + }); + + it('accumulates fractional income over multiple turns (no rep)', () => { + // Two turns of 0.5 income with multiplier=1.0 → 1.0 + const turn1 = applyReputationMultiplier(0.5, 0, DEFAULT_CFG); + const turn2 = applyReputationMultiplier(0.5, 0, DEFAULT_CFG); + expect(turn1 + turn2).toBeCloseTo(1.0); + }); + + it('accumulates fractional income with reputation multiplier', () => { + // rep=3 → multiplier=1.15 + // Each turn: 0.5 * 1.15 = 0.575 + const turn1 = applyReputationMultiplier(0.5, 3, DEFAULT_CFG); + const turn2 = applyReputationMultiplier(0.5, 3, DEFAULT_CFG); + expect(turn1).toBeCloseTo(0.575); + expect(turn2).toBeCloseTo(0.575); + // After 2 turns: 0.575 + 0.575 = 1.15 (≥ 1 coin, was 0 before fix) + expect(turn1 + turn2).toBeGreaterThanOrEqual(1.0); + }); + + it('integer baseIncome values remain unchanged (backward compat)', () => { + // Integer baseIncome with multiplier=1.0 → same integer + expect(applyReputationMultiplier(1, 0, DEFAULT_CFG)).toBe(1); + expect(applyReputationMultiplier(2, 0, DEFAULT_CFG)).toBe(2); + expect(applyReputationMultiplier(3, 0, DEFAULT_CFG)).toBe(3); + expect(applyReputationMultiplier(10, 0, DEFAULT_CFG)).toBe(10); + }); + + it('integer baseIncome with reputation still produces expected values', () => { + // delta=1, rep=3 → multiplier=1.15 → 1.15 (was floor(1.15)=1 before fix) + expect(applyReputationMultiplier(1, 3, DEFAULT_CFG)).toBeCloseTo(1.15); + // delta=2, rep=10 → multiplier=1.5 → 3.0 + expect(applyReputationMultiplier(2, 10, DEFAULT_CFG)).toBe(3); + }); }); // ── Integration: multiplier applied in applyIncome ────────────────── @@ -166,7 +211,7 @@ describe('Reputation multiplier: income integration', () => { const coinsBefore = state.resourceBank.coins; const result = applyIncome(state); - // Base income = 10 (no synergy). Multiplied: floor(10 * 1.5) = 15 + // Base income = 10 (no synergy). Multiplied: 10 * 1.5 = 15 expect(result.total).toBe(10); // computeIncome total is raw expect(state.resourceBank.coins).toBe(coinsBefore + 15); }); @@ -194,6 +239,44 @@ describe('Reputation multiplier: income integration', () => { expect(state.resourceBank.coins).toBe(coinsBefore + 10); }); + + // ── Fractional income integration tests (CG-0MRER3RE300418SG) ── + + it('accumulates fractional income over multiple turns (no rep)', () => { + const state = setupMainStreetGame({ seed: 'frac-income-no-rep' }); + state.streetGrid.fill(null); + state.streetGrid[0] = makeBiz({ id: 'biz-1', baseIncome: 0.5, synergyTypes: [] }); + state.resourceBank.reputation = 0; + + // Set initial coins to 0 for predictable counting + state.resourceBank.coins = 0; + + // Turn 1: 0.5 * 1.0 = 0.5 + applyIncome(state); + expect(state.resourceBank.coins).toBeCloseTo(0.5); + + // Turn 2: 0.5 * 1.0 = 0.5 → total = 1.0 + applyIncome(state); + expect(state.resourceBank.coins).toBeCloseTo(1.0); + }); + + it('accumulates fractional income with reputation multiplier', () => { + const state = setupMainStreetGame({ seed: 'frac-income-rep' }); + state.streetGrid.fill(null); + state.streetGrid[0] = makeBiz({ id: 'biz-1', baseIncome: 0.5, synergyTypes: [] }); + state.resourceBank.reputation = 3; // Medium preset, multiplier ≈ 1.15 + + state.resourceBank.coins = 0; + + // Turn 1: 0.5 * 1.15 = 0.575 + applyIncome(state); + expect(state.resourceBank.coins).toBeCloseTo(0.575); + + // Turn 2: 0.5 * 1.15 = 0.575 → total = 1.15 (was 0 before fix due to floor) + applyIncome(state); + expect(state.resourceBank.coins).toBeCloseTo(1.15); + expect(state.resourceBank.coins).toBeGreaterThanOrEqual(1.0); + }); }); // ── Integration: multiplier applied in resolveEvent ───────────────── @@ -217,7 +300,7 @@ describe('Reputation multiplier: event resolution integration', () => { }; resolveEvent(state, event); - // floor(5 * 2.0) = 10 + // 5 * 2.0 = 10 (integer, no change from floor behavior) expect(state.resourceBank.coins).toBe(coinsBefore + 10); }); diff --git a/tests/main-street/synergy-visuals.test.ts b/tests/main-street/synergy-visuals.test.ts index e72e8dd0..d260add5 100644 --- a/tests/main-street/synergy-visuals.test.ts +++ b/tests/main-street/synergy-visuals.test.ts @@ -20,13 +20,17 @@ import { // ── Helpers ───────────────────────────────────────────────── function makeBiz(overrides: Partial = {}): BusinessCard { + const id = overrides.id ?? 'test-biz'; + const isPawnShop = id.startsWith('biz-pawnshop-'); return { family: 'business', - id: overrides.id ?? 'test-biz', + id, name: overrides.name ?? 'Test Biz', cost: overrides.cost ?? 3, baseIncome: overrides.baseIncome ?? 2, synergyTypes: overrides.synergyTypes ?? ['Food'], + synergyCoinBonus: overrides.synergyCoinBonus ?? (isPawnShop ? 0 : 1), + synergyRepBonus: overrides.synergyRepBonus ?? (isPawnShop ? 0 : 0), maxLevel: overrides.maxLevel ?? 1, description: overrides.description ?? 'A test business', level: overrides.level ?? 0, @@ -140,6 +144,8 @@ describe('Synergy Pairs for Visual Lines', () => { cost: 4, baseIncome: 0, synergyTypes: ['Culture'], + synergyCoinBonus: 1, + synergyRepBonus: 0, maxLevel: 1, description: 'A park', level: 0, diff --git a/tests/main-street/turnflow.test.ts b/tests/main-street/turnflow.test.ts index baebce41..3ede1b4e 100644 --- a/tests/main-street/turnflow.test.ts +++ b/tests/main-street/turnflow.test.ts @@ -248,7 +248,9 @@ describe('MainStreetEngine', () => { expect(result).toBeNull(); expect(state.heldEvent).toBeNull(); - expect(state.resourceBank.coins).toBe(coinsBefore + 4); + // CG-0MRER3RE300418SG: event coinDelta is now multiplied by reputation and not floored + // Medium preset rep=3 → multiplier=1.15, 4 * 1.15 = 4.6 (was 4 before fix) + expect(state.resourceBank.coins).toBeCloseTo(coinsBefore + 4.6); }); it('should throw play-event action when no Investment is held', () => { @@ -286,8 +288,10 @@ describe('MainStreetEngine', () => { }); resolveEvent(state, event); - // 2 Food businesses * 2 coinDelta = +4 - expect(state.resourceBank.coins).toBe(coinsBefore + 4); + // 2 Food businesses * 2 coinDelta = +4 raw + // CG-0MRER3RE300418SG: raw delta multiplied by reputation, not floored + // 4 * 1.15 = 4.6 (was 4 before fix) + expect(state.resourceBank.coins).toBeCloseTo(coinsBefore + 4.6); }); it('should apply reputationDelta', () => { @@ -323,7 +327,9 @@ describe('MainStreetEngine', () => { expect(resolved).not.toBeNull(); expect(resolved!.id).toBe('e1'); expect(state.heldEvent).toBeNull(); - expect(state.resourceBank.coins).toBe(coinsBefore + 5); + // CG-0MRER3RE300418SG: event coinDelta scaled by reputation, not floored + // 5 * 1.15 = 5.75 (was 5 before fix) + expect(state.resourceBank.coins).toBeCloseTo(coinsBefore + 5.75); }); it('should return null when no event is held', () => { @@ -342,7 +348,9 @@ describe('MainStreetEngine', () => { playHeldEvent(state); expect(state.heldEvent).toBeNull(); - expect(state.resourceBank.coins).toBe(coinsBefore + 3); + // CG-0MRER3RE300418SG: event coinDelta scaled by reputation, not floored + // 3 * 1.15 = 3.45 (was 3 before fix) + expect(state.resourceBank.coins).toBeCloseTo(coinsBefore + 3.45); }); it('should throw when no event is held', () => { diff --git a/tests/main-street/tutorial-scenario.test.ts b/tests/main-street/tutorial-scenario.test.ts index 0772e97b..69444930 100644 --- a/tests/main-street/tutorial-scenario.test.ts +++ b/tests/main-street/tutorial-scenario.test.ts @@ -187,26 +187,26 @@ describe('createTutorialScenario', () => { // ── Coin budget verification (AC5) ─────────────────────────── - it('provides sufficient coin budget for tutorial (12 starting, $6 Laundromat, $2 event)', () => { + it('provides sufficient coin budget for tutorial (12 starting, $3 Laundromat, $2 event)', () => { const state = createTutorialScenario(); // Starting with Easy preset: 12 coins expect(state.resourceBank.coins).toBe(12); - // The Laundromat referenced in T3 must exist and cost ≤ 6 + // The Laundromat referenced in T3 must exist and cost ≤ 3 const t3 = UNIFIED_TUTORIAL_STEPS.find(s => s.id === 'T3')!; const laundromat = state.market.development.find( c => matchesTemplate(c.id, t3.requiredCardId ?? ''), ); expect(laundromat).toBeDefined(); - expect(laundromat!.cost).toBeLessThanOrEqual(6); + expect(laundromat!.cost).toBeLessThanOrEqual(3); - // After buying $6 card: 6 coins remaining - const afterLaundromat = state.resourceBank.coins - 6; - expect(afterLaundromat).toBe(6); + // After buying $3 card: 9 coins remaining + const afterLaundromat = state.resourceBank.coins - 3; + expect(afterLaundromat).toBe(9); - // After one turn's income (base income from Laundromat is 1): - // 6 + 1 = 7 coins should be enough for a $2 event - expect(7).toBeGreaterThanOrEqual(2); + // After one turn's income (base income from Laundromat is 0.5): + // 9 + 0.5 = 9.5 coins should be enough for a $2 event + expect(9.5).toBeGreaterThanOrEqual(2); }); // ── Market card integration with tutorial steps ────────────── @@ -225,7 +225,7 @@ describe('createTutorialScenario', () => { // Verify it's the Laundromat const laundromat = marketCard as BusinessCard; expect(laundromat.name).toBe('Laundromat'); - expect(laundromat.cost).toBe(6); + expect(laundromat.cost).toBe(3); }); it('an investment event card is in the investments row matching T7', () => { diff --git a/tests/main-street/upgrade-overlay-spec.test.ts b/tests/main-street/upgrade-overlay-spec.test.ts index a7da2bd5..8ae022cd 100644 --- a/tests/main-street/upgrade-overlay-spec.test.ts +++ b/tests/main-street/upgrade-overlay-spec.test.ts @@ -66,7 +66,7 @@ describe('buildUpgradeOverlaySpec - base cards (level === 0)', () => { const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toBe('+2/turn'); + expect(spec.incomeText!.text).toBe('Income: +2/turn'); }); it('should omit income text when total income is 0', () => { @@ -124,7 +124,7 @@ describe('buildUpgradeOverlaySpec - base cards (level === 0)', () => { const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toBe('+1/turn'); + expect(spec.incomeText!.text).toBe('Income: +1/turn'); expect(spec.reputationText).not.toBeNull(); expect(spec.reputationText!.text).toBe('+0.2/turn'); }); @@ -136,7 +136,7 @@ describe('buildUpgradeOverlaySpec - upgraded cards (level > 0)', () => { const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toBe('+3/turn'); + expect(spec.incomeText!.text).toBe('Income: +3/turn'); }); it('should show reputation text when reputationPerTurn + bonus > 0', () => { @@ -183,7 +183,7 @@ describe('buildUpgradeOverlaySpec - upgraded cards (level > 0)', () => { const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toBe('+2/turn'); + expect(spec.incomeText!.text).toBe('Income: +2/turn'); expect(spec.reputationText).toBeNull(); }); }); @@ -194,7 +194,7 @@ describe('buildUpgradeOverlaySpec - community space cards', () => { const spec = buildUpgradeOverlaySpec(cs, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toBe('+1/turn'); + expect(spec.incomeText!.text).toBe('Income: +1/turn'); }); it('should show reputation text for community space with reputation', () => { @@ -216,24 +216,28 @@ describe('buildUpgradeOverlaySpec - community space cards', () => { }); describe('buildUpgradeOverlaySpec - positioning', () => { - it('should position income text at bottom-left', () => { + it('should position income text centred on card', () => { const biz = makeBiz({ baseIncome: 2, level: 0 }); const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.incomeText).not.toBeNull(); - // Income should be positioned left-aligned near bottom - expect(spec.incomeText!.x).toBeLessThan(CARD_W / 2); - expect(spec.incomeText!.y).toBeGreaterThan(CARD_H / 2); + // Income should be horizontally centred + expect(spec.incomeText!.x).toBe(Math.round(CARD_W / 2)); + // Income should be in upper-middle band of card + expect(spec.incomeText!.y).toBeGreaterThan(0); + expect(spec.incomeText!.y).toBeLessThan(CARD_H / 2); }); - it('should position reputation text at bottom-right', () => { + it('should position reputation text centred below income', () => { const biz = makeBiz({ baseIncome: 1, reputationPerTurn: 0.2, level: 0 }); const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.reputationText).not.toBeNull(); - // Reputation should be positioned right-aligned near bottom - expect(spec.reputationText!.x).toBeGreaterThan(CARD_W / 2); - expect(spec.reputationText!.y).toBeGreaterThan(CARD_H / 2); + // Reputation should be horizontally centred + expect(spec.reputationText!.x).toBe(Math.round(CARD_W / 2)); + // Reputation should be below income but not at very bottom + expect(spec.reputationText!.y).toBeGreaterThan(0); + expect(spec.reputationText!.y).toBeLessThanOrEqual(CARD_H); }); it('should position level badge at top-right', () => { @@ -241,16 +245,21 @@ describe('buildUpgradeOverlaySpec - positioning', () => { const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.levelBadge).not.toBeNull(); - expect(spec.levelBadge!.x).toBeGreaterThan(CARD_W / 2); - expect(spec.levelBadge!.y).toBeLessThan(CARD_H / 2); + // In container-local space, right edge is at +width/2; badge should be near it + expect(spec.levelBadge!.x).toBeGreaterThan(0); + expect(spec.levelBadge!.x).toBeLessThanOrEqual(CARD_W / 2); + // At top: y should be negative (above centre in container space) + expect(spec.levelBadge!.y).toBeLessThan(0); }); - it('should position name text at top-center for upgraded cards', () => { + it('should position name text at top-centre for upgraded cards', () => { const biz = makeBiz({ name: 'Patisserie', level: 1 }); const spec = buildUpgradeOverlaySpec(biz, CARD_W, CARD_H); expect(spec.nameText).not.toBeNull(); - expect(spec.nameText!.x).toBe(Math.round(CARD_W / 2)); - expect(spec.nameText!.y).toBeLessThan(CARD_H / 2); + // Name should be horizontally centred (x=0 in container-local space) + expect(spec.nameText!.x).toBe(0); + // Name should be above centre (negative y, near top of card) + expect(spec.nameText!.y).toBeLessThan(0); }); }); diff --git a/tests/main-street/upgraded-card-rendering.test.ts b/tests/main-street/upgraded-card-rendering.test.ts index eb1e68fe..8d22fc95 100644 --- a/tests/main-street/upgraded-card-rendering.test.ts +++ b/tests/main-street/upgraded-card-rendering.test.ts @@ -74,9 +74,12 @@ describe('buildUpgradeOverlaySpec', () => { const height = 280; const spec = buildUpgradeOverlaySpec(biz, width, height); expect(spec.levelBadge).not.toBeNull(); - // Badge should be near top-right - expect(spec.levelBadge!.x).toBeGreaterThan(width * 0.5); - expect(spec.levelBadge!.y).toBeLessThan(height * 0.15); + // In container-local space (0,0 = card centre), badge should be near + // top-right: x positive (right side, near right edge at +width/2) + expect(spec.levelBadge!.x).toBeGreaterThan(0); + expect(spec.levelBadge!.x).toBeLessThanOrEqual(width / 2); + // y negative (above centre, near top at -height/2) + expect(spec.levelBadge!.y).toBeLessThan(0); }); }); @@ -85,22 +88,24 @@ describe('buildUpgradeOverlaySpec', () => { const biz = makeBiz({ baseIncome: 3, incomeBonus: 5, level: 1 }); const spec = buildUpgradeOverlaySpec(biz, 200, 280); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toContain('8'); // 3 + 5 = 8 + expect(spec.incomeText!.text).toBe('Income: +8/turn'); // 3 + 5 = 8 }); it('shows income overlay for an un-upgraded card when income > 0', () => { const biz = makeBiz({ baseIncome: 3, incomeBonus: 0, level: 0 }); const spec = buildUpgradeOverlaySpec(biz, 200, 280); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.text).toContain('3'); + expect(spec.incomeText!.text).toBe('Income: +3/turn'); }); - it('positions the income text near the bottom of the card', () => { + it('positions the income text in the upper-middle band of the card', () => { const biz = makeBiz({ baseIncome: 3, incomeBonus: 5, level: 1 }); const height = 280; const spec = buildUpgradeOverlaySpec(biz, 200, height); expect(spec.incomeText).not.toBeNull(); - expect(spec.incomeText!.y).toBeGreaterThan(height * 0.75); + // Income is now centred, not at bottom + expect(spec.incomeText!.y).toBeGreaterThan(height * 0.2); + expect(spec.incomeText!.y).toBeLessThan(height * 0.6); }); }); diff --git a/tests/sushi-go/SushiGoChopsticksScene.test.ts b/tests/sushi-go/SushiGoChopsticksScene.test.ts new file mode 100644 index 00000000..7a7560fd --- /dev/null +++ b/tests/sushi-go/SushiGoChopsticksScene.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for Sushi Go Chopsticks scene-level UX behavior. + * + * Verifies that the chopsticks button styling constants, instruction text, + * and state transitions are consistent with the expected UX flow. + * + * These are unit tests focused on importable constants and exported + * helper logic — not browser/integration tests for Phaser rendering. + */ +import { describe, it, expect, vi } from 'vitest'; + +// Mock src/ui to avoid loading Phaser (browser-only) in Node tests +vi.mock('../../src/ui', () => ({ + GAME_W: 1280, + GAME_H: 720, + FONT_FAMILY: 'sans-serif', + CARD_W: 96, + CARD_H: 134, +})); + +import { + // Chopsticks button styling + CHOPSTICKS_BUTTON_Y_OFFSET, + CHOPSTICKS_BUTTON_BG, + CHOPSTICKS_BUTTON_HOVER_BG, + CHOPSTICKS_BUTTON_TEXT_COLOR, + CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR, + CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR, + CHOPSTICKS_BUTTON_DEPTH, + + // Tableau highlight + CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR, + CHOPSTICKS_TABLEAU_ACTIVE_COLOR, + CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA, + CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING, + CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE, + + // First pick highlight + HIGHLIGHT_FIRST_PICK_COLOR, + HIGHLIGHT_FIRST_PICK_STROKE_WIDTH, + HIGHLIGHT_FIRST_PICK_FILL_ALPHA, + + // Cancel button + CHOPSTICKS_CANCEL_TEXT_COLOR, + CHOPSTICKS_CANCEL_HOVER_COLOR, + CHOPSTICKS_CANCEL_Y_OFFSET, + CHOPSTICKS_CANCEL_BG, + CHOPSTICKS_CANCEL_HOVER_BG, + CHOPSTICKS_CANCEL_DEPTH, + + // Step indicator + STEP_INDICATOR_COLOR, + STEP_INDICATOR_Y_OFFSET, + STEP_INDICATOR_DEPTH, + STEP_INDICATOR_1_OF_2, + STEP_INDICATOR_2_OF_2, + + // Suggest highlight + CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR, + CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA, +} from '../../example-games/sushi-go/scenes/SushiGoConstants'; + +describe('Chopsticks UX styling', () => { + describe('button styling', () => { + it('has a dark green background (0x1a3a2a)', () => { + expect(CHOPSTICKS_BUTTON_BG).toBe(0x1a3a2a); + }); + + it('has a lighter green hover background (0x2a5a3a)', () => { + expect(CHOPSTICKS_BUTTON_HOVER_BG).toBe(0x2a5a3a); + }); + + it('has light blue text color (#88ddff)', () => { + expect(CHOPSTICKS_BUTTON_TEXT_COLOR).toBe('#88ddff'); + }); + + it('has white hover text color (#ffffff)', () => { + expect(CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR).toBe('#ffffff'); + }); + + it('has red text color when active (#ff8888)', () => { + expect(CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR).toBe('#ff8888'); + }); + + it('is rendered at depth 700', () => { + expect(CHOPSTICKS_BUTTON_DEPTH).toBe(700); + }); + }); + + describe('tableau highlight styling', () => { + it('highlights chopsticks card in blue (#44aaff) when available', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR).toBe(0x44aaff); + }); + + it('highlights in gold (#ffaa44) when chopsticks mode is active', () => { + expect(CHOPSTICKS_TABLEAU_ACTIVE_COLOR).toBe(0xffaa44); + }); + + it('has a semi-transparent fill (0.2)', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA).toBe(0.2); + }); + + it('has padding of 8px', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING).toBe(8); + }); + + it('has a 2px stroke width', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE).toBe(2); + }); + }); + + describe('first pick highlight styling', () => { + it('uses bright green (0x00ff66)', () => { + expect(HIGHLIGHT_FIRST_PICK_COLOR).toBe(0x00ff66); + }); + + it('has a thicker 4px stroke', () => { + expect(HIGHLIGHT_FIRST_PICK_STROKE_WIDTH).toBe(4); + }); + + it('has more visible fill (0.25)', () => { + expect(HIGHLIGHT_FIRST_PICK_FILL_ALPHA).toBe(0.25); + }); + }); + + describe('cancel button styling', () => { + it('has red text color (#ff6666)', () => { + expect(CHOPSTICKS_CANCEL_TEXT_COLOR).toBe('#ff6666'); + }); + + it('has white hover text (#ffffff)', () => { + expect(CHOPSTICKS_CANCEL_HOVER_COLOR).toBe('#ffffff'); + }); + + it('is offset 55px from instruction text', () => { + expect(CHOPSTICKS_CANCEL_Y_OFFSET).toBe(55); + }); + + it('has dark red background (0x4a2a2a)', () => { + expect(CHOPSTICKS_CANCEL_BG).toBe(0x4a2a2a); + }); + + it('has lighter red hover background (0x6a3a3a)', () => { + expect(CHOPSTICKS_CANCEL_HOVER_BG).toBe(0x6a3a3a); + }); + + it('is rendered at depth 700', () => { + expect(CHOPSTICKS_CANCEL_DEPTH).toBe(700); + }); + }); + + describe('step indicator styling', () => { + it('has gold color (#ffdd44)', () => { + expect(STEP_INDICATOR_COLOR).toBe('#ffdd44'); + }); + + it('is offset -40px from the chopsticks button', () => { + expect(STEP_INDICATOR_Y_OFFSET).toBe(-40); + }); + + it('is rendered at depth 700', () => { + expect(STEP_INDICATOR_DEPTH).toBe(700); + }); + + it('shows "Step 1 of 2" for the first pick', () => { + expect(STEP_INDICATOR_1_OF_2).toBe('Step 1 of 2'); + }); + + it('shows "Step 2 of 2" for the second pick', () => { + expect(STEP_INDICATOR_2_OF_2).toBe('Step 2 of 2'); + }); + }); + + describe('suggest highlight styling', () => { + it('uses blue (0x4488ff)', () => { + expect(CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR).toBe(0x4488ff); + }); + + it('has very subtle alpha (0.08)', () => { + expect(CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA).toBe(0.08); + }); + }); + + describe('button constants are self-consistent', () => { + it('cancel button Y offset is greater than chopsticks button Y offset', () => { + // Cancel button should appear below the chopsticks button + expect(CHOPSTICKS_CANCEL_Y_OFFSET).toBeGreaterThan(CHOPSTICKS_BUTTON_Y_OFFSET); + }); + + it('first pick stroke is thicker than default highlight stroke', () => { + expect(HIGHLIGHT_FIRST_PICK_STROKE_WIDTH).toBeGreaterThan(3); + }); + + it('first pick fill is more visible than default highlight fill', () => { + expect(HIGHLIGHT_FIRST_PICK_FILL_ALPHA).toBeGreaterThan(0.15); + }); + }); +}); diff --git a/tests/sushi-go/SushiGoConstants.test.ts b/tests/sushi-go/SushiGoConstants.test.ts index e11a20d3..8d90b1ef 100644 --- a/tests/sushi-go/SushiGoConstants.test.ts +++ b/tests/sushi-go/SushiGoConstants.test.ts @@ -33,6 +33,13 @@ import { CHOPSTICKS_BUTTON_Y_OFFSET, CHOPSTICKS_BUTTON_PADDING_X, CHOPSTICKS_BUTTON_PADDING_Y, + CHOPSTICKS_BUTTON_BG, + CHOPSTICKS_BUTTON_HOVER_BG, + CHOPSTICKS_BUTTON_TEXT_COLOR, + CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR, + CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR, + CHOPSTICKS_BUTTON_RADIUS, + CHOPSTICKS_BUTTON_DEPTH, TOOLTIP_Y_OFFSET, TOOLTIP_CLAMP_BOUNDARY, TOOLTIP_FLIP_THRESHOLD, @@ -42,6 +49,36 @@ import { SCORE_TEXT_FONT_SIZE, // Turn animation delay TURN_ANIMATION_DELAY, + // Chopsticks tableau highlight + CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR, + CHOPSTICKS_TABLEAU_ACTIVE_COLOR, + CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA, + CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING, + CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE, + // First pick highlight + HIGHLIGHT_FIRST_PICK_COLOR, + HIGHLIGHT_FIRST_PICK_STROKE_WIDTH, + HIGHLIGHT_FIRST_PICK_FILL_ALPHA, + // Cancel button + CHOPSTICKS_CANCEL_FONT_SIZE, + CHOPSTICKS_CANCEL_TEXT_COLOR, + CHOPSTICKS_CANCEL_HOVER_COLOR, + CHOPSTICKS_CANCEL_Y_OFFSET, + CHOPSTICKS_CANCEL_PADDING_X, + CHOPSTICKS_CANCEL_PADDING_Y, + CHOPSTICKS_CANCEL_BG, + CHOPSTICKS_CANCEL_HOVER_BG, + CHOPSTICKS_CANCEL_DEPTH, + // Step indicator + STEP_INDICATOR_FONT_SIZE, + STEP_INDICATOR_COLOR, + STEP_INDICATOR_Y_OFFSET, + STEP_INDICATOR_DEPTH, + STEP_INDICATOR_1_OF_2, + STEP_INDICATOR_2_OF_2, + // Suggest highlight + CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR, + CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA, } from '../../example-games/sushi-go/scenes/SushiGoConstants'; describe('SushiGoConstants', () => { @@ -97,11 +134,32 @@ describe('SushiGoConstants', () => { it('CHOPSTICKS_BUTTON_Y_OFFSET is 25', () => { expect(CHOPSTICKS_BUTTON_Y_OFFSET).toBe(25); }); - it('CHOPSTICKS_BUTTON_PADDING_X is 12', () => { - expect(CHOPSTICKS_BUTTON_PADDING_X).toBe(12); + it('CHOPSTICKS_BUTTON_PADDING_X is 16', () => { + expect(CHOPSTICKS_BUTTON_PADDING_X).toBe(16); }); - it('CHOPSTICKS_BUTTON_PADDING_Y is 6', () => { - expect(CHOPSTICKS_BUTTON_PADDING_Y).toBe(6); + it('CHOPSTICKS_BUTTON_PADDING_Y is 8', () => { + expect(CHOPSTICKS_BUTTON_PADDING_Y).toBe(8); + }); + it('CHOPSTICKS_BUTTON_BG is 0x1a3a2a', () => { + expect(CHOPSTICKS_BUTTON_BG).toBe(0x1a3a2a); + }); + it('CHOPSTICKS_BUTTON_HOVER_BG is 0x2a5a3a', () => { + expect(CHOPSTICKS_BUTTON_HOVER_BG).toBe(0x2a5a3a); + }); + it('CHOPSTICKS_BUTTON_TEXT_COLOR is "#88ddff"', () => { + expect(CHOPSTICKS_BUTTON_TEXT_COLOR).toBe('#88ddff'); + }); + it('CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR is "#ffffff"', () => { + expect(CHOPSTICKS_BUTTON_HOVER_TEXT_COLOR).toBe('#ffffff'); + }); + it('CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR is "#ff8888"', () => { + expect(CHOPSTICKS_BUTTON_ACTIVE_TEXT_COLOR).toBe('#ff8888'); + }); + it('CHOPSTICKS_BUTTON_RADIUS is 8', () => { + expect(CHOPSTICKS_BUTTON_RADIUS).toBe(8); + }); + it('CHOPSTICKS_BUTTON_DEPTH is 700', () => { + expect(CHOPSTICKS_BUTTON_DEPTH).toBe(700); }); }); @@ -127,6 +185,93 @@ describe('SushiGoConstants', () => { it('HIGHLIGHT_FILL_ALPHA is 0.15', () => { expect(HIGHLIGHT_FILL_ALPHA).toBe(0.15); }); + it('HIGHLIGHT_FIRST_PICK_COLOR is 0x00ff66', () => { + expect(HIGHLIGHT_FIRST_PICK_COLOR).toBe(0x00ff66); + }); + it('HIGHLIGHT_FIRST_PICK_STROKE_WIDTH is 4', () => { + expect(HIGHLIGHT_FIRST_PICK_STROKE_WIDTH).toBe(4); + }); + it('HIGHLIGHT_FIRST_PICK_FILL_ALPHA is 0.25', () => { + expect(HIGHLIGHT_FIRST_PICK_FILL_ALPHA).toBe(0.25); + }); + }); + + describe('chopsticks tableau highlight', () => { + it('CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR is 0x44aaff', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_COLOR).toBe(0x44aaff); + }); + it('CHOPSTICKS_TABLEAU_ACTIVE_COLOR is 0xffaa44', () => { + expect(CHOPSTICKS_TABLEAU_ACTIVE_COLOR).toBe(0xffaa44); + }); + it('CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA is 0.2', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA).toBe(0.2); + }); + it('CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING is 8', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING).toBe(8); + }); + it('CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE is 2', () => { + expect(CHOPSTICKS_TABLEAU_HIGHLIGHT_STROKE).toBe(2); + }); + }); + + describe('cancel button constants', () => { + it('CHOPSTICKS_CANCEL_FONT_SIZE is "14px"', () => { + expect(CHOPSTICKS_CANCEL_FONT_SIZE).toBe('14px'); + }); + it('CHOPSTICKS_CANCEL_TEXT_COLOR is "#ff6666"', () => { + expect(CHOPSTICKS_CANCEL_TEXT_COLOR).toBe('#ff6666'); + }); + it('CHOPSTICKS_CANCEL_HOVER_COLOR is "#ffffff"', () => { + expect(CHOPSTICKS_CANCEL_HOVER_COLOR).toBe('#ffffff'); + }); + it('CHOPSTICKS_CANCEL_Y_OFFSET is 55', () => { + expect(CHOPSTICKS_CANCEL_Y_OFFSET).toBe(55); + }); + it('CHOPSTICKS_CANCEL_PADDING_X is 14', () => { + expect(CHOPSTICKS_CANCEL_PADDING_X).toBe(14); + }); + it('CHOPSTICKS_CANCEL_PADDING_Y is 6', () => { + expect(CHOPSTICKS_CANCEL_PADDING_Y).toBe(6); + }); + it('CHOPSTICKS_CANCEL_BG is 0x4a2a2a', () => { + expect(CHOPSTICKS_CANCEL_BG).toBe(0x4a2a2a); + }); + it('CHOPSTICKS_CANCEL_HOVER_BG is 0x6a3a3a', () => { + expect(CHOPSTICKS_CANCEL_HOVER_BG).toBe(0x6a3a3a); + }); + it('CHOPSTICKS_CANCEL_DEPTH is 700', () => { + expect(CHOPSTICKS_CANCEL_DEPTH).toBe(700); + }); + }); + + describe('step indicator constants', () => { + it('STEP_INDICATOR_FONT_SIZE is "15px"', () => { + expect(STEP_INDICATOR_FONT_SIZE).toBe('15px'); + }); + it('STEP_INDICATOR_COLOR is "#ffdd44"', () => { + expect(STEP_INDICATOR_COLOR).toBe('#ffdd44'); + }); + it('STEP_INDICATOR_Y_OFFSET is -40', () => { + expect(STEP_INDICATOR_Y_OFFSET).toBe(-40); + }); + it('STEP_INDICATOR_DEPTH is 700', () => { + expect(STEP_INDICATOR_DEPTH).toBe(700); + }); + it('STEP_INDICATOR_1_OF_2 is "Step 1 of 2"', () => { + expect(STEP_INDICATOR_1_OF_2).toBe('Step 1 of 2'); + }); + it('STEP_INDICATOR_2_OF_2 is "Step 2 of 2"', () => { + expect(STEP_INDICATOR_2_OF_2).toBe('Step 2 of 2'); + }); + }); + + describe('chopsticks suggest highlight constants', () => { + it('CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR is 0x4488ff', () => { + expect(CHOPSTICKS_SUGGEST_HIGHLIGHT_COLOR).toBe(0x4488ff); + }); + it('CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA is 0.08', () => { + expect(CHOPSTICKS_SUGGEST_HIGHLIGHT_ALPHA).toBe(0.08); + }); }); describe('turn animation', () => { diff --git a/tests/the-mind/MindCardRenderer.browser.test.ts b/tests/the-mind/MindCardRenderer.browser.test.ts deleted file mode 100644 index 435b1983..00000000 --- a/tests/the-mind/MindCardRenderer.browser.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { createTheMindGame } from '../../example-games/the-mind/createTheMindGame'; - -describe('TheMind browser smoke', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - if (game) game.destroy(true, false); - const el = document.getElementById('game-container'); - if (el && el.parentNode) el.parentNode.removeChild(el); - game = null; - }); - - it('renders at least one Mind card to the canvas', async () => { - const container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - game = createTheMindGame({ type: Phaser.CANVAS, parent: 'game-container', width: 900, height: 700 }); - - // Wait for the Phaser texture manager to contain at least one Mind card - // texture using DPR-aware keys (e.g. 'ms_card_mind-42_120x164@...'). - // Under the new lazy rasterisation model, textures are created on demand - // via ensureMindCardTexture. The game scene's create() method triggers - // lazy rasterisation for visible cards, so we wait for at least one - // ms_card_mind-* texture to appear in the texture manager. - const textures = () => game?.scene.getScene('TheMindScene')?.textures as Phaser.Textures.TextureManager | undefined; - - await new Promise((resolve, reject) => { - const start = Date.now(); - const check = () => { - const t = textures(); - if (t) { - const keys = t.getTextureKeys().filter((k: string) => k.startsWith('ms_card_mind-')); - if (keys.length > 0) { - resolve(); - return; - } - } - if (Date.now() - start > 10_000) { - reject(new Error('Mind textures were not available in time')); - return; - } - setTimeout(check, 100); - }; - check(); - }); - - // Sample one of the generated textures (offscreen) to ensure it's not a - // single-colour placeholder. This avoids depending on the game's canvas - // rendering context type (WebGL vs 2D). - const s = game!.scene.getScene('TheMindScene') as any as Phaser.Scene; - const keys = (s.textures.getTextureKeys?.() ?? []).filter((k: string) => k.startsWith('ms_card_mind-')); - expect(keys.length).toBeGreaterThan(0); - - function isTextureNonSolid(scene: Phaser.Scene, key: string): boolean { - const texture = (scene.textures.get(key) as any) || null; - const source = texture?.source?.[0]?.image as HTMLImageElement | HTMLCanvasElement | undefined; - if (!source) return false; - - const width = (source as any).width || 1; - const height = (source as any).height || 1; - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const context = canvas.getContext('2d'); - if (!context) return false; - - context.drawImage(source as any, 0, 0, width, height); - - const sampleW = Math.max(1, Math.floor(width * 0.5)); - const sampleH = Math.max(1, Math.floor(height * 0.5)); - const sampleX = Math.floor((width - sampleW) / 2); - const sampleY = Math.floor((height - sampleH) / 2); - const data = context.getImageData(sampleX, sampleY, sampleW, sampleH).data; - - if (data.length < 4) return false; - - const r0 = data[0]; - const g0 = data[1]; - const b0 = data[2]; - const a0 = data[3]; - - for (let i = 4; i < data.length; i += 4) { - if (data[i] !== r0 || data[i + 1] !== g0 || data[i + 2] !== b0 || data[i + 3] !== a0) { - return true; - } - } - return false; - } - - const someKey = keys.sort()[0]; - const nonSolid = isTextureNonSolid(s, someKey); - expect(nonSolid).toBe(true); - }, 30_000); -}); \ No newline at end of file diff --git a/tests/the-mind/MindConstants.test.ts b/tests/the-mind/MindConstants.test.ts deleted file mode 100644 index cb1de8a8..00000000 --- a/tests/the-mind/MindConstants.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * MindConstants tests — verify all visual/styling constants exist - * and have the expected values after magic-number extraction. - * - * This test file ensures the extracted constants are defined and - * match the original inline values so visual behaviour is unchanged. - */ -import { describe, it, expect, vi } from 'vitest'; - -// Mock src/ui to avoid loading Phaser (browser-only) in Node tests -vi.mock('../../src/ui', () => ({ - GAME_W: 1280, - GAME_H: 720, - FONT_FAMILY: 'sans-serif', - CARD_W: 96, - CARD_H: 134, -})); - -import { - // Existing constants (verify they still exist) - PRE_PENALTY_PAUSE, - DEPTH_OVERLAY, - DEPTH_OVERLAY_CONTENT, - // New overlay constants - OVERLAY_BG_ALPHA, - OVERLAY_BOX_WIDTH, - OVERLAY_BOX_HEIGHT, - OVERLAY_BOX_ALPHA, - OVERLAY_BUTTON_FONT_SIZE, - OVERLAY_BUTTON_Y_OFFSET, - OVERLAY_BUTTON_SPACING, - // New auto-play button constants - AUTO_PLAY_BUTTON_X, - AUTO_PLAY_BUTTON_MARGIN, - AUTO_PLAY_FONT_SIZE, - // New HUD/status constants - STATUS_X_OFFSET, - STATUS_LEVEL_Y, - STATUS_LIVES_Y, - // New pile constants - PILE_COUNT_Y_OFFSET, - PILE_COUNT_FONT_SIZE, - PILE_VALUE_Y_OFFSET, - PILE_VALUE_FONT_SIZE, - // New instruction constants - INSTRUCTION_MARGIN, - INSTRUCTION_FONT_SIZE, - // New hover constants - HOVER_SCALE, - HOVER_Y_OFFSET, - // New flash/animation constants - FLASH_DELAY, - FLASH_REPEATS, - FLASH_TIMER_OFFSET, - // New penalty cleanup constant - PENALTY_CLEANUP_EXTRA_DELAY, - // New level complete constants - LEVEL_COMPLETE_TEXT_Y_OFFSET, - LEVEL_COMPLETE_FADE_IN_DURATION, - LEVEL_COMPLETE_DISPLAY_DURATION, - // New penalty card constants - PENALTY_CARD_ALPHA, -} from '../../example-games/the-mind/scenes/MindConstants'; - -describe('MindConstants', () => { - describe('existing constants are unchanged', () => { - it('PRE_PENALTY_PAUSE is 120', () => { - expect(PRE_PENALTY_PAUSE).toBe(120); - }); - it('DEPTH_OVERLAY is 2000', () => { - expect(DEPTH_OVERLAY).toBe(2000); - }); - it('DEPTH_OVERLAY_CONTENT is 2001', () => { - expect(DEPTH_OVERLAY_CONTENT).toBe(2001); - }); - }); - - describe('overlay constants', () => { - it('OVERLAY_BG_ALPHA is 0.75', () => { - expect(OVERLAY_BG_ALPHA).toBe(0.75); - }); - it('OVERLAY_BOX_WIDTH is 460', () => { - expect(OVERLAY_BOX_WIDTH).toBe(460); - }); - it('OVERLAY_BOX_HEIGHT is 280', () => { - expect(OVERLAY_BOX_HEIGHT).toBe(280); - }); - it('OVERLAY_BOX_ALPHA is 0.9', () => { - expect(OVERLAY_BOX_ALPHA).toBe(0.9); - }); - it('OVERLAY_BUTTON_FONT_SIZE is "18px"', () => { - expect(OVERLAY_BUTTON_FONT_SIZE).toBe('18px'); - }); - it('OVERLAY_BUTTON_Y_OFFSET is 60', () => { - expect(OVERLAY_BUTTON_Y_OFFSET).toBe(60); - }); - it('OVERLAY_BUTTON_SPACING is 90', () => { - expect(OVERLAY_BUTTON_SPACING).toBe(90); - }); - }); - - describe('auto-play button constants', () => { - it('AUTO_PLAY_BUTTON_X is 20', () => { - expect(AUTO_PLAY_BUTTON_X).toBe(20); - }); - it('AUTO_PLAY_BUTTON_MARGIN is 20', () => { - expect(AUTO_PLAY_BUTTON_MARGIN).toBe(20); - }); - it('AUTO_PLAY_FONT_SIZE is "12px"', () => { - expect(AUTO_PLAY_FONT_SIZE).toBe('12px'); - }); - }); - - describe('HUD/status constants', () => { - it('STATUS_X_OFFSET is 100', () => { - expect(STATUS_X_OFFSET).toBe(100); - }); - it('STATUS_LEVEL_Y is 55', () => { - expect(STATUS_LEVEL_Y).toBe(55); - }); - it('STATUS_LIVES_Y is 79', () => { - expect(STATUS_LIVES_Y).toBe(79); - }); - }); - - describe('pile constants', () => { - it('PILE_COUNT_Y_OFFSET is 32', () => { - expect(PILE_COUNT_Y_OFFSET).toBe(32); - }); - it('PILE_COUNT_FONT_SIZE is "11px"', () => { - expect(PILE_COUNT_FONT_SIZE).toBe('11px'); - }); - it('PILE_VALUE_Y_OFFSET is 14', () => { - expect(PILE_VALUE_Y_OFFSET).toBe(14); - }); - it('PILE_VALUE_FONT_SIZE is "14px"', () => { - expect(PILE_VALUE_FONT_SIZE).toBe('14px'); - }); - }); - - describe('instruction constants', () => { - it('INSTRUCTION_MARGIN is 20', () => { - expect(INSTRUCTION_MARGIN).toBe(20); - }); - it('INSTRUCTION_FONT_SIZE is "12px"', () => { - expect(INSTRUCTION_FONT_SIZE).toBe('12px'); - }); - }); - - describe('hover interaction constants', () => { - it('HOVER_SCALE is 1.03', () => { - expect(HOVER_SCALE).toBe(1.03); - }); - it('HOVER_Y_OFFSET is -4', () => { - expect(HOVER_Y_OFFSET).toBe(-4); - }); - }); - - describe('flash/animation constants', () => { - it('FLASH_DELAY is 150', () => { - expect(FLASH_DELAY).toBe(150); - }); - it('FLASH_REPEATS is 5', () => { - expect(FLASH_REPEATS).toBe(5); - }); - it('FLASH_TIMER_OFFSET is 50', () => { - expect(FLASH_TIMER_OFFSET).toBe(50); - }); - }); - - describe('penalty animation constants', () => { - it('PENALTY_CARD_ALPHA is 0.8', () => { - expect(PENALTY_CARD_ALPHA).toBe(0.8); - }); - it('PENALTY_CLEANUP_EXTRA_DELAY is 50', () => { - expect(PENALTY_CLEANUP_EXTRA_DELAY).toBe(50); - }); - }); - - describe('level complete constants', () => { - it('LEVEL_COMPLETE_TEXT_Y_OFFSET is 40', () => { - expect(LEVEL_COMPLETE_TEXT_Y_OFFSET).toBe(40); - }); - it('LEVEL_COMPLETE_FADE_IN_DURATION is 300', () => { - expect(LEVEL_COMPLETE_FADE_IN_DURATION).toBe(300); - }); - it('LEVEL_COMPLETE_DISPLAY_DURATION is 2000', () => { - expect(LEVEL_COMPLETE_DISPLAY_DURATION).toBe(2000); - }); - }); -}); diff --git a/tests/the-mind/TheMindLayout.browser.test.ts b/tests/the-mind/TheMindLayout.browser.test.ts deleted file mode 100644 index ea6e2c81..00000000 --- a/tests/the-mind/TheMindLayout.browser.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * TheMindLayout — regression tests verifying The Mind scene layout. - * - * These tests run inside a real Chromium browser via Vitest browser mode - * and Playwright. They boot the The Mind scene and verify that the pile - * and hands are correctly positioned within the viewport. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { createTheMindGame } from '../../example-games/the-mind/createTheMindGame'; -import { waitForScene } from '../helpers/waitForScene'; - -async function bootGame(): Promise { - let container = document.getElementById('game-container'); - if (container) container.remove(); - container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - const game = createTheMindGame({ type: Phaser.CANVAS, parent: 'game-container', width: 1280, height: 720 }); - await waitForScene(game, 'TheMindScene'); - return game; -} - -function destroyGame(game: Phaser.Game | null): void { - if (game) game.destroy(true, false); - const container = document.getElementById('game-container'); - if (container) container.remove(); -} - -describe('TheMind layout regression', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - destroyGame(game); - game = null; - }); - - it('centers pile and hands within the viewport', async () => { - game = await bootGame(); - const scene = game.scene.getScene('TheMindScene') as Phaser.Scene; - const w = scene.scale.width; - const h = scene.scale.height; - - // Find key display objects - const images = scene.children.list.filter( - (c) => c instanceof Phaser.GameObjects.Image, - ) as Phaser.GameObjects.Image[]; - - const texts = scene.children.list.filter( - (c) => c instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; - - // HUD text should exist (level and/or lives) - const hudTexts = texts.filter((t) => { - const txt = typeof t.text === 'string' ? t.text : ''; - return txt.includes('Level') || txt.includes('Lives'); - }); - expect(hudTexts.length).toBeGreaterThanOrEqual(1); - - // Pile sprite should be centred horizontally - const pileSprite = images.find((img) => img.texture.key.includes('mind-back')); - expect(pileSprite).toBeDefined(); - expect(pileSprite!.x).toBeGreaterThan(w * 0.45); - expect(pileSprite!.x).toBeLessThan(w * 0.55); - - // No image should extend below the viewport by more than a few pixels - for (const img of images) { - const halfH = (img.displayHeight || img.height || 0) / 2; - expect(img.y + halfH).toBeLessThanOrEqual(h + 2); - expect(img.y - halfH).toBeGreaterThanOrEqual(-2); - } - }); -}); diff --git a/tests/the-mind/TheMindOverlay.browser.test.ts b/tests/the-mind/TheMindOverlay.browser.test.ts deleted file mode 100644 index 501ffd16..00000000 --- a/tests/the-mind/TheMindOverlay.browser.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * TheMindScene overlay button browser tests -- verify that game-over overlay - * buttons respond to real pointer events routed through Phaser's input - * pipeline, and that scene.restart() works correctly after clicking - * "Try Again". - * - * These tests run inside a real Chromium browser via Vitest browser mode - * and Playwright. They dispatch actual DOM MouseEvents on the canvas - * element so the full Phaser input system (hit-testing, depth sorting, - * topOnly filtering) is exercised. - * - * NOTE: Each test boots a fresh Phaser game which creates a WebGL context. - * Browsers limit concurrent WebGL contexts (~8-16). We keep total boots - * per file <= 4 to stay well within that budget. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { waitForScene } from '../helpers/waitForScene'; - -// ── Helpers ───────────────────────────────────────────────── - -async function bootGame(): Promise { - let container = document.getElementById('game-container'); - if (container) container.remove(); - container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - const { createTheMindGame } = await import( - '../../example-games/the-mind/createTheMindGame' - ); - const game = createTheMindGame({ type: Phaser.CANVAS }); - await waitForScene(game, 'TheMindScene'); - return game; -} - -function destroyGame(game: Phaser.Game | null): void { - if (game) game.destroy(true, false); - const container = document.getElementById('game-container'); - if (container) container.remove(); -} - -function waitFrames(n: number): Promise { - return new Promise((resolve) => { - let count = 0; - const step = () => { - count++; - if (count >= n) { - resolve(); - } else { - requestAnimationFrame(step); - } - }; - requestAnimationFrame(step); - }); -} - -/** - * Get scene private properties via type-safe cast. - */ -function getSceneInternals(scene: Phaser.Scene) { - return scene as any; -} - -/** - * Collect display objects from scene children and the HUD container. - * Phaser 4 containers store children in .list (not .children). - */ -function collectFromSceneAndHud( - scene: Phaser.Scene, - predicate: (obj: Phaser.GameObjects.GameObject) => obj is T, -): T[] { - const result: T[] = []; - const walk = (parent: Phaser.GameObjects.GameObject[]) => { - for (const child of parent) { - if (predicate(child)) result.push(child); - if (child instanceof Phaser.GameObjects.Container && (child as any).list) { - walk((child as any).list); - } - } - }; - walk(scene.children.list); - const hud = (scene as any).hudContainer as { list: Phaser.GameObjects.GameObject[] } | undefined; - if (hud && hud.list) walk(hud.list); - return result; -} - -/** - * Dispatch a real DOM MouseEvent on the game canvas at the given - * game-world coordinates. This routes through Phaser's full input - * pipeline: InputManager -> InputPlugin -> hit-test -> sortGameObjects. - * - * IMPORTANT: Phaser 3.x listens for 'mousedown'/'mouseup' (NOT - * 'pointerdown'/'pointerup'). Synthetic `dispatchEvent(new PointerEvent(...))` - * does NOT trigger the browser's automatic mousedown compatibility event, - * so we must dispatch MouseEvent directly. - */ -function clickAtGameCoords( - game: Phaser.Game, - gameX: number, - gameY: number, -): void { - const canvas = game.canvas; - const scale = game.scale; - - // Ensure ScaleManager bounds are up to date before computing coords - scale.refresh(); - - const pageX = - gameX / scale.displayScale.x + scale.canvasBounds.left; - const pageY = - gameY / scale.displayScale.y + scale.canvasBounds.top; - - const eventInit: MouseEventInit = { - clientX: pageX, - clientY: pageY, - screenX: pageX, - screenY: pageY, - bubbles: true, - cancelable: true, - button: 0, - buttons: 1, - }; - - const down = new MouseEvent('mousedown', eventInit); - Object.defineProperty(down, 'pageX', { value: pageX }); - Object.defineProperty(down, 'pageY', { value: pageY }); - canvas.dispatchEvent(down); - - const up = new MouseEvent('mouseup', { ...eventInit, buttons: 0 }); - Object.defineProperty(up, 'pageX', { value: pageX }); - Object.defineProperty(up, 'pageY', { value: pageY }); - canvas.dispatchEvent(up); -} - -/** - * Force the TheMindScene into game-over (loss) state and show the - * loss overlay. Manipulates session state directly so handleGameOver() - * sees outcome='loss'. - */ -function forceLossOverlay(scene: Phaser.Scene): void { - const internals = getSceneInternals(scene); - // Set session to loss state - internals.session.lives = 0; - internals.session.outcome = 'loss'; - // Call handleGameOver which shows the loss overlay - internals.handleGameOver(); -} - -/** - * Force the TheMindScene into game-over (win) state and show the - * win overlay. Manipulates session state directly so handleGameOver() - * sees outcome='win'. - */ -function forceWinOverlay(scene: Phaser.Scene): void { - const internals = getSceneInternals(scene); - // Set session to win state - internals.session.outcome = 'win'; - // Call handleGameOver which shows the win overlay - internals.handleGameOver(); -} - -// ── Tests ─────────────────────────────────────────────────── - -describe('The Mind overlay button tests', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - destroyGame(game); - game = null; - }); - - it('should show loss overlay buttons that are interactive', async () => { - game = await bootGame(); - const scene = game.scene.getScene('TheMindScene')!; - - forceLossOverlay(scene); - await waitFrames(3); - - // Find text objects with overlay button labels - const texts = collectFromSceneAndHud(scene, (child): child is Phaser.GameObjects.Text => - child instanceof Phaser.GameObjects.Text, - ); - - const tryAgainBtn = texts.find((t) => t.text === '[ Try Again ]'); - - expect(tryAgainBtn).toBeDefined(); - expect(tryAgainBtn!.input?.enabled).toBe(true); - }); - - it('should restart the scene when "Try Again" is clicked via DOM pointer event', async () => { - game = await bootGame(); - const scene = game.scene.getScene('TheMindScene')!; - - // Record original session to verify it changes after restart - const originalSession = getSceneInternals(scene).session; - - forceLossOverlay(scene); - // Wait for the overlay to render and Phaser to process the frame - await waitFrames(5); - - // Find the "Try Again" button to get its coordinates - const texts = collectFromSceneAndHud(scene, (child): child is Phaser.GameObjects.Text => - child instanceof Phaser.GameObjects.Text, - ); - const tryAgainBtn = texts.find((t) => t.text === '[ Try Again ]'); - expect(tryAgainBtn).toBeDefined(); - - // Click at the button's game-world position through the DOM - clickAtGameCoords(game, tryAgainBtn!.x, tryAgainBtn!.y); - - // Wait for restart: Phaser queues scene restart for next frame - await waitFrames(3); - // scene.restart() destroys and recreates; wait for re-activation - await waitForScene(game, 'TheMindScene'); - await waitFrames(3); - - // Verify: new session was created (different object reference) - const newScene = game.scene.getScene('TheMindScene')!; - const newSession = getSceneInternals(newScene).session; - expect(newSession).not.toBe(originalSession); - - // Verify: the scene is in 'playing' or 'dealing' phase (not game-lost) - const newPhase = getSceneInternals(newScene).phase; - expect(newPhase).not.toBe('game-lost'); - expect(newPhase).not.toBe('game-won'); - - // Verify: overlay buttons no longer exist - const newTexts = collectFromSceneAndHud(newScene, (child): child is Phaser.GameObjects.Text => - child instanceof Phaser.GameObjects.Text, - ); - const tryAgainAfterRestart = newTexts.find( - (t) => t.text === '[ Try Again ]', - ); - expect(tryAgainAfterRestart).toBeUndefined(); - }); - - it('should show win overlay buttons that respond to DOM clicks', async () => { - game = await bootGame(); - const scene = game.scene.getScene('TheMindScene')!; - - const originalSession = getSceneInternals(scene).session; - - forceWinOverlay(scene); - await waitFrames(5); - - // Find the "Play Again" button - const texts = collectFromSceneAndHud(scene, (child): child is Phaser.GameObjects.Text => - child instanceof Phaser.GameObjects.Text, - ); - const playAgainBtn = texts.find((t) => t.text === '[ Play Again ]'); - expect(playAgainBtn).toBeDefined(); - expect(playAgainBtn!.input?.enabled).toBe(true); - - // Click at the button's game-world position through the DOM - clickAtGameCoords(game, playAgainBtn!.x, playAgainBtn!.y); - - // Wait for restart - await waitFrames(3); - await waitForScene(game, 'TheMindScene'); - await waitFrames(3); - - // Verify: new session was created - const newScene = game.scene.getScene('TheMindScene')!; - const newSession = getSceneInternals(newScene).session; - expect(newSession).not.toBe(originalSession); - }); - - it('should have an interactive input blocker at overlay depth', async () => { - game = await bootGame(); - const scene = game.scene.getScene('TheMindScene')!; - - forceLossOverlay(scene); - await waitFrames(3); - - // Find interactive rectangles at depth 2000 (the overlay background) - const rects = collectFromSceneAndHud(scene, (child): child is Phaser.GameObjects.Rectangle => - child instanceof Phaser.GameObjects.Rectangle && - (child as Phaser.GameObjects.Rectangle).depth === 2000, - ); - - // Should have at least 2 rectangles: the full-screen blocker and the visible overlay box - expect(rects.length).toBeGreaterThanOrEqual(2); - - // The full-screen blocker should be interactive (1280x720 viewport) - const fullScreenBlocker = rects.find( - (r) => r.width === 1280 && r.height === 720 && r.input?.enabled, - ); - expect(fullScreenBlocker).toBeDefined(); - }); -}); diff --git a/tests/the-mind/adapter.test.ts b/tests/the-mind/adapter.test.ts deleted file mode 100644 index d1b78a28..00000000 --- a/tests/the-mind/adapter.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - resolveTemplateId, - resolveBackTemplateId, - getCanonicalTextureKey, - ensureTexture, - ensureBackTexture, - getTextureKey, -} from '../../example-games/the-mind/MindCardTextureAdapter'; -import { - MIND_CARD_W, - MIND_CARD_H, -} from '../../example-games/the-mind/MindCardRenderer'; -import { - CARD_BACK_KEY, -} from '../../example-games/the-mind/MindCard'; - -// ── resolveTemplateId ──────────────────────────────────────── - -describe('resolveTemplateId', () => { - it('should return mind-1 for value 1', () => { - expect(resolveTemplateId(1)).toBe('mind-1'); - }); - - it('should return mind-50 for value 50', () => { - expect(resolveTemplateId(50)).toBe('mind-50'); - }); - - it('should return mind-100 for value 100', () => { - expect(resolveTemplateId(100)).toBe('mind-100'); - }); - - it('should throw for value 0', () => { - expect(() => resolveTemplateId(0)).toThrow('Invalid Mind card value'); - }); - - it('should throw for value 101', () => { - expect(() => resolveTemplateId(101)).toThrow('Invalid Mind card value'); - }); - - it('should throw for non-integer values', () => { - expect(() => resolveTemplateId(3.5)).toThrow('Invalid Mind card value'); - }); - - it('should throw for negative values', () => { - expect(() => resolveTemplateId(-1)).toThrow('Invalid Mind card value'); - }); - - it('should throw for NaN', () => { - expect(() => resolveTemplateId(NaN)).toThrow('Invalid Mind card value'); - }); - - it('should throw for Infinity', () => { - expect(() => resolveTemplateId(Infinity)).toThrow('Invalid Mind card value'); - }); -}); - -// ── resolveBackTemplateId ───────────────────────────────────── - -describe('resolveBackTemplateId', () => { - it('should return mind-back', () => { - expect(resolveBackTemplateId()).toBe('mind-back'); - }); - - it('should match CARD_BACK_KEY', () => { - expect(resolveBackTemplateId()).toBe(CARD_BACK_KEY); - }); -}); - -// ── getCanonicalTextureKey ─────────────────────────────────── - -describe('getCanonicalTextureKey', () => { - it('should produce a DPR-aware key using mind-42 template ID', () => { - const key = getCanonicalTextureKey('mind-42', 48, 65, 2); - expect(key).toBe('ms_card_mind-42_48x65@2'); - }); - - it('should produce a key for the card back', () => { - const key = getCanonicalTextureKey('mind-back', 48, 65, 1); - expect(key).toBe('ms_card_mind-back_48x65@1'); - }); - - it('should default width/height to MIND_CARD_W and MIND_CARD_H', () => { - const key = getCanonicalTextureKey('mind-42', undefined, undefined, 1); - expect(key).toBe(`ms_card_mind-42_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); - - it('should default DPR to 1 in Node (no window.devicePixelRatio)', () => { - const key = getCanonicalTextureKey('mind-42'); - // In Node, window is undefined so DPR defaults to 1 - expect(key).toBe(`ms_card_mind-42_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); - - it('should use explicit DPR when provided', () => { - const key = getCanonicalTextureKey('mind-1', 48, 65, 3); - expect(key).toBe('ms_card_mind-1_48x65@3'); - }); - - it('should round non-integer dimensions in the key', () => { - const key = getCanonicalTextureKey('mind-42', 47.7, 64.3, 1); - expect(key).toBe('ms_card_mind-42_48x64@1'); - }); -}); - -// ── getTextureKey ──────────────────────────────────────────── - -describe('getTextureKey', () => { - it('should return DPR-aware key for a face-up card', () => { - const card = { value: 42, faceUp: true }; - const key = getTextureKey(card, 48, 65, 1); - expect(key).toBe('ms_card_mind-42_48x65@1'); - }); - - it('should return DPR-aware key for a face-down card', () => { - const card = { value: 42, faceUp: false }; - const key = getTextureKey(card, 48, 65, 1); - expect(key).toBe('ms_card_mind-back_48x65@1'); - }); - - it('should use default dimensions when not specified', () => { - const card = { value: 1, faceUp: true }; - const key = getTextureKey(card, undefined, undefined, 1); - expect(key).toBe(`ms_card_mind-1_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); - - it('should use default DPR in Node environment', () => { - const card = { value: 50, faceUp: true }; - const key = getTextureKey(card); - expect(key).toBe(`ms_card_mind-50_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); -}); - -// ── ensureTexture and ensureBackTexture ────────────────────── - -describe('ensureTexture family', () => { - it('ensureTexture returns consistent DPR-aware key with ensureMindCardTexture', async () => { - const { ensureMindCardTexture, preloadMindCardAssets } = await import('../../example-games/the-mind/MindCardRenderer'); - - // Minimal mock scene - const existingKeys = new Set(); - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { - exists: (key: string) => existingKeys.has(key), - addCanvas: (_key: string, _canvas: unknown) => { existingKeys.add(_key); }, - get: () => undefined, - }, - } as any; - - preloadMindCardAssets(scene, 48, 65); - - // Both should return the same DPR-aware key - const directResult = await ensureMindCardTexture(scene, 42, 48, 65); - const adapterResult = await ensureTexture(scene, 42, 48, 65); - - expect(adapterResult).toHaveProperty('key'); - expect(adapterResult.key).toBe(directResult.key); - expect(adapterResult.ready).toBe(directResult.ready); - }); - - it('ensureBackTexture returns DPR-aware key for card back', async () => { - const { preloadMindCardAssets } = await import('../../example-games/the-mind/MindCardRenderer'); - const { makeTextureKey } = await import('../../src/core-engine/SvgHelpers'); - - // Minimal mock scene - const existingKeys = new Set(); - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { - exists: (key: string) => existingKeys.has(key), - addCanvas: (_key: string, _canvas: unknown) => { existingKeys.add(_key); }, - get: () => undefined, - }, - } as any; - - preloadMindCardAssets(scene, 48, 65); - - const adapterResult = await ensureBackTexture(scene, 48, 65); - - expect(adapterResult).toHaveProperty('key'); - expect(adapterResult.key).toBe(makeTextureKey('mind-back', 48, 65, 1)); - expect(adapterResult.ready).toBe(false); // Node environment: no rasterisation - }); -}); \ No newline at end of file diff --git a/tests/the-mind/ai-out-of-turn-play.browser.test.ts b/tests/the-mind/ai-out-of-turn-play.browser.test.ts deleted file mode 100644 index d71efe8a..00000000 --- a/tests/the-mind/ai-out-of-turn-play.browser.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { createTheMindGame } from '../../example-games/the-mind/createTheMindGame'; -import { getCanonicalTextureKey, resolveTemplateId } from '../../example-games/the-mind/MindCardTextureAdapter'; -import { CARD_W, CARD_H } from '../../example-games/the-mind/scenes/MindConstants'; - -describe('The Mind — AI out-of-turn play (browser integration)', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - if (game) game.destroy(true, false); - const el = document.getElementById('game-container'); - if (el && el.parentNode) el.parentNode.removeChild(el); - game = null; - }); - - it('plays an AI card without showing a missing-texture placeholder and registers the final DPR-aware texture', async () => { - const container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - // Create the game (TheMindScene will run its create() lifecycle) - game = createTheMindGame({ type: Phaser.CANVAS, parent: 'game-container', width: 900, height: 700 }); - - // Wait for the TheMindScene to be initialized and contain an AI hand. - const scene = () => game?.scene.getScene('TheMindScene') as any | undefined; - - await new Promise((resolve, reject) => { - const start = Date.now(); - const check = () => { - const s = scene(); - if (s && s.session && s.mindAnimator && s.session.players && s.session.players[1] && s.session.players[1].hand && s.session.players[1].hand.length > 0) { - resolve(); - return; - } - if (Date.now() - start > 10_000) { - reject(new Error('TheMindScene did not initialize in time')); - return; - } - setTimeout(check, 50); - }; - check(); - }); - - const s = scene() as any as Phaser.Scene & { session: any; mindAnimator: any }; - - // Pick the first AI card value for the play - const aiHand = s.session.players[1].hand; - if (!aiHand || aiHand.length === 0) throw new Error('AI hand is empty'); - const aiValue = aiHand[0].value as number; - - // Compute the expected DPR-aware texture key used by the flip animation - const templateId = resolveTemplateId(aiValue); - const expectedKey = getCanonicalTextureKey(templateId, CARD_W, CARD_H); - - // Trigger the AI card play animation and wait for it to complete. - await new Promise((resolve, reject) => { - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - reject(new Error('AI play animation did not complete in time')); - }, 10_000); - - try { - s.mindAnimator.animateCardTowardsPile(1, aiValue, () => { - if (timedOut) return; - clearTimeout(timeout); - resolve(); - }); - } catch (err) { - clearTimeout(timeout); - reject(err); - } - }); - - // Final assertions: DPR-aware key shape and texture registered in the scene. - expect(/ms_card_mind-\d+_\d+x\d+@\d+/.test(expectedKey)).toBe(true); - const textures = (game!.scene.getScene('TheMindScene') as any).textures as Phaser.Textures.TextureManager; - expect(textures.exists(expectedKey)).toBe(true); - }, 30_000); -}); diff --git a/tests/the-mind/ai-strategy.test.ts b/tests/the-mind/ai-strategy.test.ts deleted file mode 100644 index cbe26517..00000000 --- a/tests/the-mind/ai-strategy.test.ts +++ /dev/null @@ -1,998 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - type MindAiStrategy, - type MindAiTimingConfig, - LinearTimingStrategy, - MindAiPlayer, - DEFAULT_BASE_DURATION, - DEFAULT_JITTER_RANGE, - MIN_PLAY_DELAY, - AI_LAST_CARD_DELAY, - PROXIMITY_MIN_DELAY, - PROXIMITY_THRESHOLD, - computeEffectiveDelay, -} from '../../example-games/the-mind/AiStrategy'; -import type { MindCard } from '../../example-games/the-mind/MindCard'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Create a MindCard with the given value. */ -function card(value: number): MindCard { - return { value, faceUp: false }; -} - -/** Create a hand of cards from an array of values. */ -function hand(...values: number[]): MindCard[] { - return values.map(card); -} - -/** A config with zero jitter for deterministic formula testing. */ -const ZERO_JITTER_CONFIG: MindAiTimingConfig = { - baseDuration: 5000, - jitterRange: 0, -}; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -describe('Constants', () => { - it('DEFAULT_BASE_DURATION is 10000ms', () => { - expect(DEFAULT_BASE_DURATION).toBe(10000); - }); - - it('DEFAULT_JITTER_RANGE is 800ms', () => { - expect(DEFAULT_JITTER_RANGE).toBe(800); - }); - - it('MIN_PLAY_DELAY is 1500ms', () => { - expect(MIN_PLAY_DELAY).toBe(1500); - }); -}); - -// --------------------------------------------------------------------------- -// LinearTimingStrategy -// --------------------------------------------------------------------------- - -describe('LinearTimingStrategy', () => { - it('has the name "LinearTiming"', () => { - expect(LinearTimingStrategy.name).toBe('LinearTiming'); - }); - - describe('computeDelays', () => { - it('returns one delay per card', () => { - const h = hand(10, 50, 90); - const rng = createSeededRng(100); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - expect(delays).toHaveLength(3); - }); - - it('preserves card references in returned delays', () => { - const h = hand(42); - const rng = createSeededRng(101); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - expect(delays[0].card).toBe(h[0]); - }); - - // ── Formula verification (no jitter) ── - - it('delay = (value/100) * baseDuration when jitter is 0', () => { - const h = hand(25, 50, 75, 100); - const rng = createSeededRng(102); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - - // 25/100 * 5000 = 1250, clamped to MIN_PLAY_DELAY (1500) - expect(delays[0].delay).toBeCloseTo(MIN_PLAY_DELAY, 5); - expect(delays[1].delay).toBeCloseTo(2500, 5); // 50/100 * 5000 - expect(delays[2].delay).toBeCloseTo(3750, 5); // 75/100 * 5000 - expect(delays[3].delay).toBeCloseTo(5000, 5); // 100/100 * 5000 - }); - - it('card value 1 is clamped to MIN_PLAY_DELAY', () => { - const h = hand(1); - const rng = createSeededRng(103); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - // Raw delay = 1/100 * 5000 = 50, clamped to MIN_PLAY_DELAY - expect(delays[0].delay).toBe(MIN_PLAY_DELAY); - }); - - it('scales linearly with baseDuration', () => { - const h = hand(50); - const rng = createSeededRng(104); - const config: MindAiTimingConfig = { - baseDuration: 10000, - jitterRange: 0, - }; - const delays = LinearTimingStrategy.computeDelays(h, config, rng); - expect(delays[0].delay).toBeCloseTo(5000, 5); // 50/100 * 10000 - }); - - // ── Jitter verification ── - - it('applies jitter within ±jitterRange', () => { - const h = hand(50); - const config: MindAiTimingConfig = { - baseDuration: 5000, - jitterRange: 500, - }; - - // Run many trials to verify jitter stays in bounds - for (let seed = 0; seed < 100; seed++) { - const rng = createSeededRng(seed); - const delays = LinearTimingStrategy.computeDelays(h, config, rng); - const baseDelay = (50 / 100) * 5000; // 2500 - const jitter = delays[0].delay - baseDelay; - expect(jitter).toBeGreaterThanOrEqual(-500); - expect(jitter).toBeLessThanOrEqual(500); - } - }); - - it('jitter produces variation across different seeds', () => { - const h = hand(50); - const config: MindAiTimingConfig = { - baseDuration: 5000, - jitterRange: 500, - }; - - const delays1 = LinearTimingStrategy.computeDelays( - h, - config, - createSeededRng(200), - ); - const delays2 = LinearTimingStrategy.computeDelays( - h, - config, - createSeededRng(300), - ); - - // With different seeds, delays should differ (jitter varies) - expect(delays1[0].delay).not.toBeCloseTo(delays2[0].delay, 5); - }); - - it('zero jitter range produces exact formula results', () => { - const h = hand(33); - const rng = createSeededRng(105); - const config: MindAiTimingConfig = { - baseDuration: 6000, - jitterRange: 0, - }; - const delays = LinearTimingStrategy.computeDelays(h, config, rng); - expect(delays[0].delay).toBeCloseTo((33 / 100) * 6000, 5); - }); - - // ── Error handling ── - - it('throws if baseDuration is 0', () => { - const h = hand(10); - const rng = createSeededRng(106); - expect(() => - LinearTimingStrategy.computeDelays( - h, - { baseDuration: 0, jitterRange: 0 }, - rng, - ), - ).toThrow(/baseDuration must be positive/); - }); - - it('throws if baseDuration is negative', () => { - const h = hand(10); - const rng = createSeededRng(107); - expect(() => - LinearTimingStrategy.computeDelays( - h, - { baseDuration: -1000, jitterRange: 0 }, - rng, - ), - ).toThrow(/baseDuration must be positive/); - }); - - it('returns empty array for empty hand', () => { - const rng = createSeededRng(108); - const delays = LinearTimingStrategy.computeDelays( - [], - ZERO_JITTER_CONFIG, - rng, - ); - expect(delays).toHaveLength(0); - }); - - // ── MIN_PLAY_DELAY clamping ── - - it('clamps delays below MIN_PLAY_DELAY to the minimum', () => { - // With baseDuration=5000, cards with value ≤ 30 produce raw delays ≤ 1500 - // Card value 10 → raw 500, Card value 20 → raw 1000, Card value 29 → raw 1450 - const h = hand(10, 20, 29); - const rng = createSeededRng(200); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - for (const d of delays) { - expect(d.delay).toBe(MIN_PLAY_DELAY); - } - }); - - it('does not clamp delays already above MIN_PLAY_DELAY', () => { - // Card value 50 → raw 2500 (above 1500) - const h = hand(50); - const rng = createSeededRng(201); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - expect(delays[0].delay).toBeCloseTo(2500, 5); - }); - - it('clamps at exactly MIN_PLAY_DELAY boundary', () => { - // Card value 30 → raw 30/100 * 5000 = 1500 = MIN_PLAY_DELAY exactly - const h = hand(30); - const rng = createSeededRng(202); - const delays = LinearTimingStrategy.computeDelays( - h, - ZERO_JITTER_CONFIG, - rng, - ); - expect(delays[0].delay).toBe(MIN_PLAY_DELAY); - }); - - // ── Determinism ── - - it('produces identical delays with the same seed', () => { - const h = hand(10, 30, 60, 90); - const config: MindAiTimingConfig = { - baseDuration: 5000, - jitterRange: 400, - }; - - const delays1 = LinearTimingStrategy.computeDelays( - h, - config, - createSeededRng(42), - ); - const delays2 = LinearTimingStrategy.computeDelays( - h, - config, - createSeededRng(42), - ); - - expect(delays1.map((d) => d.delay)).toEqual( - delays2.map((d) => d.delay), - ); - }); - - // ── Independence ── - - it('each card gets its own independent delay', () => { - // Use values well above MIN_PLAY_DELAY threshold to ensure distinct delays - // 50/100*5000=2500, 60/100*5000=3000, 70/100*5000=3500 (all > 1500) - const h = hand(50, 60, 70); - const rng = createSeededRng(109); - const config: MindAiTimingConfig = { - baseDuration: 5000, - jitterRange: 200, - }; - const delays = LinearTimingStrategy.computeDelays(h, config, rng); - - // All three delays should be different values - const values = delays.map((d) => d.delay); - const unique = new Set(values); - expect(unique.size).toBe(3); - }); - }); -}); - -// --------------------------------------------------------------------------- -// MindAiPlayer -// --------------------------------------------------------------------------- - -describe('MindAiPlayer', () => { - describe('construction', () => { - it('uses LinearTimingStrategy by default', () => { - const player = new MindAiPlayer(); - expect(player.strategyName).toBe('LinearTiming'); - }); - - it('uses default config when none provided', () => { - const player = new MindAiPlayer(); - const config = player.getConfig(); - expect(config.baseDuration).toBe(DEFAULT_BASE_DURATION); - expect(config.jitterRange).toBe(DEFAULT_JITTER_RANGE); - }); - - it('accepts partial config overrides', () => { - const player = new MindAiPlayer(LinearTimingStrategy, Math.random, { - baseDuration: 3000, - }); - const config = player.getConfig(); - expect(config.baseDuration).toBe(3000); - expect(config.jitterRange).toBe(DEFAULT_JITTER_RANGE); - }); - - it('accepts full config overrides', () => { - const player = new MindAiPlayer(LinearTimingStrategy, Math.random, { - baseDuration: 8000, - jitterRange: 100, - }); - const config = player.getConfig(); - expect(config.baseDuration).toBe(8000); - expect(config.jitterRange).toBe(100); - }); - - it('accepts a custom strategy', () => { - const custom: MindAiStrategy = { - name: 'Custom', - computeDelays: (h, _config, _rng) => - h.map((c) => ({ card: c, delay: c.value * 10 })), - }; - const player = new MindAiPlayer(custom); - expect(player.strategyName).toBe('Custom'); - }); - }); - - describe('commitLevel', () => { - it('computes and stores delays for the given hand', () => { - const rng = createSeededRng(110); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(20, 40, 80)); - - const delays = player.getCardDelays(); - expect(delays).toHaveLength(3); - }); - - it('sorts delays by earliest fire time (ascending)', () => { - const rng = createSeededRng(111); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - // With zero jitter, card 10 < card 50 < card 90 in delay - player.commitLevel(hand(90, 10, 50)); - - const delays = player.getCardDelays(); - expect(delays[0].card.value).toBe(10); - expect(delays[1].card.value).toBe(50); - expect(delays[2].card.value).toBe(90); - }); - - it('replaces previous level delays when called again', () => { - const rng = createSeededRng(112); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - - player.commitLevel(hand(10, 20)); - expect(player.getCardDelays()).toHaveLength(2); - - player.commitLevel(hand(30, 40, 50)); - expect(player.getCardDelays()).toHaveLength(3); - expect(player.getCardDelays()[0].card.value).toBe(30); - }); - - it('handles empty hand', () => { - const rng = createSeededRng(113); - const player = new MindAiPlayer(LinearTimingStrategy, rng); - player.commitLevel([]); - - expect(player.getCardDelays()).toHaveLength(0); - expect(player.hasCards()).toBe(false); - }); - }); - - describe('getCardDelays', () => { - it('returns a defensive copy (mutation does not affect internal state)', () => { - const rng = createSeededRng(114); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10, 20)); - - const copy = player.getCardDelays(); - copy.splice(0, 1); // Remove an element from the copy - expect(player.getCardDelays()).toHaveLength(2); // Internal unchanged - }); - }); - - describe('getNextCard', () => { - it('returns the card with the shortest delay', () => { - const rng = createSeededRng(115); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(80, 20, 50)); - - const next = player.getNextCard(); - expect(next).toBeDefined(); - expect(next!.card.value).toBe(20); - }); - - it('returns undefined when no cards are committed', () => { - const player = new MindAiPlayer(); - expect(player.getNextCard()).toBeUndefined(); - }); - - it('returns undefined after all cards are removed', () => { - const rng = createSeededRng(116); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10)); - player.removeCard(10); - - expect(player.getNextCard()).toBeUndefined(); - }); - }); - - describe('removeCard', () => { - it('removes a card by value and returns true', () => { - const rng = createSeededRng(117); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10, 30, 50)); - - const result = player.removeCard(30); - expect(result).toBe(true); - expect(player.getCardDelays()).toHaveLength(2); - expect( - player.getCardDelays().find((d) => d.card.value === 30), - ).toBeUndefined(); - }); - - it('returns false for a card not in committed delays', () => { - const rng = createSeededRng(118); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10, 20)); - - expect(player.removeCard(99)).toBe(false); - expect(player.getCardDelays()).toHaveLength(2); - }); - - it('remaining card delays are NOT reset after removal', () => { - const rng = createSeededRng(119); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10, 50, 90)); - - // Record delays before removal - const delaysBefore = player.getCardDelays(); - const delay50Before = delaysBefore.find( - (d) => d.card.value === 50, - )!.delay; - const delay90Before = delaysBefore.find( - (d) => d.card.value === 90, - )!.delay; - - // Remove card 10 - player.removeCard(10); - - // Remaining delays unchanged - const delaysAfter = player.getCardDelays(); - expect(delaysAfter.find((d) => d.card.value === 50)!.delay).toBe( - delay50Before, - ); - expect(delaysAfter.find((d) => d.card.value === 90)!.delay).toBe( - delay90Before, - ); - }); - - it('after removing the next card, getNextCard returns the new earliest', () => { - const rng = createSeededRng(120); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(10, 50, 90)); - - expect(player.getNextCard()!.card.value).toBe(10); - player.removeCard(10); - expect(player.getNextCard()!.card.value).toBe(50); - player.removeCard(50); - expect(player.getNextCard()!.card.value).toBe(90); - }); - }); - - describe('hasCards', () => { - it('returns false before commitLevel is called', () => { - const player = new MindAiPlayer(); - expect(player.hasCards()).toBe(false); - }); - - it('returns true after committing a non-empty hand', () => { - const rng = createSeededRng(121); - const player = new MindAiPlayer(LinearTimingStrategy, rng); - player.commitLevel(hand(10)); - expect(player.hasCards()).toBe(true); - }); - - it('returns false after all cards are removed', () => { - const rng = createSeededRng(122); - const player = new MindAiPlayer(LinearTimingStrategy, rng); - player.commitLevel(hand(10)); - player.removeCard(10); - expect(player.hasCards()).toBe(false); - }); - }); - - // ── Determinism across full player lifecycle ── - - describe('determinism', () => { - it('two players with the same seed produce identical delays', () => { - const config = { baseDuration: 5000, jitterRange: 300 }; - const h = hand(5, 25, 50, 75, 100); - - const player1 = new MindAiPlayer( - LinearTimingStrategy, - createSeededRng(42), - config, - ); - const player2 = new MindAiPlayer( - LinearTimingStrategy, - createSeededRng(42), - config, - ); - - player1.commitLevel(h); - player2.commitLevel(h); - - const delays1 = player1.getCardDelays().map((d) => d.delay); - const delays2 = player2.getCardDelays().map((d) => d.delay); - - expect(delays1).toEqual(delays2); - }); - - it('different seeds produce different delays', () => { - const config = { baseDuration: 5000, jitterRange: 300 }; - const h = hand(50); - - const player1 = new MindAiPlayer( - LinearTimingStrategy, - createSeededRng(1), - config, - ); - const player2 = new MindAiPlayer( - LinearTimingStrategy, - createSeededRng(999), - config, - ); - - player1.commitLevel(h); - player2.commitLevel(h); - - const delay1 = player1.getCardDelays()[0].delay; - const delay2 = player2.getCardDelays()[0].delay; - - expect(delay1).not.toBeCloseTo(delay2, 5); - }); - }); - - // ── Lowest-delay card plays first ── - - describe('lowest-delay card plays first', () => { - it('lowest-value card above MIN_PLAY_DELAY threshold has the earliest delay (no jitter)', () => { - const rng = createSeededRng(130); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - // Use values where the lowest is still above the MIN_PLAY_DELAY threshold - // 40/100 * 5000 = 2000 > 1500 - player.commitLevel(hand(99, 40, 50, 75)); - - const next = player.getNextCard()!; - expect(next.card.value).toBe(40); - }); - - it('cards clamped to MIN_PLAY_DELAY all share the same delay', () => { - const rng = createSeededRng(132); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - // Cards 1, 10, 25 all produce raw delays < 1500, so all clamp to MIN_PLAY_DELAY - player.commitLevel(hand(1, 10, 25)); - - const delays = player.getCardDelays(); - for (const d of delays) { - expect(d.delay).toBe(MIN_PLAY_DELAY); - } - }); - - it('with small jitter, lower cards generally fire before higher cards', () => { - // Card 10: raw ≈ 500±50, clamped to MIN_PLAY_DELAY (1500) - // Card 90: raw ≈ 4500±50, well above MIN_PLAY_DELAY - // Order is preserved since 1500 < ~4450 - const rng = createSeededRng(131); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 50, - }); - player.commitLevel(hand(10, 90)); - - const delays = player.getCardDelays(); - expect(delays[0].card.value).toBe(10); - expect(delays[1].card.value).toBe(90); - }); - }); - - // ── Multiple cards with similar delays ── - - describe('similar-delay cards', () => { - it('cards with close values produce close but distinct delays', () => { - const rng = createSeededRng(140); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 0, - }); - player.commitLevel(hand(49, 50, 51)); - - const delays = player.getCardDelays(); - // Delays: 2450, 2500, 2550 — close but distinct - expect(delays[0].delay).toBeCloseTo(2450, 5); - expect(delays[1].delay).toBeCloseTo(2500, 5); - expect(delays[2].delay).toBeCloseTo(2550, 5); - }); - - it('jitter does NOT reorder cards -- monotonic ordering is enforced', () => { - // With the monotonic enforcement in commitLevel, a higher-value card - // can never fire before a lower-value card, regardless of jitter. - for (let seed = 0; seed < 50; seed++) { - const rng = createSeededRng(seed); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 200, - }); - player.commitLevel(hand(50, 51)); - - const delays = player.getCardDelays(); - // Card 50 must always come first (lower value = earlier delay) - expect(delays[0].card.value).toBe(50); - expect(delays[1].card.value).toBe(51); - expect(delays[1].delay).toBeGreaterThanOrEqual(delays[0].delay); - } - }); - }); - - // ── Monotonic delay ordering ── - - describe('monotonic delay ordering', () => { - it('enforces monotonically increasing delays by card value', () => { - // Even with high jitter, lower-value cards must always have - // delays <= higher-value cards - for (let seed = 0; seed < 100; seed++) { - const rng = createSeededRng(seed); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 2000, // Very high jitter - }); - player.commitLevel(hand(10, 20, 30, 40, 50, 60, 70, 80, 90)); - - const delays = player.getCardDelays(); - for (let i = 1; i < delays.length; i++) { - expect(delays[i].delay).toBeGreaterThanOrEqual(delays[i - 1].delay); - expect(delays[i].card.value).toBeGreaterThan(delays[i - 1].card.value); - } - } - }); - - it('getNextCard always returns the lowest-value card', () => { - for (let seed = 0; seed < 50; seed++) { - const rng = createSeededRng(seed); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 1000, - }); - player.commitLevel(hand(15, 30, 45, 60)); - - const next = player.getNextCard()!; - expect(next.card.value).toBe(15); - } - }); - - it('after removing lowest card, next card is the second-lowest', () => { - const rng = createSeededRng(42); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 1000, - }); - player.commitLevel(hand(5, 25, 50, 75)); - - player.removeCard(5); - expect(player.getNextCard()!.card.value).toBe(25); - player.removeCard(25); - expect(player.getNextCard()!.card.value).toBe(50); - player.removeCard(50); - expect(player.getNextCard()!.card.value).toBe(75); - }); - - it('bumps higher-value card delay when jitter would place it before lower-value card', () => { - // Use a custom strategy that gives a higher-value card a lower delay - const reverseStrategy: MindAiStrategy = { - name: 'Reverse', - computeDelays: (h, _config, _rng) => - h.map((c) => ({ card: c, delay: (101 - c.value) * 100 })), - }; - const player = new MindAiPlayer(reverseStrategy); - // Card 10 → raw delay 9100, Card 90 → raw delay 1100 - // Without monotonic enforcement, card 90 would fire first - player.commitLevel(hand(10, 90)); - - const delays = player.getCardDelays(); - // After enforcement, card 10 must still come first - expect(delays[0].card.value).toBe(10); - expect(delays[1].card.value).toBe(90); - // Card 90's delay must be >= Card 10's delay - expect(delays[1].delay).toBeGreaterThanOrEqual(delays[0].delay); - }); - }); - - // ── Timers persist through pile changes ── - - describe('timers persist through pile changes', () => { - it('delays remain unchanged after removing a card (simulating partner play)', () => { - const rng = createSeededRng(150); - const player = new MindAiPlayer(LinearTimingStrategy, rng, { - baseDuration: 5000, - jitterRange: 100, - }); - player.commitLevel(hand(20, 40, 60, 80)); - - // Snapshot all delays - const snapshot = player.getCardDelays().map((d) => ({ - value: d.card.value, - delay: d.delay, - })); - - // Simulate partner playing — the pile top changes, but we do NOT - // call commitLevel again. Remaining timers should be unchanged. - // (In the real game, removeCard is only called if a penalty - // discards AI cards. The AI's own play also calls removeCard.) - - // Verify delays haven't changed - const current = player.getCardDelays(); - for (const entry of snapshot) { - const match = current.find((d) => d.card.value === entry.value); - if (match) { - expect(match.delay).toBe(entry.delay); - } - } - }); - }); -}); - -// --------------------------------------------------------------------------- -// computeEffectiveDelay — fast-play when opponent hand is empty -// --------------------------------------------------------------------------- - -describe('computeEffectiveDelay', () => { - it('AI_LAST_CARD_DELAY is 400ms', () => { - expect(AI_LAST_CARD_DELAY).toBe(400); - }); - - it('returns AI_LAST_CARD_DELAY when opponent hand is empty and player has 1 card', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 8000, - /* elapsed */ 2000, - /* playerHandSize */ 1, - /* opponentHandSize */ 0, - ); - expect(result).toBe(AI_LAST_CARD_DELAY); - }); - - it('returns normal delay when opponent still has cards (even if player has 1 card)', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 8000, - /* elapsed */ 2000, - /* playerHandSize */ 1, - /* opponentHandSize */ 2, - ); - // Normal: max(8000 - 2000, 100) = 6000 - expect(result).toBe(6000); - }); - - it('returns AI_LAST_CARD_DELAY when opponent hand is empty and player has multiple cards', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 8000, - /* elapsed */ 2000, - /* playerHandSize */ 3, - /* opponentHandSize */ 0, - ); - expect(result).toBe(AI_LAST_CARD_DELAY); - }); - - it('returns normal delay when both players have cards', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 5000, - /* elapsed */ 1000, - /* playerHandSize */ 2, - /* opponentHandSize */ 3, - ); - // Normal: max(5000 - 1000, 100) = 4000 - expect(result).toBe(4000); - }); - - it('clamps normal delay to 100ms minimum', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 3000, - /* elapsed */ 5000, - /* playerHandSize */ 2, - /* opponentHandSize */ 1, - ); - // Normal: max(3000 - 5000, 100) = max(-2000, 100) = 100 - expect(result).toBe(100); - }); - - it('does not clamp to 100ms in fast-play scenario (AI_LAST_CARD_DELAY is used directly)', () => { - // Even if the committed delay minus elapsed would be negative, - // the fast-play path returns AI_LAST_CARD_DELAY, not 100. - const result = computeEffectiveDelay( - /* committedDelay */ 1000, - /* elapsed */ 5000, - /* playerHandSize */ 1, - /* opponentHandSize */ 0, - ); - expect(result).toBe(AI_LAST_CARD_DELAY); - }); - - it('returns AI_LAST_CARD_DELAY when both hands are empty (edge case)', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 5000, - /* elapsed */ 1000, - /* playerHandSize */ 0, - /* opponentHandSize */ 0, - ); - // opponentHandSize is 0, so fast-play path - expect(result).toBe(AI_LAST_CARD_DELAY); - }); - - // ── Proximity delay ── - - describe('proximity delay', () => { - it('PROXIMITY_MIN_DELAY is 1000ms', () => { - expect(PROXIMITY_MIN_DELAY).toBe(1000); - }); - - it('PROXIMITY_THRESHOLD is 5', () => { - expect(PROXIMITY_THRESHOLD).toBe(5); - }); - - it('enforces PROXIMITY_MIN_DELAY when card is within threshold of pile top', () => { - // Card 53, pile top 50 → gap = 3, within threshold of 5 - // Normal delay would be max(3000 - 2500, 100) = 500 - const result = computeEffectiveDelay( - /* committedDelay */ 3000, - /* elapsed */ 2500, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - /* cardValue */ 53, - /* pileTopValue */ 50, - ); - expect(result).toBe(PROXIMITY_MIN_DELAY); - }); - - it('enforces PROXIMITY_MIN_DELAY when card is exactly at threshold', () => { - // Card 55, pile top 50 → gap = 5, exactly at threshold - const result = computeEffectiveDelay( - /* committedDelay */ 2000, - /* elapsed */ 1900, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - /* cardValue */ 55, - /* pileTopValue */ 50, - ); - expect(result).toBe(PROXIMITY_MIN_DELAY); - }); - - it('does NOT enforce proximity delay when card is beyond threshold', () => { - // Card 56, pile top 50 → gap = 6, beyond threshold of 5 - const result = computeEffectiveDelay( - /* committedDelay */ 2000, - /* elapsed */ 1900, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - /* cardValue */ 56, - /* pileTopValue */ 50, - ); - // Normal: max(2000 - 1900, 100) = 100 - expect(result).toBe(100); - }); - - it('keeps normal delay when it already exceeds PROXIMITY_MIN_DELAY', () => { - // Card 52, pile top 50 → gap = 2, within threshold - // But normal delay is already 4000 > 1000 - const result = computeEffectiveDelay( - /* committedDelay */ 5000, - /* elapsed */ 1000, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - /* cardValue */ 52, - /* pileTopValue */ 50, - ); - // Normal: max(5000 - 1000, 100) = 4000 - expect(result).toBe(4000); - }); - - it('does NOT enforce proximity delay when pile is empty (pileTopValue = 0)', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 2000, - /* elapsed */ 1900, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - /* cardValue */ 3, - /* pileTopValue */ 0, - ); - // Normal: max(2000 - 1900, 100) = 100 - expect(result).toBe(100); - }); - - it('works correctly when cardValue and pileTopValue are omitted (backward compat)', () => { - const result = computeEffectiveDelay( - /* committedDelay */ 5000, - /* elapsed */ 1000, - /* playerHandSize */ 2, - /* opponentHandSize */ 2, - ); - // Normal: max(5000 - 1000, 100) = 4000 - expect(result).toBe(4000); - }); - - it('proximity delay does NOT apply in fast-play mode (opponent hand empty)', () => { - // Even if card is close to pile top, fast-play takes priority - const result = computeEffectiveDelay( - /* committedDelay */ 5000, - /* elapsed */ 1000, - /* playerHandSize */ 2, - /* opponentHandSize */ 0, - /* cardValue */ 51, - /* pileTopValue */ 50, - ); - expect(result).toBe(AI_LAST_CARD_DELAY); - }); - - it('enforces proximity delay for card value 1 above pile top', () => { - // Card 51, pile top 50 → gap = 1 - const result = computeEffectiveDelay( - /* committedDelay */ 2000, - /* elapsed */ 1950, - /* playerHandSize */ 3, - /* opponentHandSize */ 3, - /* cardValue */ 51, - /* pileTopValue */ 50, - ); - expect(result).toBe(PROXIMITY_MIN_DELAY); - }); - }); -}); diff --git a/tests/the-mind/auto-play.test.ts b/tests/the-mind/auto-play.test.ts deleted file mode 100644 index 512a5819..00000000 --- a/tests/the-mind/auto-play.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - runGame, - type HeadlessGameConfig, - type HeadlessGameResult, -} from '../../example-games/the-mind/headlessGame'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Run a game with a known seed and return the result. */ -function runSeeded(seed: number, overrides?: Partial): HeadlessGameResult { - return runGame({ seed, ...overrides }); -} - -// --------------------------------------------------------------------------- -// runGame — basic behaviour -// --------------------------------------------------------------------------- - -describe('headless runGame', () => { - it('returns a result with outcome "win" or "loss"', () => { - const result = runSeeded(42); - expect(['win', 'loss']).toContain(result.outcome); - }); - - it('finalLevel is between 1 and 8', () => { - const result = runSeeded(42); - expect(result.finalLevel).toBeGreaterThanOrEqual(1); - expect(result.finalLevel).toBeLessThanOrEqual(8); - }); - - it('finalLives is >= 0', () => { - const result = runSeeded(42); - expect(result.finalLives).toBeGreaterThanOrEqual(0); - }); - - it('totalPlays is positive', () => { - const result = runSeeded(42); - expect(result.totalPlays).toBeGreaterThan(0); - }); - - it('totalPenalties is >= 0', () => { - const result = runSeeded(42); - expect(result.totalPenalties).toBeGreaterThanOrEqual(0); - }); - - it('outcome "loss" implies finalLives is 0', () => { - // Try several seeds to find a loss - for (let seed = 0; seed < 50; seed++) { - const result = runSeeded(seed); - if (result.outcome === 'loss') { - expect(result.finalLives).toBe(0); - return; - } - } - // If no loss found in 50 seeds, skip the assertion (unlikely) - }); - - it('outcome "win" implies finalLevel is 8', () => { - const zeroJitter = { baseDuration: 5000, jitterRange: 0 }; - for (let seed = 0; seed < 100; seed++) { - const result = runGame({ seed, timingConfig: zeroJitter }); - if (result.outcome === 'win') { - expect(result.finalLevel).toBe(8); - return; - } - } - // If no win found in 100 seeds with zero jitter, skip - }); -}); - -// --------------------------------------------------------------------------- -// Determinism -// --------------------------------------------------------------------------- - -describe('headless runGame — determinism', () => { - it('same seed produces identical results', () => { - const a = runSeeded(123); - const b = runSeeded(123); - - expect(a.outcome).toBe(b.outcome); - expect(a.totalPlays).toBe(b.totalPlays); - expect(a.totalPenalties).toBe(b.totalPenalties); - expect(a.finalLevel).toBe(b.finalLevel); - expect(a.finalLives).toBe(b.finalLives); - }); - - it('same seed produces identical transcript events (excluding timestamps)', () => { - const a = runSeeded(456); - const b = runSeeded(456); - - // Compare event types and counts - const aEvents = a.transcript.events.map((e) => e.type); - const bEvents = b.transcript.events.map((e) => e.type); - expect(aEvents).toEqual(bEvents); - - // Compare card-played events specifically - const aPlays = a.transcript.events.filter((e) => e.type === 'card-played'); - const bPlays = b.transcript.events.filter((e) => e.type === 'card-played'); - expect(aPlays.length).toBe(bPlays.length); - - for (let i = 0; i < aPlays.length; i++) { - const ap = aPlays[i] as { playerId: number; cardValue: number }; - const bp = bPlays[i] as { playerId: number; cardValue: number }; - expect(ap.playerId).toBe(bp.playerId); - expect(ap.cardValue).toBe(bp.cardValue); - } - }); - - it('different seeds produce different results (at least sometimes)', () => { - const results: HeadlessGameResult[] = []; - for (let seed = 0; seed < 20; seed++) { - results.push(runSeeded(seed)); - } - - // At least two different outcomes or play counts across seeds - const uniquePlays = new Set(results.map((r) => r.totalPlays)); - expect(uniquePlays.size).toBeGreaterThan(1); - }); -}); - -// --------------------------------------------------------------------------- -// Configuration -// --------------------------------------------------------------------------- - -describe('headless runGame — configuration', () => { - it('uses default seed of 42 when no config provided', () => { - const noConfig = runGame(); - const explicit42 = runGame({ seed: 42 }); - - expect(noConfig.outcome).toBe(explicit42.outcome); - expect(noConfig.totalPlays).toBe(explicit42.totalPlays); - expect(noConfig.finalLevel).toBe(explicit42.finalLevel); - }); - - it('respects custom player names', () => { - const result = runGame({ - seed: 42, - playerNames: ['Alice', 'Bob'], - }); - - expect(result.transcript.initialState.playerNames).toEqual([ - 'Alice', - 'Bob', - ]); - }); - - it('marks both players as AI', () => { - const result = runSeeded(42); - expect(result.transcript.initialState.isAI).toEqual([true, true]); - }); - - it('respects custom AI seeds (different AI seeds = different play order)', () => { - const a = runGame({ - seed: 42, - player0AiSeed: 100, - player1AiSeed: 200, - }); - const b = runGame({ - seed: 42, - player0AiSeed: 300, - player1AiSeed: 400, - }); - - // Same deck deal (same game seed) but different AI timing - // The total plays or penalties may differ - const aTotalEvents = a.transcript.events.length; - const bTotalEvents = b.transcript.events.length; - - // They might happen to be the same, so just check both complete - expect(['win', 'loss']).toContain(a.outcome); - expect(['win', 'loss']).toContain(b.outcome); - // At minimum both should have events - expect(aTotalEvents).toBeGreaterThan(0); - expect(bTotalEvents).toBeGreaterThan(0); - }); - - it('respects custom timing config (zero jitter)', () => { - const a = runGame({ - seed: 42, - timingConfig: { baseDuration: 5000, jitterRange: 0 }, - }); - const b = runGame({ - seed: 42, - timingConfig: { baseDuration: 5000, jitterRange: 0 }, - }); - - // With zero jitter, results must be perfectly deterministic - expect(a.outcome).toBe(b.outcome); - expect(a.totalPlays).toBe(b.totalPlays); - expect(a.totalPenalties).toBe(b.totalPenalties); - }); -}); - -// --------------------------------------------------------------------------- -// Transcript structure -// --------------------------------------------------------------------------- - -describe('headless runGame — transcript', () => { - it('transcript version is 2', () => { - const result = runSeeded(42); - expect(result.transcript.version).toBe(2); - }); - - it('transcript gameType is "the-mind"', () => { - const result = runSeeded(42); - expect(result.transcript.gameType).toBe('the-mind'); - }); - - it('transcript has startedAt and endedAt timestamps', () => { - const result = runSeeded(42); - expect(result.transcript.startedAt).toBeTruthy(); - expect(result.transcript.endedAt).toBeTruthy(); - }); - - it('transcript results are populated', () => { - const result = runSeeded(42); - expect(result.transcript.results).not.toBeNull(); - expect(result.transcript.results!.outcome).toBe(result.outcome); - expect(result.transcript.results!.finalLevel).toBe(result.finalLevel); - expect(result.transcript.results!.finalLives).toBe(result.finalLives); - }); - - it('transcript has at least one card-played event', () => { - const result = runSeeded(42); - const plays = result.transcript.events.filter( - (e) => e.type === 'card-played', - ); - expect(plays.length).toBeGreaterThan(0); - }); - - it('transcript ends with a game-over event', () => { - const result = runSeeded(42); - const lastEvent = result.transcript.events[result.transcript.events.length - 1]; - expect(lastEvent.type).toBe('game-over'); - }); - - it('transcript results totalCardsPlayed matches totalPlays', () => { - const result = runSeeded(42); - expect(result.transcript.results!.totalCardsPlayed).toBe(result.totalPlays); - }); - - it('transcript results totalPenalties matches totalPenalties', () => { - const result = runSeeded(42); - expect(result.transcript.results!.totalPenalties).toBe( - result.totalPenalties, - ); - }); - - it('card-played events have valid player IDs (0 or 1)', () => { - const result = runSeeded(42); - const plays = result.transcript.events.filter( - (e) => e.type === 'card-played', - ); - for (const play of plays) { - const p = play as { playerId: number }; - expect([0, 1]).toContain(p.playerId); - } - }); - - it('card-played events have card values between 1 and 100', () => { - const result = runSeeded(42); - const plays = result.transcript.events.filter( - (e) => e.type === 'card-played', - ); - for (const play of plays) { - const p = play as { cardValue: number }; - expect(p.cardValue).toBeGreaterThanOrEqual(1); - expect(p.cardValue).toBeLessThanOrEqual(100); - } - }); - - it('winning game has level-complete events for all 8 levels', () => { - // Use zero jitter for a realistic chance of finding a win - const zeroJitter = { baseDuration: 5000, jitterRange: 0 }; - for (let seed = 0; seed < 200; seed++) { - const result = runGame({ seed, timingConfig: zeroJitter }); - if (result.outcome === 'win') { - const levelCompletes = result.transcript.events.filter( - (e) => e.type === 'level-complete', - ); - // All 8 levels completed — each produces a level-complete event - expect(levelCompletes.length).toBe(8); - return; - } - } - // If no win found, skip - }); -}); - -// --------------------------------------------------------------------------- -// Multiple runs (statistical sanity) -// --------------------------------------------------------------------------- - -describe('headless runGame — multiple seeds', () => { - it('can run 100 games without errors', () => { - for (let seed = 0; seed < 100; seed++) { - expect(() => runSeeded(seed)).not.toThrow(); - } - }); - - it('produces both wins and losses across seeds', () => { - let wins = 0; - let losses = 0; - - // Default jitter produces mostly losses - for (let seed = 0; seed < 100; seed++) { - const result = runSeeded(seed); - if (result.outcome === 'win') wins++; - else losses++; - } - - // Zero jitter produces mostly wins - const zeroJitter = { baseDuration: 5000, jitterRange: 0 }; - for (let seed = 0; seed < 100; seed++) { - const result = runGame({ seed, timingConfig: zeroJitter }); - if (result.outcome === 'win') wins++; - else losses++; - } - - expect(wins).toBeGreaterThan(0); - expect(losses).toBeGreaterThan(0); - }); - - it('total plays are reasonable (not infinite loops)', () => { - for (let seed = 0; seed < 50; seed++) { - const result = runSeeded(seed); - // Max possible plays: 8 levels, up to 8 cards each = 2*8*8 = 128 cards - // But penalty discards reduce this. Should never exceed 128. - expect(result.totalPlays).toBeLessThanOrEqual(128); - } - }); -}); - -// --------------------------------------------------------------------------- -// Edge cases -// --------------------------------------------------------------------------- - -describe('headless runGame — edge cases', () => { - it('zero jitter produces fully deterministic play order', () => { - const config: HeadlessGameConfig = { - seed: 99, - timingConfig: { baseDuration: 5000, jitterRange: 0 }, - }; - - const a = runGame(config); - const b = runGame(config); - - // With zero jitter, the exact sequence of plays should be identical - const aPlayValues = a.transcript.events - .filter((e) => e.type === 'card-played') - .map((e) => (e as { cardValue: number }).cardValue); - const bPlayValues = b.transcript.events - .filter((e) => e.type === 'card-played') - .map((e) => (e as { cardValue: number }).cardValue); - - expect(aPlayValues).toEqual(bPlayValues); - }); - - it('very short base duration still produces valid games', () => { - const result = runGame({ - seed: 42, - timingConfig: { baseDuration: 10, jitterRange: 0 }, - }); - expect(['win', 'loss']).toContain(result.outcome); - expect(result.totalPlays).toBeGreaterThan(0); - }); - - it('very long base duration still produces valid games', () => { - const result = runGame({ - seed: 42, - timingConfig: { baseDuration: 100000, jitterRange: 0 }, - }); - expect(['win', 'loss']).toContain(result.outcome); - expect(result.totalPlays).toBeGreaterThan(0); - }); - - it('high jitter still produces valid games', () => { - const result = runGame({ - seed: 42, - timingConfig: { baseDuration: 5000, jitterRange: 4000 }, - }); - expect(['win', 'loss']).toContain(result.outcome); - expect(result.totalPlays).toBeGreaterThan(0); - }); -}); diff --git a/tests/the-mind/game-state.test.ts b/tests/the-mind/game-state.test.ts deleted file mode 100644 index dac36a3e..00000000 --- a/tests/the-mind/game-state.test.ts +++ /dev/null @@ -1,803 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - setupTheMindGame, - dealLevel, - playCard, - getPileTopValue, - isGameOver, - isLevelComplete, - STARTING_LIVES, - MAX_LEVEL, - MAX_LIVES, - BONUS_LIFE_LEVELS, - type TheMindSession, - type PlayerId, -} from '../../example-games/the-mind/TheMindGameState'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; - -// ── Helper: create a session with controlled hands ───────── -// Instead of relying on random deals, we manually set hands to -// test specific scenarios deterministically. - -function createTestSession( - humanHand: number[], - aiHand: number[], - options?: { lives?: number; level?: number }, -): TheMindSession { - const rng = createSeededRng(42); - const session = setupTheMindGame({ rng }); - - // Override hands with known values for testing - session.players[0].hand = humanHand.map((v) => ({ value: v, faceUp: false })); - session.players[1].hand = aiHand.map((v) => ({ value: v, faceUp: false })); - - // Sort ascending (as dealLevel does) - session.players[0].hand.sort((a, b) => a.value - b.value); - session.players[1].hand.sort((a, b) => a.value - b.value); - - if (options?.lives !== undefined) { - session.lives = options.lives; - } - if (options?.level !== undefined) { - session.currentLevel = options.level; - } - - // Clear the pile so it starts empty for our test - session.pile.clear(); - - return session; -} - -// ═══════════════════════════════════════════════════════════ -// Setup -// ═══════════════════════════════════════════════════════════ - -describe('setupTheMindGame', () => { - it('creates a session with 2 players', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.players).toHaveLength(2); - }); - - it('uses default player names', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.players[0].name).toBe('Player 1'); - expect(session.players[1].name).toBe('Player 2'); - }); - - it('uses custom player names', () => { - const session = setupTheMindGame({ - playerNames: ['Alice', 'Bob'], - rng: createSeededRng(1), - }); - expect(session.players[0].name).toBe('Alice'); - expect(session.players[1].name).toBe('Bob'); - }); - - it('defaults to human + AI', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.players[0].isAI).toBe(false); - expect(session.players[1].isAI).toBe(true); - }); - - it('supports custom AI flags', () => { - const session = setupTheMindGame({ - isAI: [true, true], - rng: createSeededRng(1), - }); - expect(session.players[0].isAI).toBe(true); - expect(session.players[1].isAI).toBe(true); - }); - - it('starts at level 1', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.currentLevel).toBe(1); - }); - - it('starts with STARTING_LIVES lives', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.lives).toBe(STARTING_LIVES); - }); - - it('starts with outcome in-progress', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.outcome).toBe('in-progress'); - }); - - it('deals 1 card per player at level 1', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.players[0].hand).toHaveLength(1); - expect(session.players[1].hand).toHaveLength(1); - }); - - it('starts with an empty pile', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.pile.isEmpty()).toBe(true); - }); - - it('produces deterministic deals with same seed', () => { - const s1 = setupTheMindGame({ rng: createSeededRng(42) }); - const s2 = setupTheMindGame({ rng: createSeededRng(42) }); - - expect(s1.players[0].hand.map((c) => c.value)).toEqual( - s2.players[0].hand.map((c) => c.value), - ); - expect(s1.players[1].hand.map((c) => c.value)).toEqual( - s2.players[1].hand.map((c) => c.value), - ); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Dealing -// ═══════════════════════════════════════════════════════════ - -describe('dealLevel', () => { - let session: TheMindSession; - - beforeEach(() => { - session = setupTheMindGame({ rng: createSeededRng(42) }); - }); - - it('deals correct number of cards per player for each level', () => { - for (let level = 1; level <= MAX_LEVEL; level++) { - dealLevel(session, level); - expect(session.players[0].hand).toHaveLength(level); - expect(session.players[1].hand).toHaveLength(level); - } - }); - - it('clears the pile when dealing a new level', () => { - // Play a card to put something on the pile - const s = createTestSession([10], [20]); - playCard(s, 0, 10); - expect(s.pile.isEmpty()).toBe(false); - - // Deal new level - dealLevel(s, 2); - expect(s.pile.isEmpty()).toBe(true); - }); - - it('updates currentLevel to the dealt level', () => { - dealLevel(session, 5); - expect(session.currentLevel).toBe(5); - }); - - it('hands are sorted ascending by value', () => { - for (let level = 1; level <= MAX_LEVEL; level++) { - dealLevel(session, level); - for (const player of session.players) { - for (let i = 1; i < player.hand.length; i++) { - expect(player.hand[i].value).toBeGreaterThan( - player.hand[i - 1].value, - ); - } - } - } - }); - - it('all dealt cards have unique values', () => { - dealLevel(session, MAX_LEVEL); - const allValues = [ - ...session.players[0].hand.map((c) => c.value), - ...session.players[1].hand.map((c) => c.value), - ]; - const valueSet = new Set(allValues); - expect(valueSet.size).toBe(allValues.length); - }); - - it('all dealt card values are in [1, 100]', () => { - dealLevel(session, MAX_LEVEL); - for (const player of session.players) { - for (const card of player.hand) { - expect(card.value).toBeGreaterThanOrEqual(1); - expect(card.value).toBeLessThanOrEqual(100); - } - } - }); - - it('throws for level 0', () => { - expect(() => dealLevel(session, 0)).toThrow('Invalid level 0'); - }); - - it('throws for level 9', () => { - expect(() => dealLevel(session, 9)).toThrow('Invalid level 9'); - }); - - it('throws for negative levels', () => { - expect(() => dealLevel(session, -1)).toThrow('Invalid level -1'); - }); - - it('all dealt cards start face-down', () => { - dealLevel(session, 5); - for (const player of session.players) { - for (const card of player.hand) { - expect(card.faceUp).toBe(false); - } - } - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Query helpers -// ═══════════════════════════════════════════════════════════ - -describe('getPileTopValue', () => { - it('returns 0 for empty pile', () => { - const session = createTestSession([10], [20]); - expect(getPileTopValue(session)).toBe(0); - }); - - it('returns value of the last played card', () => { - const session = createTestSession([10, 30], [20, 40]); - playCard(session, 0, 10); - expect(getPileTopValue(session)).toBe(10); - playCard(session, 1, 20); - expect(getPileTopValue(session)).toBe(20); - }); -}); - -describe('isGameOver', () => { - it('returns false for in-progress game', () => { - const session = createTestSession([10], [20]); - expect(isGameOver(session)).toBe(false); - }); - - it('returns true after a loss', () => { - const session = createTestSession([10], [20], { lives: 1 }); - // Play 20 first -- penalty for holding 10 - playCard(session, 1, 20); - expect(isGameOver(session)).toBe(true); - }); -}); - -describe('isLevelComplete', () => { - it('returns false when players have cards', () => { - const session = createTestSession([10], [20]); - expect(isLevelComplete(session)).toBe(false); - }); - - it('returns true when both hands are empty', () => { - const session = createTestSession([], []); - expect(isLevelComplete(session)).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Legal play -// ═══════════════════════════════════════════════════════════ - -describe('playCard - legal plays', () => { - it('plays a card onto an empty pile', () => { - const session = createTestSession([10], [20]); - const result = playCard(session, 0, 10); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(false); - expect(result.penaltyCards).toHaveLength(0); - expect(getPileTopValue(session)).toBe(10); - expect(session.players[0].hand).toHaveLength(0); - }); - - it('plays a card higher than pile top', () => { - const session = createTestSession([10, 30], [20, 40]); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(false); - expect(getPileTopValue(session)).toBe(20); - }); - - it('either player can play (no turn order)', () => { - const session = createTestSession([30], [10, 20]); - - // AI plays first - const r1 = playCard(session, 1, 10); - expect(r1.success).toBe(true); - - // AI plays again - const r2 = playCard(session, 1, 20); - expect(r2.success).toBe(true); - }); - - it('played card is turned face-up', () => { - const session = createTestSession([10], [20]); - playCard(session, 0, 10); - const top = session.pile.peek()!; - expect(top.faceUp).toBe(true); - }); - - it('played card is removed from hand', () => { - const session = createTestSession([10, 30], [20]); - playCard(session, 0, 10); - expect(session.players[0].hand.map((c) => c.value)).toEqual([30]); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Illegal plays -// ═══════════════════════════════════════════════════════════ - -describe('playCard - illegal plays', () => { - it('rejects card lower than pile top', () => { - // Use cards where 10 is the lowest, so no penalty when playing 20 - const session = createTestSession([10, 20], [30]); - playCard(session, 0, 20); // pile top = 20 (penalty: discards 10) - // Now try to play a card lower than pile top - // We need to add a card manually since 10 was discarded - session.players[0].hand = [{ value: 15, faceUp: false }]; - const result = playCard(session, 0, 15); - - expect(result.success).toBe(false); - expect(result.error).toContain('not higher than pile top'); - }); - - it('rejects card equal to pile top', () => { - // This shouldn't normally happen (unique values), but guard anyway - const session = createTestSession([10], [20]); - playCard(session, 0, 10); // pile top = 10 - - // Manually add a card with same value to test the guard - session.players[1].hand = [{ value: 10, faceUp: false }]; - const result = playCard(session, 1, 10); - expect(result.success).toBe(false); - expect(result.error).toContain('not higher than pile top'); - }); - - it('rejects card not in hand', () => { - const session = createTestSession([10], [20]); - const result = playCard(session, 0, 99); - - expect(result.success).toBe(false); - expect(result.error).toContain('not in player'); - }); - - it('rejects play when game is over (loss)', () => { - const session = createTestSession([10], [20], { lives: 1 }); - playCard(session, 1, 20); // penalty kills last life - - const result = playCard(session, 0, 10); - expect(result.success).toBe(false); - expect(result.error).toContain('already over'); - }); - - it('rejects play when game is over (win)', () => { - const session = createTestSession([10], [20], { level: MAX_LEVEL }); - playCard(session, 0, 10); - playCard(session, 1, 20); - // Game is won - - // Try to play after win - session.players[0].hand = [{ value: 30, faceUp: false }]; - const result = playCard(session, 0, 30); - expect(result.success).toBe(false); - expect(result.error).toContain('already over'); - }); - - it('does not modify state on rejected play', () => { - const session = createTestSession([10], [20]); - playCard(session, 0, 10); // pile top = 10 - - const livesBefore = session.lives; - const pileSizeBefore = session.pile.size(); - const handBefore = [...session.players[1].hand.map((c) => c.value)]; - - // Try illegal play - playCard(session, 1, 5); // card not in hand - - expect(session.lives).toBe(livesBefore); - expect(session.pile.size()).toBe(pileSizeBefore); - expect(session.players[1].hand.map((c) => c.value)).toEqual(handBefore); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Penalty logic -// ═══════════════════════════════════════════════════════════ - -describe('playCard - penalty (single lower card)', () => { - it('loses a life when opponent holds a lower card', () => { - const session = createTestSession([5], [20], { lives: 2 }); - // AI plays 20 while human holds 5 - const result = playCard(session, 1, 20); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(true); - expect(session.lives).toBe(1); - }); - - it('discards the lower card from opponent', () => { - const session = createTestSession([5], [20], { lives: 2 }); - const result = playCard(session, 1, 20); - - expect(result.penaltyCards).toHaveLength(1); - expect(result.penaltyCards[0].playerId).toBe(0); - expect(result.penaltyCards[0].card.value).toBe(5); - // Level also completed (both hands empty after penalty + play), - // so new cards were dealt for the next level. - expect(result.levelComplete).toBe(true); - }); -}); - -describe('playCard - penalty (multiple lower cards across both hands)', () => { - it('discards all lower cards from both players', () => { - // Human holds [3, 8], AI holds [5, 50] - // AI plays 50 -- human's 3 and 8, and AI's 5 are all lower - const session = createTestSession([3, 8], [5, 50], { lives: 2 }); - const result = playCard(session, 1, 50); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(true); - expect(result.penaltyCards).toHaveLength(3); - - // Verify all lower cards were discarded - const penaltyValues = result.penaltyCards.map((p) => p.card.value).sort((a, b) => a - b); - expect(penaltyValues).toEqual([3, 5, 8]); - - // After penalty + playing 50, all original cards are gone. - // Level completes (both hands empty) and auto-advances with new deal. - expect(result.levelComplete).toBe(true); - }); - - it('only loses one life per play regardless of how many cards are discarded', () => { - const session = createTestSession([1, 2, 3], [4, 50], { lives: 2 }); - const result = playCard(session, 1, 50); - - expect(result.lifeLost).toBe(true); - expect(session.lives).toBe(1); - expect(result.penaltyCards).toHaveLength(4); // 1, 2, 3, 4 - }); - - it('discards lower cards from the playing player too', () => { - // Human holds [5, 30], human plays 30 while also holding 5 - // Wait, 5 < 30, so human's own 5 gets discarded as penalty? - // Actually re-reading the rules: "when a card is played while EITHER - // player holds a lower-valued card" -- but the played card itself is - // already being played, not "held". The player's own lower cards - // should also trigger a penalty. - const session = createTestSession([5, 30], [40], { lives: 2 }); - const result = playCard(session, 0, 30); - - // Human held 5 which is lower than 30 they just played - expect(result.lifeLost).toBe(true); - expect(result.penaltyCards).toHaveLength(1); - expect(result.penaltyCards[0].playerId).toBe(0); - expect(result.penaltyCards[0].card.value).toBe(5); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Life loss -// ═══════════════════════════════════════════════════════════ - -describe('playCard - life loss and game over', () => { - it('game ends in loss when lives reach 0', () => { - const session = createTestSession([5], [20], { lives: 1 }); - const result = playCard(session, 1, 20); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(true); - expect(session.lives).toBe(0); - expect(session.outcome).toBe('loss'); - expect(isGameOver(session)).toBe(true); - }); - - it('game continues if lives remain after penalty', () => { - const session = createTestSession([5], [20], { lives: 2 }); - playCard(session, 1, 20); - - expect(session.lives).toBe(1); - expect(session.outcome).toBe('in-progress'); - expect(isGameOver(session)).toBe(false); - }); - - it('starts with the correct number of lives', () => { - const session = setupTheMindGame({ rng: createSeededRng(1) }); - expect(session.lives).toBe(STARTING_LIVES); - expect(STARTING_LIVES).toBe(2); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Bonus life -// ═══════════════════════════════════════════════════════════ - -describe('playCard - bonus life award', () => { - it('awards bonus life after clearing level 3', () => { - const session = createTestSession([10], [20], { level: 3, lives: 2 }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - expect(result.bonusLifeAwarded).toBe(true); - expect(session.lives).toBe(3); - }); - - it('awards bonus life after clearing level 6', () => { - const session = createTestSession([10], [20], { level: 6, lives: 2 }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - expect(result.bonusLifeAwarded).toBe(true); - expect(session.lives).toBe(3); - }); - - it('does not award bonus life at non-bonus levels', () => { - const session = createTestSession([10], [20], { level: 2, lives: 2 }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - expect(result.bonusLifeAwarded).toBe(false); - expect(session.lives).toBe(2); - }); - - it('does not exceed MAX_LIVES', () => { - const session = createTestSession([10], [20], { level: 3, lives: MAX_LIVES }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - expect(result.bonusLifeAwarded).toBe(false); - expect(session.lives).toBe(MAX_LIVES); - }); - - it('BONUS_LIFE_LEVELS contains levels 3 and 6', () => { - expect(BONUS_LIFE_LEVELS).toContain(3); - expect(BONUS_LIFE_LEVELS).toContain(6); - expect(BONUS_LIFE_LEVELS).toHaveLength(2); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Level completion -// ═══════════════════════════════════════════════════════════ - -describe('playCard - level completion', () => { - it('completes a level when both hands are empty', () => { - const session = createTestSession([10], [20], { level: 1 }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - }); - - it('does not complete level while cards remain', () => { - const session = createTestSession([10, 30], [20, 40], { level: 2 }); - const result = playCard(session, 0, 10); - - expect(result.levelComplete).toBe(false); - }); - - it('auto-advances to next level after completion', () => { - const session = createTestSession([10], [20], { level: 1 }); - playCard(session, 0, 10); - playCard(session, 1, 20); - - // Should have advanced to level 2 and dealt 2 cards each - expect(session.currentLevel).toBe(2); - expect(session.players[0].hand).toHaveLength(2); - expect(session.players[1].hand).toHaveLength(2); - }); - - it('clears the pile when advancing to next level', () => { - const session = createTestSession([10], [20], { level: 1 }); - playCard(session, 0, 10); - playCard(session, 1, 20); - - // Pile should be clear for the new level - expect(session.pile.isEmpty()).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Win condition -// ═══════════════════════════════════════════════════════════ - -describe('playCard - win condition', () => { - it('game ends in win after clearing level 8', () => { - const session = createTestSession([10], [20], { level: MAX_LEVEL }); - playCard(session, 0, 10); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(true); - expect(session.outcome).toBe('win'); - expect(isGameOver(session)).toBe(true); - }); - - it('does not deal a new level after winning', () => { - const session = createTestSession([10], [20], { level: MAX_LEVEL }); - playCard(session, 0, 10); - playCard(session, 1, 20); - - // Level should remain at MAX_LEVEL (no level 9) - expect(session.currentLevel).toBe(MAX_LEVEL); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Loss condition -// ═══════════════════════════════════════════════════════════ - -describe('playCard - loss condition', () => { - it('game ends in loss when last life is lost', () => { - const session = createTestSession([5], [20], { lives: 1 }); - const result = playCard(session, 1, 20); - - expect(result.lifeLost).toBe(true); - expect(session.lives).toBe(0); - expect(session.outcome).toBe('loss'); - }); - - it('levelComplete is false on a losing play', () => { - const session = createTestSession([5], [20], { lives: 1 }); - const result = playCard(session, 1, 20); - - expect(result.levelComplete).toBe(false); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Edge cases -// ═══════════════════════════════════════════════════════════ - -describe('edge cases', () => { - it('play from empty hand is rejected', () => { - const session = createTestSession([], [20]); - const result = playCard(session, 0, 10); - - expect(result.success).toBe(false); - expect(result.error).toContain('not in player'); - }); - - it('no penalty when playing the lowest card', () => { - // If the played card is the lowest of all held cards, no penalty - const session = createTestSession([5, 30], [20, 40]); - const result = playCard(session, 0, 5); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(false); - expect(result.penaltyCards).toHaveLength(0); - }); - - it('perfect play through a level with no penalties', () => { - // Cards played in perfect ascending order - const session = createTestSession([10, 30], [20, 40], { level: 2 }); - - playCard(session, 0, 10); - playCard(session, 1, 20); - playCard(session, 0, 30); - const result = playCard(session, 1, 40); - - expect(result.levelComplete).toBe(true); - expect(session.lives).toBe(STARTING_LIVES); // No lives lost - }); - - it('penalty discards are reflected in penaltyCards result', () => { - const session = createTestSession([1, 2, 3], [10, 50], { lives: 2 }); - const result = playCard(session, 1, 10); - - // 1, 2, 3 are all lower than 10 and held by human - expect(result.penaltyCards).toHaveLength(3); - const discardedValues = result.penaltyCards - .map((p) => p.card.value) - .sort((a, b) => a - b); - expect(discardedValues).toEqual([1, 2, 3]); - }); - - it('consecutive penalties reduce lives correctly', () => { - // Set up for two penalties - const session = createTestSession([5, 25], [15, 35], { lives: 3 }); - - // AI plays 15 while human holds 5 -- penalty (lives: 3 -> 2) - playCard(session, 1, 15); - expect(session.lives).toBe(2); - - // AI plays 35 while human holds 25 -- penalty (lives: 2 -> 1) - playCard(session, 1, 35); - expect(session.lives).toBe(1); - }); - - it('level completion after penalty with lives remaining', () => { - // Human holds [5], AI holds [20]. AI plays 20 -- penalty (5 discarded). - // After penalty, both hands are empty, so level completes. - const session = createTestSession([5], [20], { lives: 2, level: 1 }); - const result = playCard(session, 1, 20); - - expect(result.lifeLost).toBe(true); - expect(result.levelComplete).toBe(true); - expect(session.lives).toBe(1); - // Should have advanced to level 2 - expect(session.currentLevel).toBe(2); - }); - - it('multiple plays from same player in sequence', () => { - const session = createTestSession([10, 20, 30], [40, 50, 60], { level: 3 }); - - playCard(session, 0, 10); - playCard(session, 0, 20); - const result = playCard(session, 0, 30); - - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(false); - expect(session.players[0].hand).toHaveLength(0); - }); - - it('interleaved plays from both players', () => { - const session = createTestSession([10, 30, 50], [20, 40, 60], { level: 3 }); - - playCard(session, 0, 10); - playCard(session, 1, 20); - playCard(session, 0, 30); - playCard(session, 1, 40); - playCard(session, 0, 50); - const result = playCard(session, 1, 60); - - expect(result.levelComplete).toBe(true); - expect(result.lifeLost).toBe(false); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Full game simulation -// ═══════════════════════════════════════════════════════════ - -describe('full game simulation', () => { - it('can complete a perfect game through all 8 levels', () => { - const rng = createSeededRng(7); - const session = setupTheMindGame({ rng, isAI: [true, true] }); - - for (let level = 1; level <= MAX_LEVEL; level++) { - // Collect all cards from both hands and sort ascending - const allCards = [ - ...session.players[0].hand.map((c) => ({ ...c, pid: 0 as PlayerId })), - ...session.players[1].hand.map((c) => ({ ...c, pid: 1 as PlayerId })), - ].sort((a, b) => a.value - b.value); - - // Play cards in perfect ascending order - for (const { value, pid } of allCards) { - const result = playCard(session, pid, value); - expect(result.success).toBe(true); - expect(result.lifeLost).toBe(false); - } - - if (level < MAX_LEVEL) { - // Should have advanced to next level - expect(session.currentLevel).toBe(level + 1); - } - } - - expect(session.outcome).toBe('win'); - // Bonus life earned at level 3 (2 -> 3), but level 6 bonus is - // blocked because lives already at MAX_LIVES (3). - expect(session.lives).toBe(MAX_LIVES); - }); - - it('can lose the game from penalties', () => { - const session = createTestSession([1], [50], { lives: 1, level: 1 }); - - // AI plays 50 while human holds 1 -- penalty, lose last life - const result = playCard(session, 1, 50); - expect(result.lifeLost).toBe(true); - expect(session.outcome).toBe('loss'); - }); -}); - -// ═══════════════════════════════════════════════════════════ -// Constants -// ═══════════════════════════════════════════════════════════ - -describe('game constants', () => { - it('MAX_LEVEL is 8', () => { - expect(MAX_LEVEL).toBe(8); - }); - - it('STARTING_LIVES is 2', () => { - expect(STARTING_LIVES).toBe(2); - }); - - it('MAX_LIVES is 3', () => { - expect(MAX_LIVES).toBe(3); - }); -}); diff --git a/tests/the-mind/headless.test.ts b/tests/the-mind/headless.test.ts deleted file mode 100644 index eb99bdef..00000000 --- a/tests/the-mind/headless.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Headless integration smoke test for The Mind card texture pipeline. - * - * Verifies that the full preload → ensure → key resolution pipeline works - * in a Node (headless) environment without a browser. This confirms that: - * 1. preloadMindCardAssets populates the SVG text cache - * 2. ensureMindCardTexture returns a valid DPR-aware key - * 3. The adapter correctly resolves template IDs and texture keys - * 4. No missing-texture errors or unhandled rejections occur - */ - -import { describe, it, expect } from 'vitest'; - -describe('The Mind headless integration', () => { - it('full preload → ensure pipeline produces valid texture keys for card face and back', async () => { - const { preloadMindCardAssets, ensureMindCardTexture, ensureMindCardBackTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - const { resolveTemplateId, resolveBackTemplateId, getCanonicalTextureKey, ensureTexture, ensureBackTexture } = await import('../../example-games/the-mind/MindCardTextureAdapter'); - const { makeTextureKey } = await import('../../src/core-engine/SvgHelpers'); - - // Create a minimal mock scene compatible with SvgHelpers - const existingKeys = new Set(); - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { - exists: (key: string) => existingKeys.has(key), - addCanvas: (_key: string, _canvas: any) => { - existingKeys.add(_key); - }, - get: () => undefined, - }, - } as any; - - // Step 1: Preload (Node path populates svgTextCache) - preloadMindCardAssets(scene, 48, 65); - - // Step 2: Ensure a face texture for a known card value - const faceResult = await ensureMindCardTexture(scene, 42, 48, 65); - expect(faceResult).toHaveProperty('key'); - expect(faceResult.key).toBe(makeTextureKey('mind-42', 48, 65, 1)); - expect(faceResult.ready).toBe(false); // Node: no rasterisation possible - - // Step 3: Ensure the card-back texture - const backResult = await ensureMindCardBackTexture(scene, 48, 65); - expect(backResult).toHaveProperty('key'); - expect(backResult.key).toBe(makeTextureKey('mind-back', 48, 65, 1)); - expect(backResult.ready).toBe(false); - - // Step 4: Verify adapter produces matching keys - const adapterFaceKey = getCanonicalTextureKey(resolveTemplateId(42), 48, 65, 1); - expect(adapterFaceKey).toBe(faceResult.key); - - const adapterBackKey = getCanonicalTextureKey(resolveBackTemplateId(), 48, 65, 1); - expect(adapterBackKey).toBe(backResult.key); - - // Step 5: Adapter wrappers should produce same results - const adapterEnsureFace = await ensureTexture(scene, 42, 48, 65); - expect(adapterEnsureFace.key).toBe(faceResult.key); - expect(adapterEnsureFace.ready).toBe(faceResult.ready); - - const adapterEnsureBack = await ensureBackTexture(scene, 48, 65); - expect(adapterEnsureBack.key).toBe(backResult.key); - expect(adapterEnsureBack.ready).toBe(backResult.ready); - }); - - it('multiple ensureMindCardTexture calls return consistent keys', async () => { - const { preloadMindCardAssets, ensureMindCardTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { exists: () => false, addCanvas: () => undefined, get: () => undefined }, - } as any; - - preloadMindCardAssets(scene, 48, 65); - - // Call ensure for the same card twice - should return the same key - const result1 = await ensureMindCardTexture(scene, 7, 48, 65); - const result2 = await ensureMindCardTexture(scene, 7, 48, 65); - - expect(result1.key).toBe(result2.key); - expect(result1.key).toMatch(/^ms_card_mind-7_/); - }); - - it('boundary values (1 and 100) produce valid texture keys', async () => { - const { preloadMindCardAssets, ensureMindCardTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { exists: () => false, addCanvas: () => undefined, get: () => undefined }, - } as any; - - preloadMindCardAssets(scene, 48, 65); - - const minResult = await ensureMindCardTexture(scene, 1, 48, 65); - const maxResult = await ensureMindCardTexture(scene, 100, 48, 65); - - expect(minResult.key).toMatch(/^ms_card_mind-1_/); - expect(maxResult.key).toMatch(/^ms_card_mind-100_/); - expect(minResult.ready).toBe(false); - expect(maxResult.ready).toBe(false); - }); - - it('custom dimensions produce different texture keys than defaults', async () => { - const { preloadMindCardAssets, ensureMindCardTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - - const scene = { - sys: { game: {} }, - cache: { text: { get: () => undefined } }, - textures: { exists: () => false, addCanvas: () => undefined, get: () => undefined }, - } as any; - - preloadMindCardAssets(scene, 48, 65); - - const defaultResult = await ensureMindCardTexture(scene, 42, 48, 65); - const customResult = await ensureMindCardTexture(scene, 42, 120, 164); - - // Different dimensions should produce different keys - expect(defaultResult.key).not.toBe(customResult.key); - expect(defaultResult.key).toContain('48x65'); - expect(customResult.key).toContain('120x164'); - }); -}); \ No newline at end of file diff --git a/tests/the-mind/integration.test.ts b/tests/the-mind/integration.test.ts deleted file mode 100644 index 4d9fd813..00000000 --- a/tests/the-mind/integration.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Integration tests for The Mind. - * - * Verifies end-to-end integration across all modules: - * - Game selector registration in main.ts - * - Headless AI-vs-AI games completing without errors - * - Transcript structure and invariants - * - Game state + AI strategy + transcript recorder working together - * - Level progression and lives system correctness - */ - -import { describe, it, expect } from 'vitest'; -import { - runGame, - type HeadlessGameConfig, - type HeadlessGameResult, -} from '../../example-games/the-mind/headlessGame'; -import { - MAX_LEVEL, - STARTING_LIVES, - MAX_LIVES, - BONUS_LIFE_LEVELS, -} from '../../example-games/the-mind/TheMindGameState'; -import type { - MindCardPlayedEvent, - MindPenaltyEvent, - MindLevelCompleteEvent, - MindGameOverEvent, - MindTranscript, -} from '../../example-games/the-mind/GameTranscript'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Run a game with zero jitter for deterministic ordering. */ -function runDeterministic(seed: number, overrides?: Partial): HeadlessGameResult { - return runGame({ - seed, - timingConfig: { baseDuration: 5000, jitterRange: 0 }, - ...overrides, - }); -} - -/** Find a winning game among deterministic seeds. */ -function findWinningGame(maxSeeds = 200): HeadlessGameResult | null { - for (let seed = 0; seed < maxSeeds; seed++) { - const result = runDeterministic(seed); - if (result.outcome === 'win') return result; - } - return null; -} - -/** Find a losing game with default jitter. */ -function findLosingGame(maxSeeds = 100): HeadlessGameResult | null { - for (let seed = 0; seed < maxSeeds; seed++) { - const result = runGame({ seed }); - if (result.outcome === 'loss') return result; - } - return null; -} - -// --------------------------------------------------------------------------- -// Game selector registration -// --------------------------------------------------------------------------- - -describe('Integration: Game selector registration', () => { - // These tests verify that main.ts correctly registers The Mind. - // We import main.ts exports indirectly by checking the game catalogue shape. - - it('TheMindScene module exists at the expected path', async () => { - // We can't import TheMindScene directly in unit tests because it - // depends on Phaser which requires a browser environment. - // Instead we verify the module file exists by importing a non-Phaser - // module from the same game directory. - const mindCard = await import('../../example-games/the-mind/MindCard'); - expect(mindCard.createMindDeck).toBeDefined(); - expect(mindCard.CARD_BACK_KEY).toBe('mind-back'); - // Also verify the scene key constant used in main.ts - expect(mindCard.cardAssetKey({ value: 42, faceUp: true })).toBe('mind-42'); - }); - - it('all The Mind module exports are importable without errors', async () => { - // Verify every The Mind module can be imported cleanly - const [gameState, aiStrategy, transcript, headless, mindCard] = - await Promise.all([ - import('../../example-games/the-mind/TheMindGameState'), - import('../../example-games/the-mind/AiStrategy'), - import('../../example-games/the-mind/GameTranscript'), - import('../../example-games/the-mind/headlessGame'), - import('../../example-games/the-mind/MindCard'), - ]); - - // Core exports exist - expect(gameState.setupTheMindGame).toBeDefined(); - expect(gameState.playCard).toBeDefined(); - expect(gameState.isGameOver).toBeDefined(); - expect(gameState.MAX_LEVEL).toBe(8); - expect(aiStrategy.MindAiPlayer).toBeDefined(); - expect(transcript.MindTranscriptRecorder).toBeDefined(); - expect(headless.runGame).toBeDefined(); - expect(mindCard.createMindDeck).toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// Full game integration (winning game) -// --------------------------------------------------------------------------- - -describe('Integration: Full winning game', () => { - const winResult = findWinningGame(); - - // Guard: skip the suite if no win found (extremely unlikely with zero jitter) - it('a winning game can be found with deterministic seeds', () => { - expect(winResult).not.toBeNull(); - }); - - it('outcome is "win" and final level is MAX_LEVEL', () => { - if (!winResult) return; - expect(winResult.outcome).toBe('win'); - expect(winResult.finalLevel).toBe(MAX_LEVEL); - }); - - it('final lives are between 1 and MAX_LIVES', () => { - if (!winResult) return; - expect(winResult.finalLives).toBeGreaterThanOrEqual(1); - expect(winResult.finalLives).toBeLessThanOrEqual(MAX_LIVES); - }); - - it('transcript has level-complete events for all 8 levels in order', () => { - if (!winResult) return; - const levelCompletes = winResult.transcript.events.filter( - (e): e is MindLevelCompleteEvent => e.type === 'level-complete', - ); - expect(levelCompletes).toHaveLength(MAX_LEVEL); - for (let i = 0; i < MAX_LEVEL; i++) { - expect(levelCompletes[i].level).toBe(i + 1); - } - }); - - it('card-played events have valid card values in range 1-100', () => { - if (!winResult) return; - const plays = winResult.transcript.events.filter( - (e): e is MindCardPlayedEvent => e.type === 'card-played', - ); - - for (const play of plays) { - expect(play.cardValue).toBeGreaterThanOrEqual(1); - expect(play.cardValue).toBeLessThanOrEqual(100); - // pileTopAfter and pileSizeAfter are non-negative - // (may be 0 if level auto-advanced and cleared the pile) - expect(play.pileTopAfter).toBeGreaterThanOrEqual(0); - expect(play.pileSizeAfter).toBeGreaterThanOrEqual(0); - } - }); - - it('total cards played matches transcript event count', () => { - if (!winResult) return; - const plays = winResult.transcript.events.filter( - (e) => e.type === 'card-played', - ); - expect(plays.length).toBe(winResult.totalPlays); - expect(winResult.transcript.results!.totalCardsPlayed).toBe( - winResult.totalPlays, - ); - }); - - it('bonus lives are awarded at correct levels', () => { - if (!winResult) return; - const levelCompletes = winResult.transcript.events.filter( - (e): e is MindLevelCompleteEvent => e.type === 'level-complete', - ); - - for (const lc of levelCompletes) { - if (BONUS_LIFE_LEVELS.includes(lc.level)) { - // Bonus life may or may not be awarded (capped at MAX_LIVES) - // If lives were already at MAX_LIVES, no bonus awarded - // Just verify the field is a boolean - expect(typeof lc.bonusLifeAwarded).toBe('boolean'); - } else { - // Non-bonus levels should never award bonus - expect(lc.bonusLifeAwarded).toBe(false); - } - } - }); - - it('lives never exceed MAX_LIVES', () => { - if (!winResult) return; - const levelCompletes = winResult.transcript.events.filter( - (e): e is MindLevelCompleteEvent => e.type === 'level-complete', - ); - - for (const lc of levelCompletes) { - expect(lc.livesAfter).toBeLessThanOrEqual(MAX_LIVES); - expect(lc.livesAfter).toBeGreaterThanOrEqual(1); // still alive if we won - } - }); - - it('last event is game-over with outcome "win"', () => { - if (!winResult) return; - const lastEvent = winResult.transcript.events[ - winResult.transcript.events.length - 1 - ] as MindGameOverEvent; - expect(lastEvent.type).toBe('game-over'); - expect(lastEvent.outcome).toBe('win'); - expect(lastEvent.finalLevel).toBe(MAX_LEVEL); - expect(lastEvent.finalLives).toBe(winResult.finalLives); - }); -}); - -// --------------------------------------------------------------------------- -// Full game integration (losing game) -// --------------------------------------------------------------------------- - -describe('Integration: Full losing game', () => { - const loseResult = findLosingGame(); - - it('a losing game can be found with default jitter', () => { - expect(loseResult).not.toBeNull(); - }); - - it('outcome is "loss" and final lives is 0', () => { - if (!loseResult) return; - expect(loseResult.outcome).toBe('loss'); - expect(loseResult.finalLives).toBe(0); - }); - - it('has at least one penalty event', () => { - if (!loseResult) return; - const penalties = loseResult.transcript.events.filter( - (e) => e.type === 'penalty', - ); - expect(penalties.length).toBeGreaterThan(0); - expect(loseResult.totalPenalties).toBeGreaterThan(0); - }); - - it('final penalty brings lives to 0', () => { - if (!loseResult) return; - const penalties = loseResult.transcript.events.filter( - (e): e is MindPenaltyEvent => e.type === 'penalty', - ); - const lastPenalty = penalties[penalties.length - 1]; - expect(lastPenalty.livesRemaining).toBe(0); - }); - - it('penalty events have discarded cards with valid player IDs and values', () => { - if (!loseResult) return; - const penalties = loseResult.transcript.events.filter( - (e): e is MindPenaltyEvent => e.type === 'penalty', - ); - - for (const penalty of penalties) { - expect(penalty.discardedCards.length).toBeGreaterThan(0); - for (const dc of penalty.discardedCards) { - expect([0, 1]).toContain(dc.playerId); - expect(dc.cardValue).toBeGreaterThanOrEqual(1); - expect(dc.cardValue).toBeLessThanOrEqual(100); - } - } - }); - - it('last event is game-over with outcome "loss"', () => { - if (!loseResult) return; - const lastEvent = loseResult.transcript.events[ - loseResult.transcript.events.length - 1 - ] as MindGameOverEvent; - expect(lastEvent.type).toBe('game-over'); - expect(lastEvent.outcome).toBe('loss'); - expect(lastEvent.finalLives).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// Transcript structure invariants -// --------------------------------------------------------------------------- - -describe('Integration: Transcript structure invariants', () => { - it('transcript serializes to valid JSON and round-trips', () => { - const result = runDeterministic(42); - const json = JSON.stringify(result.transcript); - const parsed: MindTranscript = JSON.parse(json); - - expect(parsed.version).toBe(2); - expect(parsed.gameType).toBe('the-mind'); - expect(parsed.startedAt).toBeTruthy(); - expect(parsed.endedAt).toBeTruthy(); - expect(parsed.initialState).toBeDefined(); - expect(parsed.initialState.playerNames).toHaveLength(2); - expect(parsed.initialState.isAI).toEqual([true, true]); - expect(parsed.initialState.startingLives).toBe(STARTING_LIVES); - expect(parsed.initialState.startingLevel).toBe(1); - expect(parsed.initialState.hands).toHaveLength(2); - expect(parsed.events).toBeInstanceOf(Array); - expect(parsed.events.length).toBeGreaterThan(0); - expect(parsed.results).not.toBeNull(); - }); - - it('events are in valid chronological order (timestamps non-negative)', () => { - const result = runDeterministic(42); - for (const event of result.transcript.events) { - expect(event.timestamp).toBeGreaterThanOrEqual(0); - } - }); - - it('event types follow valid sequencing (no card-played after game-over)', () => { - const result = runDeterministic(42); - let gameOverSeen = false; - for (const event of result.transcript.events) { - if (gameOverSeen) { - // No events should follow game-over - throw new Error(`Event ${event.type} found after game-over`); - } - if (event.type === 'game-over') { - gameOverSeen = true; - } - } - expect(gameOverSeen).toBe(true); - }); - - it('player IDs in card-played events are always 0 or 1', () => { - const result = runDeterministic(42); - const plays = result.transcript.events.filter( - (e): e is MindCardPlayedEvent => e.type === 'card-played', - ); - for (const play of plays) { - expect([0, 1]).toContain(play.playerId); - } - }); - - it('card-played events have non-negative pileSizeAfter', () => { - const result = runDeterministic(42); - - for (const event of result.transcript.events) { - if (event.type === 'card-played') { - // pileSizeAfter may be 0 when the play completes a level - // (playCard auto-advances via dealLevel which clears the pile - // before the headless runner reads pile.size()) - expect(event.pileSizeAfter).toBeGreaterThanOrEqual(0); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Game invariants across multiple seeds -// --------------------------------------------------------------------------- - -describe('Integration: Game invariants across seeds', () => { - it('runs 50 deterministic games without errors', () => { - for (let seed = 0; seed < 50; seed++) { - expect(() => runDeterministic(seed)).not.toThrow(); - } - }); - - it('runs 50 jittered games without errors', () => { - for (let seed = 0; seed < 50; seed++) { - expect(() => runGame({ seed })).not.toThrow(); - } - }); - - it('all games have valid transcripts with required fields', () => { - for (let seed = 0; seed < 20; seed++) { - const result = runDeterministic(seed); - const t = result.transcript; - - // Required fields - expect(t.version).toBe(2); - expect(t.gameType).toBe('the-mind'); - expect(t.startedAt).toBeTruthy(); - expect(t.endedAt).toBeTruthy(); - expect(t.initialState.startingLives).toBe(STARTING_LIVES); - expect(t.initialState.startingLevel).toBe(1); - expect(t.results).not.toBeNull(); - expect(['win', 'loss']).toContain(t.results!.outcome); - - // Result consistency - expect(t.results!.outcome).toBe(result.outcome); - expect(t.results!.finalLevel).toBe(result.finalLevel); - expect(t.results!.finalLives).toBe(result.finalLives); - expect(t.results!.totalCardsPlayed).toBe(result.totalPlays); - expect(t.results!.totalPenalties).toBe(result.totalPenalties); - - // Last event is game-over - const lastEvent = t.events[t.events.length - 1]; - expect(lastEvent.type).toBe('game-over'); - } - }); - - it('winning games always have finalLevel = MAX_LEVEL and lives > 0', () => { - for (let seed = 0; seed < 100; seed++) { - const result = runDeterministic(seed); - if (result.outcome === 'win') { - expect(result.finalLevel).toBe(MAX_LEVEL); - expect(result.finalLives).toBeGreaterThan(0); - } - } - }); - - it('losing games always have finalLives = 0', () => { - for (let seed = 0; seed < 100; seed++) { - const result = runGame({ seed }); - if (result.outcome === 'loss') { - expect(result.finalLives).toBe(0); - } - } - }); - - it('total plays never exceed theoretical maximum (sum of level card counts)', () => { - // Level i has i cards per player, 2 players. Total = 2 * sum(1..8) = 72. - const maxPossiblePlays = 2 * (MAX_LEVEL * (MAX_LEVEL + 1)) / 2; // 72 - for (let seed = 0; seed < 50; seed++) { - const result = runDeterministic(seed); - expect(result.totalPlays).toBeLessThanOrEqual(maxPossiblePlays); - } - }); - - it('level-complete events have levels in strictly ascending order', () => { - for (let seed = 0; seed < 20; seed++) { - const result = runDeterministic(seed); - const levelCompletes = result.transcript.events.filter( - (e): e is MindLevelCompleteEvent => e.type === 'level-complete', - ); - - for (let i = 1; i < levelCompletes.length; i++) { - expect(levelCompletes[i].level).toBeGreaterThan( - levelCompletes[i - 1].level, - ); - } - } - }); -}); - -// --------------------------------------------------------------------------- -// Cross-module consistency -// --------------------------------------------------------------------------- - -describe('Integration: Cross-module consistency', () => { - it('initial state hands match level 1 (1 card per player)', () => { - const result = runDeterministic(42); - const hands = result.transcript.initialState.hands; - expect(hands[0]).toHaveLength(1); - expect(hands[1]).toHaveLength(1); - // Cards are in range 1-100 - expect(hands[0][0]).toBeGreaterThanOrEqual(1); - expect(hands[0][0]).toBeLessThanOrEqual(100); - expect(hands[1][0]).toBeGreaterThanOrEqual(1); - expect(hands[1][0]).toBeLessThanOrEqual(100); - }); - - it('all initial hand card values are unique', () => { - const result = runDeterministic(42); - const allValues = [ - ...result.transcript.initialState.hands[0], - ...result.transcript.initialState.hands[1], - ]; - const unique = new Set(allValues); - expect(unique.size).toBe(allValues.length); - }); - - it('AI timing config affects play patterns', () => { - const fastResult = runGame({ - seed: 42, - timingConfig: { baseDuration: 100, jitterRange: 0 }, - }); - const slowResult = runGame({ - seed: 42, - timingConfig: { baseDuration: 100000, jitterRange: 0 }, - }); - - // Both should complete successfully - expect(['win', 'loss']).toContain(fastResult.outcome); - expect(['win', 'loss']).toContain(slowResult.outcome); - - // With zero jitter, both should produce the same play order - // (timing doesn't change ordering when jitter is 0, only absolute times differ) - const fastPlays = fastResult.transcript.events - .filter((e): e is MindCardPlayedEvent => e.type === 'card-played') - .map((e) => e.cardValue); - const slowPlays = slowResult.transcript.events - .filter((e): e is MindCardPlayedEvent => e.type === 'card-played') - .map((e) => e.cardValue); - expect(fastPlays).toEqual(slowPlays); - }); - - it('custom player names appear in transcript', () => { - const result = runGame({ - seed: 42, - playerNames: ['Human', 'Bot'], - }); - expect(result.transcript.initialState.playerNames).toEqual([ - 'Human', - 'Bot', - ]); - }); -}); diff --git a/tests/the-mind/mind-animator.test.ts b/tests/the-mind/mind-animator.test.ts deleted file mode 100644 index 7577a47c..00000000 --- a/tests/the-mind/mind-animator.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { ensureTextureMock, ensureBackTextureMock, flipCardMock } = vi.hoisted(() => ({ - ensureTextureMock: vi.fn(async () => ({ key: 'ensured-face-key', ready: true })), - ensureBackTextureMock: vi.fn(async () => ({ key: 'ensured-back-key', ready: true })), - flipCardMock: vi.fn(), -})); - -vi.mock('../../example-games/the-mind/MindCardTextureAdapter', () => ({ - resolveTemplateId: vi.fn((value: number) => `mind-${value}`), - resolveBackTemplateId: vi.fn(() => 'mind-back'), - getCanonicalTextureKey: vi.fn((templateId: string) => `canonical-${templateId}`), - ensureTexture: ensureTextureMock, - ensureBackTexture: ensureBackTextureMock, -})); - -vi.mock('../../src/ui', () => ({ - GAME_W: 1000, - GAME_H: 700, - flipCard: flipCardMock, - shakeIllegalMove: vi.fn(), -})); - -import { MindAnimator } from '../../example-games/the-mind/scenes/MindAnimator'; - -function createSprite(key: string) { - const sprite = { - x: 100, - y: 100, - texture: { key }, - setDisplaySize: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setScale: vi.fn().mockReturnThis(), - disableInteractive: vi.fn().mockReturnThis(), - destroy: vi.fn(), - setTexture: vi.fn().mockReturnThis(), - }; - return sprite; -} - -function createScene() { - return { - add: { - image: vi.fn((_x: number, _y: number, key: string) => createSprite(key)), - }, - tweens: { - add: vi.fn(() => ({ stop: vi.fn() })), - }, - time: { - delayedCall: vi.fn((_delay: number, cb: () => void) => { - cb(); - }), - }, - } as unknown as Phaser.Scene; -} - -describe('MindAnimator', () => { - beforeEach(() => { - ensureTextureMock.mockClear(); - ensureBackTextureMock.mockClear(); - flipCardMock.mockClear(); - }); - - it('normalizes human card sprite scale before animating to pile', () => { - const scene = createScene(); - const humanSprite = createSprite('canonical-mind-42') as any; - humanSprite.__mindCardValue = 42; - - const renderer = { - aiCardSprites: [], - humanCardSprites: [humanSprite], - layout: { playPileCenterX: 640, playPileCenterY: 350, humanHandCenterY: 610, aiHandCenterY: 150 }, - } as any; - const session = { - players: [ - { hand: [] }, - { hand: [] }, - ], - } as any; - - const animator = new MindAnimator(scene, session, renderer, null); - - animator.animateCardTowardsPile(0, 42, vi.fn()); - - expect(humanSprite.disableInteractive).toHaveBeenCalled(); - expect(humanSprite.setScale).toHaveBeenCalledWith(1); - expect(humanSprite.setDisplaySize).toHaveBeenCalledWith(120, 164); - }); - - it('uses flip completion callback (not delayedCall) to finish AI card animation', async () => { - const scene = createScene() as any; - const renderer = { - aiCardSprites: [createSprite('canonical-mind-back')], - humanCardSprites: [], - layout: { playPileCenterX: 640, playPileCenterY: 350, humanHandCenterY: 610, aiHandCenterY: 150 }, - } as any; - const session = { - players: [ - { hand: [] }, - { hand: [] }, - ], - } as any; - - const animator = new MindAnimator(scene, session, renderer, null); - const onComplete = vi.fn(); - - animator.animateCardTowardsPile(1, 42, onComplete); - await Promise.resolve(); - await Promise.resolve(); - - expect(flipCardMock).toHaveBeenCalled(); - const flipArgs = flipCardMock.mock.calls[0][0] as { onComplete?: () => void }; - expect(typeof flipArgs.onComplete).toBe('function'); - - // Complete animation via flip callback and ensure delayedCall is not used. - flipArgs.onComplete?.(); - expect(onComplete).toHaveBeenCalled(); - expect(scene.time.delayedCall).not.toHaveBeenCalled(); - }); - - it('uses ensured textures for AI play animation instead of unresolved canonical keys', async () => { - const scene = createScene(); - const renderer = { - aiCardSprites: [createSprite('canonical-mind-back')], - humanCardSprites: [], - layout: { playPileCenterX: 640, playPileCenterY: 350, humanHandCenterY: 610, aiHandCenterY: 150 }, - } as any; - const session = { - players: [ - { hand: [] }, - { hand: [] }, - ], - } as any; - - const animator = new MindAnimator(scene, session, renderer, null); - const onComplete = vi.fn(); - - animator.animateCardTowardsPile(1, 42, onComplete); - await Promise.resolve(); - await Promise.resolve(); - - expect(ensureBackTextureMock).toHaveBeenCalled(); - expect(ensureTextureMock).toHaveBeenCalledWith(scene, 42, 120, 164); - - expect(flipCardMock).toHaveBeenCalled(); - const flipArgs = flipCardMock.mock.calls[0][0] as { newTexture: string }; - expect(flipArgs.newTexture).toBe('ensured-face-key'); - }); -}); diff --git a/tests/the-mind/mind-card-renderer.test.ts b/tests/the-mind/mind-card-renderer.test.ts deleted file mode 100644 index b9b46b2f..00000000 --- a/tests/the-mind/mind-card-renderer.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import type { MindCard } from '../../example-games/the-mind/MindCard'; -import { - CARD_BACK_KEY, - MIN_VALUE, - MAX_VALUE, -} from '../../example-games/the-mind/MindCard'; -import { - getMindCardTexture, - mindCardTextureKey, - MIND_CARD_W, - MIND_CARD_H, - makeMindCardTextureKey, -} from '../../example-games/the-mind/MindCardRenderer'; -import { - resolveTemplateId, - resolveBackTemplateId, - getCanonicalTextureKey, -} from '../../example-games/the-mind/MindCardTextureAdapter'; - -// ── Constants ──────────────────────────────────────────────── - -const ASSETS_DIR = join(__dirname, '..', '..', 'public', 'assets', 'cards', 'the-mind'); - -// ── Helper ─────────────────────────────────────────────────── - -function makeMindCard(value: number, faceUp: boolean): MindCard { - return { value, faceUp }; -} - -// ── getMindCardTexture ─────────────────────────────────────── - -describe('getMindCardTexture', () => { - it('should return card back key for a face-down card', () => { - const card = makeMindCard(42, false); - expect(getMindCardTexture(card)).toBe(CARD_BACK_KEY); - }); - - it('should return the correct template ID for a face-up card', () => { - const card = makeMindCard(42, true); - expect(getMindCardTexture(card)).toBe('mind-42'); - }); - - it('should return correct key for value 1', () => { - const card = makeMindCard(1, true); - expect(getMindCardTexture(card)).toBe('mind-1'); - }); - - it('should return correct key for value 100', () => { - const card = makeMindCard(100, true); - expect(getMindCardTexture(card)).toBe('mind-100'); - }); - - it('should return card back when faceUp is toggled to false', () => { - const card = makeMindCard(50, true); - expect(getMindCardTexture(card)).toBe('mind-50'); - card.faceUp = false; - expect(getMindCardTexture(card)).toBe(CARD_BACK_KEY); - }); - - it('should throw for value 0', () => { - const card = makeMindCard(0, true); - expect(() => getMindCardTexture(card)).toThrow('Invalid Mind card value: 0'); - }); - - it('should throw for value 101', () => { - const card = makeMindCard(101, true); - expect(() => getMindCardTexture(card)).toThrow('Invalid Mind card value: 101'); - }); - - it('should throw for negative values', () => { - const card = makeMindCard(-5, true); - expect(() => getMindCardTexture(card)).toThrow('Invalid Mind card value: -5'); - }); - - it('should throw for non-integer values', () => { - const card = makeMindCard(3.5, true); - expect(() => getMindCardTexture(card)).toThrow('Invalid Mind card value: 3.5'); - }); - - it('should NOT throw for face-down cards with invalid values (back key returned before validation)', () => { - const card = makeMindCard(0, false); - expect(getMindCardTexture(card)).toBe(CARD_BACK_KEY); - }); -}); - -// ── mindCardTextureKey ─────────────────────────────────────── - -describe('mindCardTextureKey', () => { - it('should return mind-1 for value 1', () => { - expect(mindCardTextureKey(1)).toBe('mind-1'); - }); - - it('should return mind-50 for value 50', () => { - expect(mindCardTextureKey(50)).toBe('mind-50'); - }); - - it('should return mind-100 for value 100', () => { - expect(mindCardTextureKey(100)).toBe('mind-100'); - }); - - it('should throw for value 0', () => { - expect(() => mindCardTextureKey(0)).toThrow('Invalid Mind card value'); - }); - - it('should throw for value 101', () => { - expect(() => mindCardTextureKey(101)).toThrow('Invalid Mind card value'); - }); - - it('should throw for NaN', () => { - expect(() => mindCardTextureKey(NaN)).toThrow('Invalid Mind card value'); - }); - - it('should throw for Infinity', () => { - expect(() => mindCardTextureKey(Infinity)).toThrow('Invalid Mind card value'); - }); -}); - -// ── DPR-aware texture keys ──────────────────────────────────── - -describe('makeMindCardTextureKey', () => { - it('should produce a DPR-aware key using SvgHelpers.makeTextureKey', () => { - const key = makeMindCardTextureKey('mind-42', 48, 65, 2); - expect(key).toBe('ms_card_mind-42_48x65@2'); - }); - - it('should default DPR to 1 when not provided and no window', () => { - const key = makeMindCardTextureKey('mind-42', 48, 65); - // In Node test environment, window is undefined, so DPR defaults to 1 - expect(key).toBe('ms_card_mind-42_48x65@1'); - }); - - it('should produce a key for the card back', () => { - const key = makeMindCardTextureKey('mind-back', 48, 65, 2); - expect(key).toBe('ms_card_mind-back_48x65@2'); - }); - - it('should round non-integer dimensions', () => { - const key = makeMindCardTextureKey('mind-1', 47.7, 64.3, 1); - expect(key).toBe('ms_card_mind-1_48x64@1'); - }); -}); - -// ── MindCardTextureAdapter ──────────────────────────────────── - -describe('MindCardTextureAdapter', () => { - describe('resolveTemplateId', () => { - it('should return mind-1 for value 1', () => { - expect(resolveTemplateId(1)).toBe('mind-1'); - }); - - it('should return mind-50 for value 50', () => { - expect(resolveTemplateId(50)).toBe('mind-50'); - }); - - it('should return mind-100 for value 100', () => { - expect(resolveTemplateId(100)).toBe('mind-100'); - }); - - it('should throw for value 0', () => { - expect(() => resolveTemplateId(0)).toThrow('Invalid Mind card value'); - }); - - it('should throw for value 101', () => { - expect(() => resolveTemplateId(101)).toThrow('Invalid Mind card value'); - }); - - it('should throw for non-integer values', () => { - expect(() => resolveTemplateId(3.5)).toThrow('Invalid Mind card value'); - }); - }); - - describe('resolveBackTemplateId', () => { - it('should return mind-back', () => { - expect(resolveBackTemplateId()).toBe('mind-back'); - }); - }); - - describe('getCanonicalTextureKey', () => { - it('should produce a DPR-aware key for a template ID', () => { - const key = getCanonicalTextureKey('mind-42', 48, 65, 2); - expect(key).toBe('ms_card_mind-42_48x65@2'); - }); - - it('should default width and height to MIND_CARD_W and MIND_CARD_H', () => { - const key = getCanonicalTextureKey('mind-42', undefined, undefined, 1); - expect(key).toBe(`ms_card_mind-42_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); - - it('should default DPR to 1 in Node environment', () => { - const key = getCanonicalTextureKey('mind-back'); - expect(key).toBe(`ms_card_mind-back_${MIND_CARD_W}x${MIND_CARD_H}@1`); - }); - }); -}); - -// ── Constants exports ──────────────────────────────────────── - -describe('MindCardRenderer constants', () => { - it('should export MIND_CARD_W as 48', () => { - expect(MIND_CARD_W).toBe(48); - }); - - it('should export MIND_CARD_H as 65', () => { - expect(MIND_CARD_H).toBe(65); - }); -}); - -// ── SVG asset file existence ───────────────────────────────── - -describe('generated SVG assets', () => { - it('should have generated 101 SVG files in the-mind directory', () => { - expect(existsSync(ASSETS_DIR)).toBe(true); - }); - - it('should have a card back SVG (mind-back.svg)', () => { - const filePath = join(ASSETS_DIR, 'mind-back.svg'); - expect(existsSync(filePath)).toBe(true); - }); - - it.each([1, 2, 10, 42, 50, 99, 100])( - 'should have numbered card SVG for value %d', - (value) => { - const filePath = join(ASSETS_DIR, `mind-${value}.svg`); - expect(existsSync(filePath)).toBe(true); - }, - ); - - it('should have all 100 numbered card SVGs (1-100)', () => { - for (let v = MIN_VALUE; v <= MAX_VALUE; v++) { - const filePath = join(ASSETS_DIR, `mind-${v}.svg`); - expect(existsSync(filePath)).toBe(true); - } - }); -}); - -// ── SVG content validation ─────────────────────────────────── - -describe('SVG content structure', () => { - it('should have correct dimensions (140x190) in numbered card SVGs', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-42.svg'), 'utf8'); - expect(svg).toContain('width="140"'); - expect(svg).toContain('height="190"'); - expect(svg).toContain('viewBox="0 0 140 190"'); - }); - - it('should have correct dimensions (140x190) in card back SVG', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-back.svg'), 'utf8'); - expect(svg).toContain('width="140"'); - expect(svg).toContain('height="190"'); - }); - - it('should contain the card value as text in a numbered card SVG', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-42.svg'), 'utf8'); - expect(svg).toContain('>42<'); - }); - - it('should contain the value 100 in the 3-digit card SVG', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-100.svg'), 'utf8'); - expect(svg).toContain('>100<'); - }); - - it('should contain the value 1 in the single-digit card SVG', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-1.svg'), 'utf8'); - expect(svg).toContain('>1<'); - }); - - it('should contain a "?" in the card back SVG', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-back.svg'), 'utf8'); - expect(svg).toContain('>?<'); - }); - - it('should be valid SVG (starts with )', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-42.svg'), 'utf8'); - expect(svg.trim()).toMatch(/^$/); - }); - - it('should use rounded corners (rx attribute)', () => { - const svg = readFileSync(join(ASSETS_DIR, 'mind-42.svg'), 'utf8'); - expect(svg).toContain('rx="10"'); - }); -}); - -// ── Lazy rasterisation helpers ───────────────────────────── - -describe('lazy rasterisation helpers', () => { - it('ensureMindCardTexture returns a getOrCreateTexture-like result when preloaded in Node', async () => { - const { preloadMindCardAssets, ensureMindCardTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - const { makeTextureKey } = await import('../../src/core-engine/SvgHelpers'); - - // Minimal mock scene compatible with SvgHelpers expectations used in tests. - const existingKeys = new Set(); - const textures = new Map void }>(); - - const scene = { - sys: { game: {} }, - cache: { text: { get: (_: string) => undefined } }, - textures: { - exists: (key: string) => existingKeys.has(key), - addCanvas: (_: string, __: any) => undefined, - get: (k: string) => textures.get(k), - }, - } as any; - - // Preload (Node path will read files into module cache if available). - preloadMindCardAssets(scene, 48, 65); - - // Call ensureMindCardTexture for a known card value. We expect a result - // with a DPR-aware texture key and an object that may include a promise - // for async rasterisation. - const res = await ensureMindCardTexture(scene, 42, 48, 65); - - expect(res).toHaveProperty('key'); - // Key should follow the DPR-aware format from SvgHelpers.makeTextureKey - expect(res.key).toBe(makeTextureKey('mind-42', 48, 65, 1)); - // In Node environment, ready should be false (no rasterisation possible) - expect(res.ready).toBe(false); - }); - - it('ensureMindCardTexture throws for invalid card values', async () => { - const { ensureMindCardTexture } = await import('../../example-games/the-mind/MindCardRenderer'); - - const scene = { - sys: { game: {} }, - textures: { exists: () => false, addCanvas: () => undefined, get: () => undefined }, - } as any; - - await expect(ensureMindCardTexture(scene, 0, 48, 65)).rejects.toThrow('Invalid Mind card value'); - await expect(ensureMindCardTexture(scene, 101, 48, 65)).rejects.toThrow('Invalid Mind card value'); - }); -}); \ No newline at end of file diff --git a/tests/the-mind/mind-card.test.ts b/tests/the-mind/mind-card.test.ts deleted file mode 100644 index 8ff83779..00000000 --- a/tests/the-mind/mind-card.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - type MindCard, - DECK_SIZE, - MIN_VALUE, - MAX_VALUE, - createMindDeck, - shuffleDeck, - cardLabel, - cardAssetKey, - CARD_BACK_KEY, -} from '../../example-games/the-mind/MindCard'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; - -// ── Constants ────────────────────────────────────────────── - -describe('Constants', () => { - it('has a deck size of 100', () => { - expect(DECK_SIZE).toBe(100); - }); - - it('has minimum value of 1', () => { - expect(MIN_VALUE).toBe(1); - }); - - it('has maximum value of 100', () => { - expect(MAX_VALUE).toBe(100); - }); - - it('card back key is defined', () => { - expect(CARD_BACK_KEY).toBe('mind-back'); - }); -}); - -// ── Deck creation ────────────────────────────────────────── - -describe('createMindDeck', () => { - const deck = createMindDeck(); - - it('creates exactly 100 cards', () => { - expect(deck).toHaveLength(100); - }); - - it('has cards with values 1 through 100', () => { - const values = deck.map((c) => c.value).sort((a, b) => a - b); - const expected = Array.from({ length: 100 }, (_, i) => i + 1); - expect(values).toEqual(expected); - }); - - it('has no duplicate values', () => { - const valueSet = new Set(deck.map((c) => c.value)); - expect(valueSet.size).toBe(100); - }); - - it('creates all cards face-down', () => { - expect(deck.every((c) => c.faceUp === false)).toBe(true); - }); - - it('creates cards in ascending order (1, 2, 3, ..., 100)', () => { - const values = deck.map((c) => c.value); - const expected = Array.from({ length: 100 }, (_, i) => i + 1); - expect(values).toEqual(expected); - }); - - it('returns a new array on each call', () => { - const deck1 = createMindDeck(); - const deck2 = createMindDeck(); - expect(deck1).not.toBe(deck2); - }); - - it('all values are within the valid range [1, 100]', () => { - for (const card of deck) { - expect(card.value).toBeGreaterThanOrEqual(MIN_VALUE); - expect(card.value).toBeLessThanOrEqual(MAX_VALUE); - } - }); - - it('all values are integers', () => { - for (const card of deck) { - expect(Number.isInteger(card.value)).toBe(true); - } - }); -}); - -// ── Card property access ─────────────────────────────────── - -describe('Card property access', () => { - it('faceUp can be mutated', () => { - const card = createMindDeck()[0]; - expect(card.faceUp).toBe(false); - card.faceUp = true; - expect(card.faceUp).toBe(true); - }); - - it('value is readonly (structurally enforced by TypeScript)', () => { - // We verify that the value property exists and is a number. - // TypeScript enforces readonly at compile time. - const card: MindCard = { value: 42, faceUp: false }; - expect(card.value).toBe(42); - expect(typeof card.value).toBe('number'); - }); -}); - -// ── Card labels ──────────────────────────────────────────── - -describe('cardLabel', () => { - it('returns the card value as a string', () => { - const card: MindCard = { value: 1, faceUp: false }; - expect(cardLabel(card)).toBe('1'); - }); - - it('handles two-digit values', () => { - const card: MindCard = { value: 42, faceUp: false }; - expect(cardLabel(card)).toBe('42'); - }); - - it('handles the maximum value', () => { - const card: MindCard = { value: 100, faceUp: false }; - expect(cardLabel(card)).toBe('100'); - }); -}); - -// ── Card asset keys ──────────────────────────────────────── - -describe('cardAssetKey', () => { - it('returns correct key for single-digit value', () => { - const card: MindCard = { value: 7, faceUp: false }; - expect(cardAssetKey(card)).toBe('mind-7'); - }); - - it('returns correct key for two-digit value', () => { - const card: MindCard = { value: 42, faceUp: false }; - expect(cardAssetKey(card)).toBe('mind-42'); - }); - - it('returns correct key for maximum value', () => { - const card: MindCard = { value: 100, faceUp: false }; - expect(cardAssetKey(card)).toBe('mind-100'); - }); - - it('returns correct key for minimum value', () => { - const card: MindCard = { value: 1, faceUp: false }; - expect(cardAssetKey(card)).toBe('mind-1'); - }); -}); - -// ── Shuffle ──────────────────────────────────────────────── - -describe('shuffleDeck', () => { - it('returns the same array reference', () => { - const deck = createMindDeck(); - const result = shuffleDeck(deck); - expect(result).toBe(deck); - }); - - it('preserves all 100 cards (no loss or duplication)', () => { - const deck = createMindDeck(); - shuffleDeck(deck); - expect(deck).toHaveLength(100); - const values = new Set(deck.map((c) => c.value)); - expect(values.size).toBe(100); - }); - - it('produces deterministic results with seeded RNG', () => { - const rng1 = createSeededRng(42); - const deck1 = shuffleDeck(createMindDeck(), rng1); - - const rng2 = createSeededRng(42); - const deck2 = shuffleDeck(createMindDeck(), rng2); - - expect(deck1.map((c) => c.value)).toEqual(deck2.map((c) => c.value)); - }); - - it('different seeds produce different orders', () => { - const rng1 = createSeededRng(42); - const deck1 = shuffleDeck(createMindDeck(), rng1); - - const rng2 = createSeededRng(99); - const deck2 = shuffleDeck(createMindDeck(), rng2); - - // Extremely unlikely that two different seeds produce the same order - const values1 = deck1.map((c) => c.value); - const values2 = deck2.map((c) => c.value); - expect(values1).not.toEqual(values2); - }); - - it('changes the order of cards (with high probability)', () => { - const original = createMindDeck().map((c) => c.value); - const deck = createMindDeck(); - shuffleDeck(deck); - const shuffled = deck.map((c) => c.value); - - // At least some positions should have changed - const changedPositions = original.filter((v, i) => v !== shuffled[i]); - expect(changedPositions.length).toBeGreaterThan(0); - }); - - it('uses Math.random by default when no RNG is provided', () => { - // Just verify it doesn't throw and produces a valid shuffled deck - const deck = createMindDeck(); - const result = shuffleDeck(deck); - expect(result).toHaveLength(100); - const values = new Set(result.map((c) => c.value)); - expect(values.size).toBe(100); - }); -}); diff --git a/tests/the-mind/mind-renderer.test.ts b/tests/the-mind/mind-renderer.test.ts deleted file mode 100644 index d119c526..00000000 --- a/tests/the-mind/mind-renderer.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { ensureTextureMock, ensureBackTextureMock } = vi.hoisted(() => ({ - ensureTextureMock: vi.fn(async (_scene: unknown, value: number) => ({ - key: `ms_card_mind-${value}_120x164@1`, - ready: true, - })), - ensureBackTextureMock: vi.fn(async () => ({ - key: 'ms_card_mind-back_120x164@1', - ready: true, - })), -})); - -const { createMindHudTextMock } = vi.hoisted(() => ({ - createMindHudTextMock: vi.fn((_scene: unknown, _x: number, _y: number, _text: string, _color: string) => ({ - setOrigin: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setText: vi.fn().mockReturnThis(), - setColor: vi.fn().mockReturnThis(), - destroy: vi.fn(), - })), -})); - -vi.mock('../../example-games/the-mind/MindCardTextureAdapter', () => ({ - ensureTexture: ensureTextureMock, - ensureBackTexture: ensureBackTextureMock, - resolveTemplateId: vi.fn((value: number) => `mind-${value}`), - resolveBackTemplateId: vi.fn(() => 'mind-back'), - getCanonicalTextureKey: vi.fn((templateId: string, _w?: number, _h?: number, _dpr?: number) => `ms_card_${templateId}_120x164@1`), - getTextureKey: vi.fn((card: { value: number; faceUp: boolean }) => - card.faceUp ? `ms_card_mind-${card.value}_120x164@1` : 'ms_card_mind-back_120x164@1', - ), -})); - -vi.mock('../../src/ui/Renderer/adapters/MindAdapter', () => ({ - createMindHudText: createMindHudTextMock, -})); - -vi.mock('../../src/ui/Renderer', () => ({ - applyEnsuredTexture: vi.fn(async ( - sprite: { setTexture: (key: string) => unknown; setDisplaySize: (w: number, h: number) => unknown }, - ensureOp: Promise<{ key: string; ready: boolean; promise?: Promise }>, - stillMounted: () => boolean, - displayWidth?: number, - displayHeight?: number, - ) => { - const result = await ensureOp; - if (!result.ready && result.promise) { - await result.promise; - } - if (!stillMounted()) return; - sprite.setTexture(result.key); - if (displayWidth !== undefined && displayHeight !== undefined) { - sprite.setDisplaySize(displayWidth, displayHeight); - } - }), -})); - -vi.mock('../../src/ui', () => { - // Shared sprite pool for HandView - const spritePool: Array<{ texture: { key: string } }> = []; - - function makeMockSprite(): any { - const sprite = { - texture: { key: 'ms_card_mind-back_120x164@1' }, - setDisplaySize: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setInteractive: vi.fn().mockReturnThis(), - setY: vi.fn(), - on: vi.fn().mockReturnThis(), - destroy: vi.fn(), - setTexture: vi.fn(), - }; - return sprite; - } - - return { - GAME_W: 1000, - GAME_H: 700, - FONT_FAMILY: 'sans-serif', - createSceneHeader: vi.fn(), - layoutCardPositions: vi.fn(({ count }: { count: number }) => ({ - positions: Array.from({ length: count }, (_, i) => 100 + i * 40), - })), - HandView: vi.fn().mockImplementation(() => { - spritePool.length = 0; - return { - setCards: vi.fn((cards: any[]) => { - spritePool.length = 0; - if (cards) { - for (let i = 0; i < cards.length; i++) { - spritePool.push(makeMockSprite()); - } - } - }), - on: vi.fn(), - getSprites: vi.fn(() => spritePool), - destroy: vi.fn(), - }; - }), - PileView: vi.fn().mockImplementation(() => { - const pileSprite = { - texture: { key: 'ms_card_mind-back_120x164@1' }, - setAlpha: vi.fn(), - setTexture: vi.fn(), - setDisplaySize: vi.fn(), - setDepth: vi.fn(), - }; - const countText = { setText: vi.fn() }; - return { - setPile: vi.fn(), - onClick: vi.fn(), - update: vi.fn(), - getSprite: vi.fn(() => pileSprite), - getCountText: vi.fn(() => countText), - destroy: vi.fn(), - }; - }), - CardTextureResolver: undefined, - }; -}); - -import { MindRenderer } from '../../example-games/the-mind/scenes/MindRenderer'; -import type { TheMindSession } from '../../example-games/the-mind/TheMindGameState'; - -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; -} - -function createMockSprite() { - const sprite = { - texture: { key: 'ms_card_mind-back_120x164@1' }, - x: 0, - y: 0, - setDisplaySize: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setInteractive: vi.fn().mockReturnThis(), - setTexture: vi.fn(), - on: vi.fn().mockReturnThis(), - setScale: vi.fn().mockReturnThis(), - setY: vi.fn(), - destroy: vi.fn(), - disableInteractive: vi.fn(), - setAlpha: vi.fn().mockReturnThis(), - }; - - sprite.setTexture.mockImplementation((key: string) => { - sprite.texture.key = key; - return sprite; - }); - sprite.setY.mockImplementation((y: number) => { - sprite.y = y; - return sprite; - }); - - return sprite; -} - -function createMockText() { - return { - setOrigin: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setText: vi.fn().mockReturnThis(), - setColor: vi.fn().mockReturnThis(), - destroy: vi.fn(), - }; -} - -function createMockScene() { - return { - add: { - image: vi.fn(() => createMockSprite()), - text: vi.fn(() => createMockText()), - }, - time: { - addEvent: vi.fn(() => ({ destroy: vi.fn() })), - delayedCall: vi.fn(), - }, - game: { - config: { - width: 1000, - height: 700, - }, - }, - } as unknown as Phaser.Scene; -} - -function createSession(): TheMindSession { - return { - players: [ - { name: 'P1', isAI: false, hand: [{ value: 5, faceUp: false }, { value: 20, faceUp: false }] }, - { name: 'P2', isAI: true, hand: [{ value: 10, faceUp: false }, { value: 30, faceUp: false }] }, - ], - pile: { - peek: () => null, - size: () => 0, - clear: () => undefined, - isEmpty: () => true, - } as unknown as TheMindSession['pile'], - currentLevel: 1, - lives: 2, - outcome: 'in-progress', - rng: Math.random, - }; -} - -describe('MindRenderer', () => { - let scene: Phaser.Scene; - let session: TheMindSession; - let renderer: MindRenderer; - - beforeEach(() => { - ensureTextureMock.mockReset(); - ensureBackTextureMock.mockReset(); - - ensureTextureMock.mockImplementation(async (_scene: unknown, value: number) => ({ - key: `ms_card_mind-${value}_120x164@1`, - ready: true, - })); - ensureBackTextureMock.mockImplementation(async () => ({ - key: 'ms_card_mind-back_120x164@1', - ready: true, - })); - - scene = createMockScene(); - session = createSession(); - renderer = new MindRenderer(scene, session); - renderer.createStatusDisplay(); - renderer.createHands(); - renderer.createPile(); - renderer.createInstruction(); - renderer.renderHumanHand(() => undefined, 'playing', false); - renderer.renderAiHand(); - }); - - it('re-renders hands when penalty changes hand size', () => { - expect(renderer.humanCardSprites).toHaveLength(2); - expect(renderer.aiCardSprites).toHaveLength(2); - - session.players[0].hand = [{ value: 20, faceUp: false }]; - session.players[1].hand = [{ value: 30, faceUp: false }]; - - renderer.refreshAll(); - - expect(renderer.humanCardSprites).toHaveLength(1); - expect(renderer.aiCardSprites).toHaveLength(1); - }); - - it('waits for face texture promise before applying card texture', async () => { - const deferred = createDeferred(); - ensureTextureMock.mockImplementation(async (_scene: unknown, value: number) => ({ - key: `ms_card_mind-${value}_120x164@1`, - ready: false, - promise: deferred.promise, - })); - - const firstSprite = renderer.humanCardSprites[0] as unknown as { texture: { key: string }; setTexture: ReturnType }; - (firstSprite.setTexture as unknown as { mockClear: () => void }).mockClear(); - - renderer.refreshHumanHand(); - const callsBeforeResolve = (firstSprite.setTexture as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect(callsBeforeResolve.some((c) => c[0] === 'ms_card_mind-5_120x164@1')).toBe(false); - - deferred.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - const callsAfterResolve = (firstSprite.setTexture as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect(callsAfterResolve.some((c) => c[0] === 'ms_card_mind-5_120x164@1')).toBe(true); - }); - - it('re-applies display size after setting ensured texture', async () => { - renderer.refreshHumanHand(); - await Promise.resolve(); - await Promise.resolve(); - - const firstSprite = renderer.humanCardSprites[0] as unknown as { setDisplaySize: ReturnType }; - const displayCalls = (firstSprite.setDisplaySize as unknown as { mock: { calls: unknown[][] } }).mock.calls; - - // One call is from sprite creation; at least one additional call should happen - // after texture swap to avoid unintended size reset. - expect(displayCalls.length).toBeGreaterThan(1); - expect(displayCalls[displayCalls.length - 1]).toEqual([120, 164]); - }); - - it('ensures real card-back texture for AI hand', async () => { - expect(ensureBackTextureMock).toHaveBeenCalled(); - await Promise.resolve(); - await Promise.resolve(); - - for (const sprite of renderer.aiCardSprites as unknown as Array<{ texture: { key: string } }>) { - expect(sprite.texture.key).toBe('ms_card_mind-back_120x164@1'); - } - }); - - it('does not flash pile back texture when face texture already exists', () => { - session.pile.peek = () => ({ value: 42, faceUp: true }) as any; - session.pile.size = () => 1; - - (scene as any).textures = { - exists: (key: string) => key === 'ms_card_mind-42_120x164@1' || key === 'ms_card_mind-back_120x164@1', - }; - - const pileSprite = renderer.pileSprite as unknown as { setTexture: ReturnType }; - (pileSprite.setTexture as unknown as { mockClear: () => void }).mockClear(); - - renderer.refreshPile(); - - const calls = (pileSprite.setTexture as unknown as { mock: { calls: unknown[][] } }).mock.calls; - expect(calls.length).toBeGreaterThan(0); - expect(calls[0][0]).toBe('ms_card_mind-42_120x164@1'); - }); -}); diff --git a/tests/the-mind/mind-turn-controller.test.ts b/tests/the-mind/mind-turn-controller.test.ts deleted file mode 100644 index 369b5518..00000000 --- a/tests/the-mind/mind-turn-controller.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { setupTheMindGame, type TheMindSession } from '../../example-games/the-mind/TheMindGameState'; -import { MindTurnController } from '../../example-games/the-mind/scenes/MindTurnController'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; - -function createSession(humanHand: number[], aiHand: number[]): TheMindSession { - const session = setupTheMindGame({ rng: createSeededRng(7) }); - session.pile.clear(); - session.players[0].hand = humanHand.map((value) => ({ value, faceUp: false })); - session.players[1].hand = aiHand.map((value) => ({ value, faceUp: false })); - return session; -} - -function createController(session: TheMindSession): { - controller: MindTurnController; - recorder: { - recordCardPlay: ReturnType; - recordPenalty: ReturnType; - recordLevelComplete: ReturnType; - finalize: ReturnType; - }; - gameEvents: { emit: ReturnType }; - soundManager: { play: ReturnType }; -} { - const recorder = { - recordCardPlay: vi.fn(), - recordPenalty: vi.fn(), - recordLevelComplete: vi.fn(), - finalize: vi.fn(), - }; - const gameEvents = { emit: vi.fn() }; - const soundManager = { play: vi.fn() }; - - return { - controller: new MindTurnController( - session, - recorder as unknown as never, - gameEvents as unknown as never, - soundManager as unknown as never, - ), - recorder, - gameEvents, - soundManager, - }; -} - -describe('MindTurnController.performPlay', () => { - it('uses animation hook and normal callback for valid non-penalty plays', () => { - const session = createSession([10], [40]); - const { controller, recorder, soundManager } = createController(session); - - const aiScheduler = { - removeCardFromAi: vi.fn(), - cancelAllTimers: vi.fn(), - removePenaltyCards: vi.fn(), - }; - - const animateCard = vi.fn((playerId: 0 | 1, cardValue: number, onComplete: () => void) => { - expect(playerId).toBe(0); - expect(cardValue).toBe(10); - onComplete(); - }); - - const onPenaltyComplete = vi.fn(); - const onNormalComplete = vi.fn(); - const onInvalidPlay = vi.fn(); - - controller.performPlay( - 0, - 10, - aiScheduler as never, - animateCard, - onPenaltyComplete, - onNormalComplete, - onInvalidPlay, - ); - - expect(animateCard).toHaveBeenCalledTimes(1); - expect(onNormalComplete).toHaveBeenCalledTimes(1); - expect(onPenaltyComplete).not.toHaveBeenCalled(); - expect(onInvalidPlay).not.toHaveBeenCalled(); - expect(recorder.recordCardPlay).toHaveBeenCalledTimes(1); - expect(soundManager.play).toHaveBeenCalledWith('sfx-card-play'); - expect(aiScheduler.removeCardFromAi).toHaveBeenCalledWith(10); - }); - - it('uses animation hook and penalty callback when life is lost', () => { - const session = createSession([30], [20]); - const { controller, recorder, soundManager } = createController(session); - - const aiScheduler = { - removeCardFromAi: vi.fn(), - cancelAllTimers: vi.fn(), - removePenaltyCards: vi.fn(), - }; - - const animateCard = vi.fn((_playerId: 0 | 1, _cardValue: number, onComplete: () => void) => { - onComplete(); - }); - - const onPenaltyComplete = vi.fn(); - const onNormalComplete = vi.fn(); - const onInvalidPlay = vi.fn(); - - controller.performPlay( - 0, - 30, - aiScheduler as never, - animateCard, - onPenaltyComplete, - onNormalComplete, - onInvalidPlay, - ); - - expect(animateCard).toHaveBeenCalledTimes(1); - expect(onPenaltyComplete).toHaveBeenCalledTimes(1); - expect(onNormalComplete).not.toHaveBeenCalled(); - expect(soundManager.play).toHaveBeenCalledWith('sfx-life-lost'); - expect(aiScheduler.cancelAllTimers).toHaveBeenCalledTimes(1); - expect(aiScheduler.removePenaltyCards).toHaveBeenCalledTimes(1); - expect(recorder.recordPenalty).toHaveBeenCalledTimes(1); - }); - - it('invokes invalid callback for invalid human play and skips animation', () => { - const session = createSession([12], [35]); - const { controller } = createController(session); - - const aiScheduler = { - removeCardFromAi: vi.fn(), - cancelAllTimers: vi.fn(), - removePenaltyCards: vi.fn(), - }; - - const animateCard = vi.fn(); - const onPenaltyComplete = vi.fn(); - const onNormalComplete = vi.fn(); - const onInvalidPlay = vi.fn(); - - controller.performPlay( - 0, - 99, - aiScheduler as never, - animateCard, - onPenaltyComplete, - onNormalComplete, - onInvalidPlay, - ); - - expect(onInvalidPlay).toHaveBeenCalledWith(99); - expect(animateCard).not.toHaveBeenCalled(); - expect(onPenaltyComplete).not.toHaveBeenCalled(); - expect(onNormalComplete).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/the-mind/penalty-animation.test.ts b/tests/the-mind/penalty-animation.test.ts deleted file mode 100644 index a67669a1..00000000 --- a/tests/the-mind/penalty-animation.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - pickPenaltyStartPositions, - type PenaltyCardRef, - type SpritePoint, -} from '../../example-games/the-mind/scenes/penaltyAnimation'; - -describe('pickPenaltyStartPositions', () => { - it('uses leftmost hand sprite positions for each player', () => { - const penaltyCards: PenaltyCardRef[] = [ - { playerId: 0, card: { value: 5 } }, - { playerId: 0, card: { value: 8 } }, - { playerId: 1, card: { value: 12 } }, - ]; - - const humanSprites: SpritePoint[] = [ - { x: 100, y: 590 }, - { x: 170, y: 590 }, - { x: 240, y: 590 }, - ]; - const aiSprites: SpritePoint[] = [ - { x: 320, y: 150 }, - { x: 390, y: 150 }, - ]; - - const starts = pickPenaltyStartPositions(penaltyCards, humanSprites, aiSprites); - - expect(starts).toEqual([ - { x: 100, y: 590 }, - { x: 170, y: 590 }, - { x: 320, y: 150 }, - ]); - }); - - it('falls back to provided default positions when sprite slots are unavailable', () => { - const penaltyCards: PenaltyCardRef[] = [ - { playerId: 1, card: { value: 3 } }, - { playerId: 1, card: { value: 4 } }, - ]; - - const starts = pickPenaltyStartPositions( - penaltyCards, - [], - [{ x: 350, y: 150 }], - { - 0: { x: 500, y: 590 }, - 1: { x: 500, y: 150 }, - }, - ); - - expect(starts).toEqual([ - { x: 350, y: 150 }, - { x: 500, y: 150 }, - ]); - }); -}); diff --git a/tests/the-mind/run-game-orchestrator.test.ts b/tests/the-mind/run-game-orchestrator.test.ts deleted file mode 100644 index 9653a02f..00000000 --- a/tests/the-mind/run-game-orchestrator.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { setupTheMindGame } from '../../example-games/the-mind/TheMindGameState'; -import { - buildPlayQueue, - buildResultSnapshot, - type SimulationStats, -} from '../../example-games/the-mind/RunGameOrchestrator'; -import { PROXIMITY_MIN_DELAY } from '../../example-games/the-mind/AiStrategy'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; - -type DelayEntry = { card: { value: number; faceUp: boolean }; delay: number }; - -function makeAi(delays: DelayEntry[]) { - return { - getCardDelays: () => delays, - }; -} - -describe('RunGameOrchestrator.buildPlayQueue', () => { - it('sorts by fireTime and then by cardValue', () => { - const p0 = makeAi([ - { card: { value: 40, faceUp: false }, delay: 900 }, - { card: { value: 20, faceUp: false }, delay: 900 }, - ]); - const p1 = makeAi([ - { card: { value: 10, faceUp: false }, delay: 800 }, - ]); - - const queue = buildPlayQueue([p0, p1] as never, 1000, 0); - - expect(queue.map((entry) => entry.cardValue)).toEqual([10, 20, 40]); - expect(queue.map((entry) => entry.fireTime)).toEqual([1800, 1900, 1900]); - }); - - it('enforces minimum proximity delay for cards close to pile top', () => { - const p0 = makeAi([{ card: { value: 15, faceUp: false }, delay: 50 }]); - const p1 = makeAi([{ card: { value: 90, faceUp: false }, delay: 500 }]); - - const levelStartTime = 200; - const queue = buildPlayQueue([p0, p1] as never, levelStartTime, 12); - - const closeCard = queue.find((entry) => entry.cardValue === 15); - expect(closeCard).toBeDefined(); - expect(closeCard?.fireTime).toBe(levelStartTime + PROXIMITY_MIN_DELAY); - }); -}); - -describe('RunGameOrchestrator.buildResultSnapshot', () => { - it('aggregates final statistics from simulation and session state', () => { - const session = setupTheMindGame({ rng: createSeededRng(11) }); - session.currentLevel = 6; - session.lives = 1; - session.outcome = 'loss'; - - const stats: SimulationStats = { - totalPlays: 42, - totalPenalties: 3, - levelStartTime: 1234, - }; - - const snapshot = buildResultSnapshot(stats, session); - - expect(snapshot).toEqual({ - totalPlays: 42, - totalPenalties: 3, - outcome: 'loss', - finalLevel: 6, - finalLives: 1, - }); - }); -}); diff --git a/tests/the-mind/transcript.test.ts b/tests/the-mind/transcript.test.ts deleted file mode 100644 index c82a3198..00000000 --- a/tests/the-mind/transcript.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - type MindTranscript, - type MindInitialState, - type MindCardPlayedEvent, - type MindPenaltyEvent, - type MindLevelCompleteEvent, - type MindGameOverEvent, - MindTranscriptRecorder, -} from '../../example-games/the-mind/GameTranscript'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Create a standard initial state for tests. */ -function createTestInitialState(): MindInitialState { - return { - playerNames: ['Player', 'AI'], - isAI: [false, true], - startingLives: 2, - startingLevel: 1, - hands: [[15], [42]], - }; -} - -// --------------------------------------------------------------------------- -// MindTranscriptRecorder -// --------------------------------------------------------------------------- - -describe('MindTranscriptRecorder', () => { - let recorder: MindTranscriptRecorder; - let initialState: MindInitialState; - - beforeEach(() => { - initialState = createTestInitialState(); - recorder = new MindTranscriptRecorder(initialState); - }); - - describe('construction', () => { - it('creates a transcript with version 2', () => { - const t = recorder.getTranscript(); - expect(t.version).toBe(2); - }); - - it('creates a transcript with gameType "the-mind"', () => { - const t = recorder.getTranscript(); - expect(t.gameType).toBe('the-mind'); - }); - - it('sets startedAt to a valid ISO 8601 timestamp', () => { - const t = recorder.getTranscript(); - expect(t.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('sets endedAt to empty string initially', () => { - const t = recorder.getTranscript(); - expect(t.endedAt).toBe(''); - }); - - it('stores the initial state', () => { - const t = recorder.getTranscript(); - expect(t.initialState).toBe(initialState); - expect(t.initialState.playerNames).toEqual(['Player', 'AI']); - expect(t.initialState.isAI).toEqual([false, true]); - expect(t.initialState.startingLives).toBe(2); - expect(t.initialState.startingLevel).toBe(1); - expect(t.initialState.hands).toEqual([[15], [42]]); - }); - - it('starts with empty events array', () => { - const t = recorder.getTranscript(); - expect(t.events).toEqual([]); - }); - - it('starts with null results', () => { - const t = recorder.getTranscript(); - expect(t.results).toBeNull(); - }); - - it('is not sealed initially', () => { - expect(recorder.isSealed()).toBe(false); - }); - }); - - describe('recordCardPlay', () => { - it('adds a card-played event', () => { - recorder.recordCardPlay(100, 0, 15, 15, 1); - const events = recorder.getTranscript().events; - expect(events).toHaveLength(1); - expect(events[0].type).toBe('card-played'); - }); - - it('records all card play fields correctly', () => { - recorder.recordCardPlay(250, 1, 42, 42, 2); - const event = recorder.getTranscript().events[0] as MindCardPlayedEvent; - expect(event.timestamp).toBe(250); - expect(event.playerId).toBe(1); - expect(event.cardValue).toBe(42); - expect(event.pileTopAfter).toBe(42); - expect(event.pileSizeAfter).toBe(2); - }); - - it('records multiple card plays in order', () => { - recorder.recordCardPlay(100, 0, 15, 15, 1); - recorder.recordCardPlay(200, 1, 42, 42, 2); - recorder.recordCardPlay(350, 0, 55, 55, 3); - - const events = recorder.getTranscript().events; - expect(events).toHaveLength(3); - expect((events[0] as MindCardPlayedEvent).cardValue).toBe(15); - expect((events[1] as MindCardPlayedEvent).cardValue).toBe(42); - expect((events[2] as MindCardPlayedEvent).cardValue).toBe(55); - }); - }); - - describe('recordPenalty', () => { - it('adds a penalty event', () => { - recorder.recordPenalty(150, 1, [{ playerId: 0, cardValue: 10 }]); - const events = recorder.getTranscript().events; - expect(events).toHaveLength(1); - expect(events[0].type).toBe('penalty'); - }); - - it('records all penalty fields correctly', () => { - const discarded = [ - { playerId: 0 as const, cardValue: 5 }, - { playerId: 1 as const, cardValue: 8 }, - ]; - recorder.recordPenalty(300, 1, discarded); - - const event = recorder.getTranscript().events[0] as MindPenaltyEvent; - expect(event.timestamp).toBe(300); - expect(event.livesRemaining).toBe(1); - expect(event.discardedCards).toEqual(discarded); - }); - - it('records empty discarded cards array', () => { - recorder.recordPenalty(100, 2, []); - const event = recorder.getTranscript().events[0] as MindPenaltyEvent; - expect(event.discardedCards).toEqual([]); - }); - }); - - describe('recordLevelComplete', () => { - it('adds a level-complete event', () => { - recorder.recordLevelComplete(500, 1, false, 2); - const events = recorder.getTranscript().events; - expect(events).toHaveLength(1); - expect(events[0].type).toBe('level-complete'); - }); - - it('records level completion without bonus life', () => { - recorder.recordLevelComplete(800, 2, false, 2); - const event = recorder.getTranscript() - .events[0] as MindLevelCompleteEvent; - expect(event.timestamp).toBe(800); - expect(event.level).toBe(2); - expect(event.bonusLifeAwarded).toBe(false); - expect(event.livesAfter).toBe(2); - }); - - it('records level completion with bonus life', () => { - recorder.recordLevelComplete(1200, 3, true, 3); - const event = recorder.getTranscript() - .events[0] as MindLevelCompleteEvent; - expect(event.level).toBe(3); - expect(event.bonusLifeAwarded).toBe(true); - expect(event.livesAfter).toBe(3); - }); - }); - - describe('finalize', () => { - it('adds a game-over event', () => { - const t = recorder.finalize(1000, 'win', 8, 2); - const lastEvent = t.events[t.events.length - 1]; - expect(lastEvent.type).toBe('game-over'); - }); - - it('records win outcome correctly', () => { - const t = recorder.finalize(5000, 'win', 8, 3); - const event = t.events[t.events.length - 1] as MindGameOverEvent; - expect(event.outcome).toBe('win'); - expect(event.finalLevel).toBe(8); - expect(event.finalLives).toBe(3); - expect(event.timestamp).toBe(5000); - }); - - it('records loss outcome correctly', () => { - const t = recorder.finalize(2000, 'loss', 4, 0); - const event = t.events[t.events.length - 1] as MindGameOverEvent; - expect(event.outcome).toBe('loss'); - expect(event.finalLevel).toBe(4); - expect(event.finalLives).toBe(0); - }); - - it('sets endedAt to a valid ISO 8601 timestamp', () => { - const t = recorder.finalize(1000, 'win', 8, 2); - expect(t.endedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(t.endedAt).not.toBe(''); - }); - - it('sets results with correct totals', () => { - recorder.recordCardPlay(100, 0, 15, 15, 1); - recorder.recordCardPlay(200, 1, 42, 42, 2); - recorder.recordPenalty(150, 1, [{ playerId: 0, cardValue: 10 }]); - - const t = recorder.finalize(1000, 'loss', 1, 0); - - expect(t.results).not.toBeNull(); - expect(t.results!.outcome).toBe('loss'); - expect(t.results!.finalLevel).toBe(1); - expect(t.results!.finalLives).toBe(0); - expect(t.results!.totalCardsPlayed).toBe(2); - expect(t.results!.totalPenalties).toBe(1); - }); - - it('seals the transcript', () => { - recorder.finalize(1000, 'win', 8, 2); - expect(recorder.isSealed()).toBe(true); - }); - - it('returns the same transcript on repeated calls', () => { - const t1 = recorder.finalize(1000, 'win', 8, 2); - const t2 = recorder.finalize(9999, 'loss', 1, 0); - expect(t1).toBe(t2); - // The second finalize is a no-op — original values preserved - expect(t1.results!.outcome).toBe('win'); - }); - }); - - describe('sealed behavior', () => { - it('recordCardPlay is a no-op after finalize', () => { - recorder.finalize(1000, 'win', 8, 2); - const eventsBefore = recorder.getTranscript().events.length; - - recorder.recordCardPlay(2000, 0, 50, 50, 1); - expect(recorder.getTranscript().events.length).toBe(eventsBefore); - }); - - it('recordPenalty is a no-op after finalize', () => { - recorder.finalize(1000, 'win', 8, 2); - const eventsBefore = recorder.getTranscript().events.length; - - recorder.recordPenalty(2000, 1, []); - expect(recorder.getTranscript().events.length).toBe(eventsBefore); - }); - - it('recordLevelComplete is a no-op after finalize', () => { - recorder.finalize(1000, 'win', 8, 2); - const eventsBefore = recorder.getTranscript().events.length; - - recorder.recordLevelComplete(2000, 5, false, 2); - expect(recorder.getTranscript().events.length).toBe(eventsBefore); - }); - }); - - describe('JSON serialization', () => { - it('transcript is serializable to JSON (no circular references)', () => { - recorder.recordCardPlay(100, 0, 15, 15, 1); - recorder.recordPenalty(150, 1, [{ playerId: 1, cardValue: 8 }]); - recorder.recordLevelComplete(500, 1, false, 1); - const t = recorder.finalize(600, 'loss', 2, 0); - - const json = JSON.stringify(t); - expect(() => JSON.parse(json)).not.toThrow(); - - const parsed = JSON.parse(json) as MindTranscript; - expect(parsed.version).toBe(2); - expect(parsed.gameType).toBe('the-mind'); - expect(parsed.events).toHaveLength(4); // play + penalty + level + game-over - expect(parsed.results).not.toBeNull(); - }); - - it('round-trips through JSON without data loss', () => { - recorder.recordCardPlay(100, 0, 15, 15, 1); - recorder.recordCardPlay(200, 1, 42, 42, 2); - recorder.recordPenalty(150, 1, [ - { playerId: 0, cardValue: 5 }, - { playerId: 1, cardValue: 8 }, - ]); - recorder.recordLevelComplete(500, 1, false, 1); - const t = recorder.finalize(600, 'win', 8, 2); - - const parsed = JSON.parse(JSON.stringify(t)) as MindTranscript; - - expect(parsed.initialState.playerNames).toEqual(['Player', 'AI']); - expect(parsed.events).toHaveLength(5); - expect(parsed.results!.totalCardsPlayed).toBe(2); - expect(parsed.results!.totalPenalties).toBe(1); - }); - }); - - // ── Complete multi-level game transcript ── - - describe('complete multi-level game transcript', () => { - it('records a full 3-level game with penalties and bonus life', () => { - // Level 1: Both players play successfully - recorder.recordCardPlay(100, 0, 15, 15, 1); - recorder.recordCardPlay(300, 1, 42, 42, 2); - recorder.recordLevelComplete(300, 1, false, 2); - - // Level 2: AI plays out of order, penalty - recorder.recordCardPlay(100, 1, 30, 30, 1); - recorder.recordPenalty(100, 1, [{ playerId: 0, cardValue: 20 }]); - recorder.recordCardPlay(200, 0, 55, 55, 2); - recorder.recordCardPlay(400, 1, 60, 60, 3); - recorder.recordCardPlay(500, 0, 80, 80, 4); - recorder.recordLevelComplete(500, 2, false, 1); - - // Level 3: Clean play, bonus life - recorder.recordCardPlay(200, 0, 10, 10, 1); - recorder.recordCardPlay(300, 1, 25, 25, 2); - recorder.recordCardPlay(500, 0, 45, 45, 3); - recorder.recordCardPlay(600, 1, 70, 70, 4); - recorder.recordCardPlay(800, 0, 88, 88, 5); - recorder.recordCardPlay(900, 1, 95, 95, 6); - recorder.recordLevelComplete(900, 3, true, 2); - - // Game continues but let's finalize as a loss at level 4 - recorder.recordCardPlay(100, 1, 50, 50, 1); - recorder.recordPenalty(100, 0, [ - { playerId: 0, cardValue: 12 }, - { playerId: 0, cardValue: 35 }, - ]); - - const t = recorder.finalize(200, 'loss', 4, 0); - - // Verify structure - expect(t.version).toBe(2); - expect(t.gameType).toBe('the-mind'); - expect(t.events).toHaveLength( - // Level 1: 2 plays + 1 level-complete = 3 - // Level 2: 4 plays + 1 penalty + 1 level-complete = 6 - // Level 3: 6 plays + 1 level-complete = 7 - // Level 4: 1 play + 1 penalty + 1 game-over = 3 - 3 + 6 + 7 + 3, - ); - - // Verify event types in order - const types = t.events.map((e) => e.type); - expect(types[0]).toBe('card-played'); - expect(types[1]).toBe('card-played'); - expect(types[2]).toBe('level-complete'); - - // Verify penalties - const penalties = t.events.filter( - (e): e is MindPenaltyEvent => e.type === 'penalty', - ); - expect(penalties).toHaveLength(2); - expect(penalties[0].livesRemaining).toBe(1); - expect(penalties[0].discardedCards).toHaveLength(1); - expect(penalties[1].livesRemaining).toBe(0); - expect(penalties[1].discardedCards).toHaveLength(2); - - // Verify level completions - const levelCompletes = t.events.filter( - (e): e is MindLevelCompleteEvent => e.type === 'level-complete', - ); - expect(levelCompletes).toHaveLength(3); - expect(levelCompletes[0].level).toBe(1); - expect(levelCompletes[0].bonusLifeAwarded).toBe(false); - expect(levelCompletes[1].level).toBe(2); - expect(levelCompletes[2].level).toBe(3); - expect(levelCompletes[2].bonusLifeAwarded).toBe(true); - expect(levelCompletes[2].livesAfter).toBe(2); - - // Verify game-over - const gameOver = t.events[t.events.length - 1] as MindGameOverEvent; - expect(gameOver.type).toBe('game-over'); - expect(gameOver.outcome).toBe('loss'); - expect(gameOver.finalLevel).toBe(4); - expect(gameOver.finalLives).toBe(0); - - // Verify results - expect(t.results!.outcome).toBe('loss'); - expect(t.results!.finalLevel).toBe(4); - expect(t.results!.totalCardsPlayed).toBe(13); - expect(t.results!.totalPenalties).toBe(2); - - // Verify JSON-serializable - expect(() => JSON.stringify(t)).not.toThrow(); - }); - }); - - describe('getTranscript', () => { - it('returns the same object reference (no defensive copy)', () => { - const t1 = recorder.getTranscript(); - const t2 = recorder.getTranscript(); - expect(t1).toBe(t2); - }); - - it('reflects events added after getTranscript was called', () => { - const t = recorder.getTranscript(); - expect(t.events).toHaveLength(0); - - recorder.recordCardPlay(100, 0, 15, 15, 1); - expect(t.events).toHaveLength(1); - }); - }); -}); diff --git a/tests/ui/GameOverOverlay.test.ts b/tests/ui/GameOverOverlay.test.ts new file mode 100644 index 00000000..a5829b24 --- /dev/null +++ b/tests/ui/GameOverOverlay.test.ts @@ -0,0 +1,376 @@ +/** + * Unit tests for the shared GameOverOverlay component: + * - createGameOverOverlay + * + * Verifies that the shared game-over overlay creates the correct + * structure: semi-transparent background, title, auto-scaling + * summary text, optional extra buttons, and bottom-row buttons + * ([Play Again] and [Menu]). + * + * All Phaser scene interactions are mocked to run in Node. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createGameOverOverlay, computeAutoScaleFontSize } from '../../src/ui/GameOverOverlay'; +import { GAME_W, GAME_H, FONT_FAMILY } from '../../src/ui/constants'; + +// ── Mock helpers ──────────────────────────────────────────── + +interface MockText { + setOrigin: ReturnType; + setDepth: ReturnType; + setInteractive: ReturnType; + setColor: ReturnType; + setText: ReturnType; + setStyle: ReturnType; + on: ReturnType; + destroy: ReturnType; + width: number; + height: number; + style: Record; +} + +/** Create a mock Phaser.GameObjects.Rectangle. */ +function mockRectangle() { + return { + setDepth: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + destroy: vi.fn(), + x: 0, + y: 0, + width: 0, + height: 0, + }; +} + +/** Create a mock Phaser.GameObjects.Text. */ +function mockText(): MockText { + return { + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setInteractive: vi.fn().mockReturnThis(), + setColor: vi.fn().mockReturnThis(), + setText: vi.fn().mockReturnThis(), + setStyle: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + destroy: vi.fn(), + width: 200, + height: 100, + style: { fontSize: '14px' }, + }; +} + +/** Create a minimal mock Phaser.Scene. */ +function mockScene() { + const scene = { + add: { + rectangle: vi.fn(() => mockRectangle()), + text: vi.fn(() => mockText()), + container: vi.fn(() => ({ + setDepth: vi.fn().mockReturnThis(), + destroy: vi.fn(), + add: vi.fn().mockReturnThis(), + list: [] as any[], + })), + }, + scene: { + start: vi.fn(), + }, + }; + return scene as unknown as Phaser.Scene; +} + +/** Helper to get the mock function for scene.add.text. */ +function mockTextFn(scene: Phaser.Scene): ReturnType { + return (scene as any).add.text; +} + +/** Helper to get the text creation call args from scene.add.text. */ +function textCalls(scene: Phaser.Scene): any[][] { + return mockTextFn(scene).mock.calls; +} + +// ── createGameOverOverlay ─────────────────────────────────── + +describe('createGameOverOverlay', () => { + let scene: ReturnType; + /** Default simple callback. */ + const onPlayAgain = vi.fn(); + const onMenu = vi.fn(); + + beforeEach(() => { + scene = mockScene(); + vi.clearAllMocks(); + }); + + it('creates a full-screen semi-transparent background', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + }); + + // Should have created a full-screen rectangle for the background + expect(scene.add.rectangle).toHaveBeenCalledWith( + expect.any(Number), expect.any(Number), + GAME_W, GAME_H, + expect.any(Number), expect.any(Number), + ); + + // Background should be interactive to block input + expect(result.background.setInteractive).toHaveBeenCalled(); + }); + + it('creates a centered overlay box', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + }); + + // A box rectangle should have been created + expect(result.box).not.toBeNull(); + expect(result.box!.setDepth).toHaveBeenCalled(); + }); + + it('creates a title element', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + }); + + expect(result.title).toBeDefined(); + expect(typeof result.title.setDepth).toBe('function'); + }); + + it('uses custom title text when provided', () => { + createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + title: 'You Win!', + }); + + // scene.add.text signature: (x, y, text, style) + const calls = textCalls(scene); + expect(calls.some((call: any[]) => call[2] === 'You Win!')).toBe(true); + }); + + it('creates a summary text area with the provided content', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Score: 100\nCards: 20\nTime: 5m', + onPlayAgain, + onMenu, + }); + + expect(result.summary).toBeDefined(); + // Summary text should be passed as the third argument to scene.add.text + // scene.add.text signature: (x, y, text, style) + expect(scene.add.text).toHaveBeenCalledWith( + expect.any(Number), + expect.any(Number), + 'Score: 100\nCards: 20\nTime: 5m', + expect.objectContaining({ + fontFamily: FONT_FAMILY, + }), + ); + }); + + it('creates bottom-row buttons [Play Again] and [Menu]', () => { + createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + }); + + // Should create two interactive text buttons with the correct labels + const calls = textCalls(scene); + const buttonLabels = calls + .filter((call: any[]) => { + const label = call[2]; + return typeof label === 'string' && + (label.includes('Play Again') || label.includes('Menu')); + }) + .map((call: any[]) => call[2]); + + expect(buttonLabels).toContain('[ Play Again ]'); + expect(buttonLabels).toContain('[ Menu ]'); + }); + + it('Play Again button triggers onPlayAgain callback', () => { + createGameOverOverlay(scene, { + summaryText: 'Test', + onPlayAgain, + onMenu, + }); + + // The callback is wired via pointerdown on the button text objects. + expect(onPlayAgain).not.toHaveBeenCalled(); + }); + + it('Menu button navigates to GameSelectorScene when no custom onMenu provided', () => { + createGameOverOverlay(scene, { + summaryText: 'Test', + onPlayAgain, + // No onMenu provided + }); + + // Verify scene scape was used for default menu behavior + // (not triggered because pointerdown wasn't fired) + expect(scene.scene.start).not.toHaveBeenCalled(); + }); + + it('uses custom onMenu callback when provided', () => { + const customOnMenu = vi.fn(); + + createGameOverOverlay(scene, { + summaryText: 'Test', + onPlayAgain, + onMenu: customOnMenu, + }); + + // Custom onMenu should be wired up (not yet called) + expect(customOnMenu).not.toHaveBeenCalled(); + }); + + it('creates extra buttons in an optional row when provided', () => { + const onShare = vi.fn(); + const onSave = vi.fn(); + + createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + extraButtons: [ + { label: '[ Share Score ]', onClick: onShare }, + { label: '[ Save Replay ]', onClick: onSave }, + ], + }); + + // Should create text elements for the extra buttons + const calls = textCalls(scene); + const extraLabels = calls + .filter((call: any[]) => { + const label = call[2]; + return typeof label === 'string' && + (label === '[ Share Score ]' || label === '[ Save Replay ]'); + }) + .map((call: any[]) => call[2]); + + expect(extraLabels).toContain('[ Share Score ]'); + expect(extraLabels).toContain('[ Save Replay ]'); + }); + + it('returns a dismiss function that destroys all objects', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Test', + onPlayAgain, + onMenu, + }); + + expect(typeof result.dismiss).toBe('function'); + + result.dismiss(); + + // All objects should be destroyed + for (const obj of result.objects) { + expect(obj.destroy).toHaveBeenCalled(); + } + }); + + it('exposes objects array for lifecycle management', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Test', + onPlayAgain, + onMenu, + }); + + expect(Array.isArray(result.objects)).toBe(true); + expect(result.objects.length).toBeGreaterThan(0); + // objects should include background, box, title, summary, and buttons + expect(result.objects.length).toBeGreaterThanOrEqual(5); + }); + + it('uses custom labels for buttons when provided', () => { + createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + playAgainLabel: 'Restart', + menuLabel: 'Quit', + }); + + const calls = textCalls(scene); + const buttonLabels = calls + .filter((call: any[]) => typeof call[2] === 'string') + .map((call: any[]) => call[2]); + + expect(buttonLabels).toContain('[ Restart ]'); + expect(buttonLabels).toContain('[ Quit ]'); + }); + + it('sets the title to "Game Over" by default', () => { + createGameOverOverlay(scene, { + summaryText: 'Test summary', + onPlayAgain, + onMenu, + }); + + const calls = textCalls(scene); + const hasDefaultTitle = calls.some( + (call: any[]) => typeof call[2] === 'string' && call[2] === 'Game Over' + ); + + expect(hasDefaultTitle).toBe(true); + }); + + it('places summary text above the buttons and below the title', () => { + const result = createGameOverOverlay(scene, { + summaryText: 'Summary content', + onPlayAgain, + onMenu, + }); + + // The summary text should have setOrigin(0.5, 0) for top-left anchoring + expect(result.summary.setOrigin).toHaveBeenCalledWith(0.5, 0); + }); +}); + +// ── computeAutoScaleFontSize ──────────────────────────────── + +describe('computeAutoScaleFontSize', () => { + it('returns minimum font size for empty text', () => { + expect(computeAutoScaleFontSize('', 500, 300)).toBe(12); + }); + + it('returns minimum font size when height is zero', () => { + expect(computeAutoScaleFontSize('Hello', 500, 0)).toBe(12); + }); + + it('uses large font for short text with plenty of space', () => { + const size = computeAutoScaleFontSize('Hello', 500, 300); + expect(size).toBeGreaterThanOrEqual(18); + expect(size).toBeLessThanOrEqual(22); + }); + + it('uses smaller font for long text with limited space', () => { + const longText = Array(20).fill('This is a very long line of text that should wrap to multiple lines').join('\n'); + const size = computeAutoScaleFontSize(longText, 300, 150); + expect(size).toBeGreaterThanOrEqual(12); + expect(size).toBeLessThanOrEqual(16); + }); + + it('returns font size between min and max inclusive', () => { + for (let i = 0; i < 20; i++) { + const size = computeAutoScaleFontSize('Line ' + i, 400, 200); + expect(size).toBeGreaterThanOrEqual(12); + expect(size).toBeLessThanOrEqual(22); + } + }); + + it('handles single-char text', () => { + const size = computeAutoScaleFontSize('A', 500, 300); + expect(size).toBeGreaterThanOrEqual(18); + }); +}); diff --git a/tests/ui/TheMindMigration.browser.test.ts b/tests/ui/TheMindMigration.browser.test.ts deleted file mode 100644 index b0051d8a..00000000 --- a/tests/ui/TheMindMigration.browser.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * The Mind migration smoke test. - * - * Boots the The Mind scene, captures a screenshot, and verifies that - * key UI regions (human hand, AI hand, pile, status text) contain - * non-trivial rendered content (i.e. not a single solid colour). - * - * This test guards against visual regressions caused by the shared - * Renderer migration (CG-0MP12VWO1003YL55). - */ -import { describe, it, expect, afterEach } from 'vitest'; -import Phaser from 'phaser'; -import { waitForScene } from '../helpers/waitForScene'; -import { createSeededRng } from '../../src/core-engine/SeededRng'; -import { page } from '@vitest/browser/context'; - -// ── Deterministic seed ───────────────────────────────────── - -/** Fixed seed for reproducible rendering across test runs. */ -const TEST_SEED = 42; - -/** - * Temporarily replace `Math.random` with a seeded RNG, execute - * `fn`, then restore the original `Math.random`. - */ -async function withSeededRandom(seed: number, fn: () => Promise): Promise { - const original = Math.random; - const seeded = createSeededRng(seed); - Math.random = seeded; - try { - return await fn(); - } finally { - Math.random = original; - } -} - -// ── Boot helper ──────────────────────────────────────────── - -async function bootGame(): Promise { - let container = document.getElementById('game-container'); - if (container) container.remove(); - container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - const { createTheMindGame } = await import( - '../../example-games/the-mind/createTheMindGame' - ); - const game = createTheMindGame({ type: Phaser.CANVAS, parent: 'game-container', width: 1280, height: 720 }); - await waitForScene(game, 'TheMindScene'); - return game; -} - -function destroyGame(game: Phaser.Game | null): void { - if (game) game.destroy(true, false); - const container = document.getElementById('game-container'); - if (container) container.remove(); -} - -/** Wait N animation frames with a timeout fallback. */ -function waitFrames(n: number, fallbackMs = 2000): Promise { - return new Promise((resolve) => { - let settled = false; - let left = n; - const finish = () => { - if (settled) return; - settled = true; - resolve(); - }; - const fallback = setTimeout(finish, fallbackMs); - const tick = () => { - if (settled) return; - left -= 1; - if (left <= 0) { clearTimeout(fallback); finish(); } - else requestAnimationFrame(tick); - }; - requestAnimationFrame(tick); - }); -} - -// ── Screenshot / pixel helpers ───────────────────────────── - -/** - * Read a single pixel from the game canvas at (x, y). - * Returns [r, g, b, a]. - */ -async function readPixel( - scene: Phaser.Scene, - canvas: HTMLCanvasElement, - x: number, - y: number, -): Promise<[number, number, number, number]> { - try { - const renderer: unknown = (scene.game as Phaser.Game).renderer; - const r = renderer as { snapshotPixel?: (x: number, y: number, cb: (p: { r: number; g: number; b: number; a?: number }) => void) => void }; - if (r && typeof r.snapshotPixel === 'function') { - const value = await new Promise<{ r: number; g: number; b: number; a?: number }>((resolve) => { - r.snapshotPixel!(x, y, (pixel) => resolve(pixel)); - }); - if (value && typeof value.r === 'number') { - return [value.r, value.g, value.b, value.a ?? 255]; - } - } - } catch (_) { /* fall back */ } - - const ctx2d = canvas.getContext('2d'); - if (ctx2d) { - const data = ctx2d.getImageData(x, y, 1, 1).data; - return [data[0], data[1], data[2], data[3]]; - } - throw new Error('No render context available for pixel read'); -} - -/** - * Sample multiple pixels from a rectangular region and check that - * they are NOT all the same solid colour. Returns the number of - * distinct colours found. - */ -async function countDistinctColoursInRegion( - scene: Phaser.Scene, - canvas: HTMLCanvasElement, - x: number, - y: number, - w: number, - h: number, - step = 8, -): Promise { - const colours = new Set(); - for (let px = x; px < x + w; px += step) { - for (let py = y; py < y + h; py += step) { - const sx = Math.max(0, Math.min(px, canvas.width - 1)); - const sy = Math.max(0, Math.min(py, canvas.height - 1)); - const [r, g, b] = await readPixel(scene, canvas, sx, sy); - colours.add(`${r},${g},${b}`); - } - } - return colours.size; -} - -/** - * Save a canvas screenshot to the __screenshots__ directory via Vitest's - * browser page API. Returns the path to the saved PNG. - */ -async function saveScreenshot(canvas: HTMLCanvasElement, name: string): Promise { - // eslint-disable-next-line no-console - console.log(`[screenshot:${name}] canvas=${canvas.width}x${canvas.height}`); - const path = await page.screenshot({ path: `__screenshots__/TheMindMigration.browser.test.ts/${name}.png` }); - return path; -} - -// ── Tests ────────────────────────────────────────────────── - -describe('The Mind migration smoke (browser)', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - destroyGame(game); - game = null; - }); - - it('human hand (Your Hand) renders non-trivial content', async () => { - await withSeededRandom(TEST_SEED, async () => { - game = await bootGame(); - }); - const scene = game!.scene.getScene('TheMindScene') as Phaser.Scene; - await waitFrames(24); - - const canvas = document.querySelector('#game-container canvas') as HTMLCanvasElement | null; - expect(canvas).toBeTruthy(); - if (!canvas) throw new Error('Canvas not found'); - - await saveScreenshot(canvas, 'the-mind-human-hand'); - - // Your Hand region is at the bottom of the screen. - const handY = 520; - const handH = 180; - const distinctColours = await countDistinctColoursInRegion( - scene, canvas, - 100, handY, canvas.width - 200, handH, - 12, - ); - // Human hand has cards with textures and a "Your Hand" label. - expect(distinctColours).toBeGreaterThan(4); - }, 30_000); - - it('AI hand renders non-trivial content', async () => { - await withSeededRandom(TEST_SEED, async () => { - game = await bootGame(); - }); - const scene = game!.scene.getScene('TheMindScene') as Phaser.Scene; - await waitFrames(24); - - const canvas = document.querySelector('#game-container canvas') as HTMLCanvasElement | null; - expect(canvas).toBeTruthy(); - if (!canvas) throw new Error('Canvas not found'); - - await saveScreenshot(canvas, 'the-mind-ai-hand'); - - // AI Hand region is near the top, below the title. - const aiHandY = 50; - const aiHandH = 160; - const distinctColours = await countDistinctColoursInRegion( - scene, canvas, - 100, aiHandY, canvas.width - 200, aiHandH, - 12, - ); - // AI hand has card-back images and an "AI Hand" label. - // Card-backs are visually similar so we accept a lower threshold. - expect(distinctColours).toBeGreaterThan(2); - }, 30_000); - - it('pile renders non-trivial content', async () => { - await withSeededRandom(TEST_SEED, async () => { - game = await bootGame(); - }); - const scene = game!.scene.getScene('TheMindScene') as Phaser.Scene; - await waitFrames(48); - - const canvas = document.querySelector('#game-container canvas') as HTMLCanvasElement | null; - expect(canvas).toBeTruthy(); - if (!canvas) throw new Error('Canvas not found'); - - await saveScreenshot(canvas, 'the-mind-pile'); - - // Pile is centred in the middle of the screen. - // PileView sprite at (640, 360), count text at y=360+82=442. - const pileX = Math.floor(canvas.width / 2) - 50; - const pileY = Math.floor(canvas.height / 2) - 60; - const distinctColours = await countDistinctColoursInRegion( - scene, canvas, - pileX, pileY, 100, 200, - 8, - ); - // Pile has card-back textures, a "Pile: N" count, and a slot background. - expect(distinctColours).toBeGreaterThan(3); - }, 30_000); - - it('status text renders non-trivial content', async () => { - await withSeededRandom(TEST_SEED, async () => { - game = await bootGame(); - }); - const scene = game!.scene.getScene('TheMindScene') as Phaser.Scene; - await waitFrames(48); - - const canvas = document.querySelector('#game-container canvas') as HTMLCanvasElement | null; - expect(canvas).toBeTruthy(); - if (!canvas) throw new Error('Canvas not found'); - - await saveScreenshot(canvas, 'the-mind-status-text'); - - // Status text (level/lives) is at top-right corner (x~1180, y~55-80). - const statusX = Math.floor(canvas.width * 0.8); - const statusY = Math.floor(canvas.height * 0.03); - const distinctColours = await countDistinctColoursInRegion( - scene, canvas, - statusX, statusY, 220, 100, - 8, - ); - // Status area has level text, lives hearts, and background elements. - expect(distinctColours).toBeGreaterThan(3); - }, 30_000); - - it('scene contains expected display objects (labels, images)', async () => { - await withSeededRandom(TEST_SEED, async () => { - game = await bootGame(); - }); - const scene = game!.scene.getScene('TheMindScene') as Phaser.Scene; - await waitFrames(16); - - const texts = scene.children.list.filter( - (c) => c instanceof Phaser.GameObjects.Text, - ) as Phaser.GameObjects.Text[]; - - const images = scene.children.list.filter( - (c) => c instanceof Phaser.GameObjects.Image, - ) as Phaser.GameObjects.Image[]; - - // Verify the scene header exists. - const headerLabel = texts.find((t) => t.text === 'The Mind'); - expect(headerLabel).toBeDefined(); - - // Verify status text objects exist (level and lives). - const levelLabel = texts.find((t) => t.text.startsWith('Level ')); - expect(levelLabel).toBeDefined(); - const livesLabel = texts.find((t) => t.text.includes('Lives')); - expect(livesLabel).toBeDefined(); - - // HandViews use showLabels: false, so no "Your Hand" / "AI Hand" text. - // We verify card-back image objects exist (AI hand cards use face-down textures). - const cardBackImages = images.filter((img) => img.texture.key.includes('mind-back')); - expect(cardBackImages.length).toBeGreaterThan(0); - }, 30_000); -}); diff --git a/tests/ui/flipCard.test.ts b/tests/ui/flipCard.test.ts index d088687f..92ad1b90 100644 --- a/tests/ui/flipCard.test.ts +++ b/tests/ui/flipCard.test.ts @@ -285,9 +285,9 @@ describe('flipCard', () => { expect(tweenConfigs[1].y).toBe(100); }); - // ── Equivalence with TheMind's AI flip pattern ───────── + // ── Equivalence with The Mind's AI flip pattern ──────── - it('reproduces TheMind AI flip: asymmetric easing, 250ms, flip-only (translation separate)', () => { + it('reproduces The Mind AI flip: asymmetric easing, 250ms, flip-only (translation separate)', () => { const sprite = createMockTarget(400, 100) as any; sprite.scaleX = 1; const setDisplaySize = vi.fn(() => { diff --git a/tests/ui/handView.animation.test.ts b/tests/ui/handView.animation.test.ts index 3aa6604c..a3c79e59 100644 --- a/tests/ui/handView.animation.test.ts +++ b/tests/ui/handView.animation.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HandView } from '../../src/ui/HandView'; import type { Card } from '../../src/card-system/Card'; import { createCard } from '../../src/card-system/Card'; +import { rankValue } from '../../src/card-system/rankValue'; import { layoutCardPositions } from '../../src/ui/layoutCardPositions'; // ── Minimal Phaser mock (extended from handView.test.ts) ──── @@ -38,6 +39,7 @@ function createMockScene(): any { clearTint: vi.fn().mockReturnThis(), setOrigin: vi.fn().mockReturnThis(), setAlpha: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), setPosition: vi.fn((px: number, py: number) => { img.x = px; img.y = py; @@ -94,6 +96,15 @@ function createMockScene(): any { }, tweens: { add: vi.fn().mockImplementation((config: any) => { + // Apply final positions to targets (simulating Phaser tween behaviour) + if (config.targets) { + const targets = Array.isArray(config.targets) ? config.targets : [config.targets]; + for (const target of targets) { + if (config.x !== undefined) target.x = config.x; + if (config.y !== undefined) target.y = config.y; + if (config.rotation !== undefined) target.rotation = config.rotation; + } + } tweens.push(config); const tween = { stop: vi.fn() }; sceneTweens.push(tween); @@ -750,4 +761,263 @@ describe('HandView animateAddCard', () => { expect(cards[3].rank).toBe('5'); // appended at end }); }); + + // ═══════════════════════════════════════════════════════════ + // Sort Animation + // ═══════════════════════════════════════════════════════════ + + describe('sortCards animation', () => { + beforeEach(() => { + hv = new HandView(scene, { + baseX, + baseY, + spacing: 56, + arcRadius: 0, + showLabels: false, + }); + }); + + it('non-animated sort (default) preserves snap behaviour', () => { + hv.setCards([ + card('K', 'hearts'), + card('2', 'clubs'), + card('A', 'diamonds'), + ]); + + const spriteCountBefore = hv.getSprites().length; + expect(spriteCountBefore).toBe(3); + + // Sort without animate — should rebuild display (destroy + recreate) + (hv as any).sortCards((a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank)); + + const sprites = hv.getSprites(); + const centers = hv.getCardCenters(); + + // Same number of sprites (recreated) + expect(sprites).toHaveLength(3); + expect(centers).toHaveLength(3); + + // Cards should be sorted (ace-low: A, 2, K) + const cards = hv.getCards(); + expect(cards[0].rank).toBe('A'); + expect(cards[1].rank).toBe('2'); + expect(cards[2].rank).toBe('K'); + + // Positions should match computeCardPositions + const gap = (hv as any).spacing - (hv as any).cardWidth; + const centerX = (hv as any)._centerX ?? (hv as any).baseX; + const { positions } = layoutCardPositions({ + count: 3, + cardWidth: (hv as any).cardWidth, + gap, + centerX, + }); + + for (let i = 0; i < 3; i++) { + expect(Math.abs(centers[i].x - positions[i])).toBeLessThanOrEqual(1); + expect(centers[i].y).toBe(baseY); + } + }); + + it('animated sort tweens sprites to correct destination coordinates', () => { + hv.setCards([ + card('K', 'hearts'), + card('2', 'clubs'), + card('A', 'diamonds'), + ]); + + // Sort — cards will reorder to A, 2, K (ace-low) + (hv as any).sortCards( + (a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank), + { animate: true, duration: 200 }, + ); + + // After sort, sprites should still exist (not destroyed) + const sprites = hv.getSprites(); + expect(sprites).toHaveLength(3); + + // Cards should be sorted (ace-low: A, 2, K) + const cards = hv.getCards(); + expect(cards[0].rank).toBe('A'); + expect(cards[1].rank).toBe('2'); + expect(cards[2].rank).toBe('K'); + + // Tween configs should have been created for sprites that moved + const tweenConfigs = scene._tweens; + expect(tweenConfigs.length).toBeGreaterThan(0); + + // Compute expected positions + const gap = (hv as any).spacing - (hv as any).cardWidth; + const centerX = (hv as any)._centerX ?? (hv as any).baseX; + const { positions } = layoutCardPositions({ + count: 3, + cardWidth: (hv as any).cardWidth, + gap, + centerX, + }); + + // After animated sort, sprites should be at the new computed positions + // (mock tweens.add applies final positions synchronously) + const centers = hv.getCardCenters(); + for (let i = 0; i < 3; i++) { + expect(Math.abs(centers[i].x - positions[i])).toBeLessThanOrEqual(1); + expect(centers[i].y).toBe(baseY); + } + }); + + it('animated sort preserves sprites (not destroyed/recreated)', () => { + hv.setCards([ + card('Q', 'hearts'), + card('3', 'clubs'), + card('7', 'diamonds'), + card('J', 'spades'), + ]); + + const spritesBefore = hv.getSprites(); + const spriteIds = spritesBefore.map((s) => s); + + (hv as any).sortCards( + (a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank), + { animate: true }, + ); + + const spritesAfter = hv.getSprites(); + + // Same sprites — just reordered + expect(spritesAfter).toHaveLength(4); + const afterIds = spritesAfter.map((s) => s); + // All sprites from before should be present (same objects) + for (const id of spriteIds) { + expect(afterIds).toContain(id); + } + }); + + it('reducedMotion skips animation and places cards instantly', () => { + const hvReduced = new HandView(scene, { + baseX, + baseY, + spacing: 56, + arcRadius: 0, + showLabels: false, + reducedMotion: true, + }); + + hvReduced.setCards([ + card('K', 'hearts'), + card('2', 'clubs'), + card('A', 'diamonds'), + ]); + + const tweenCountBefore = scene._tweens.length; + + (hvReduced as any).sortCards( + (a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank), + { animate: true }, + ); + + // Cards should be sorted + const cards = hvReduced.getCards(); + expect(cards[0].rank).toBe('A'); + expect(cards[1].rank).toBe('2'); + expect(cards[2].rank).toBe('K'); + + // No tweens should have been created (reduced motion path) + expect(scene._tweens.length).toBe(tweenCountBefore); + + // Sprites should be at correct positions (instant placement) + const centers = hvReduced.getCardCenters(); + const gap = (hvReduced as any).spacing - (hvReduced as any).cardWidth; + const centerX = (hvReduced as any)._centerX ?? (hvReduced as any).baseX; + const { positions } = layoutCardPositions({ + count: 3, + cardWidth: (hvReduced as any).cardWidth, + gap, + centerX, + }); + + for (let i = 0; i < 3; i++) { + expect(Math.abs(centers[i].x - positions[i])).toBeLessThanOrEqual(1); + expect(centers[i].y).toBe(baseY); + } + + hvReduced.destroy(); + }); + + it('animated sort with arc layout uses correct arc Y positions', () => { + const hvArc = new HandView(scene, { + baseX, + baseY, + spacing: 56, + arcRadius: 150, + showLabels: false, + }); + + hvArc.setCards([ + card('K', 'hearts'), + card('2', 'clubs'), + card('A', 'diamonds'), + card('3', 'spades'), + ]); + + (hvArc as any).sortCards( + (a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank), + { animate: true }, + ); + + // Cards sorted: 2, 3, A, K + const cards = hvArc.getCards(); + expect(cards[0].rank).toBe('A'); + expect(cards[1].rank).toBe('2'); + expect(cards[2].rank).toBe('3'); + expect(cards[3].rank).toBe('K'); + + // Arc layout: center cards should be lifted above baseY + const centers = hvArc.getCardCenters(); + expect(centers).toHaveLength(4); + + // Inner cards (indices 1 and 2) should be higher (lower Y) + expect(centers[1].y).toBeLessThan(baseY); + expect(centers[2].y).toBeLessThan(baseY); + + // Edge cards at or near baseY + expect(centers[0].y).toBe(baseY); + expect(centers[3].y).toBe(baseY); + + hvArc.destroy(); + }); + + it('animated sort moves all cards to correct final positions', () => { + hv.setCards([ + card('3', 'clubs'), + card('K', 'diamonds'), + card('2', 'spades'), + ]); + // After sort: 2, 3, K + + (hv as any).sortCards( + (a: Card, b: Card) => rankValue(a.rank) - rankValue(b.rank), + { animate: true }, + ); + + // All 3 cards should have tweens + expect(scene._tweens.length).toBe(3); + + // Verify final positions are correct + const centers = hv.getCardCenters(); + expect(centers).toHaveLength(3); + + const gap = (hv as any).spacing - (hv as any).cardWidth; + const centerX = (hv as any)._centerX ?? (hv as any).baseX; + const { positions } = layoutCardPositions({ + count: 3, + cardWidth: (hv as any).cardWidth, + gap, + centerX, + }); + + for (let i = 0; i < 3; i++) { + expect(Math.abs(centers[i].x - positions[i])).toBeLessThanOrEqual(1); + } + }); + }); }); diff --git a/tests/ui/layoutCardPositions.test.ts b/tests/ui/layoutCardPositions.test.ts index c9cb0f8d..a2373f26 100644 --- a/tests/ui/layoutCardPositions.test.ts +++ b/tests/ui/layoutCardPositions.test.ts @@ -126,7 +126,7 @@ describe('layoutCardPositions', () => { // ── Equivalence with The Mind algorithm ─────────────── it('produces the same result as The Mind renderHumanHand pattern', () => { - // Reproduce The Mind's algorithm for comparison + // Reproduce The Mind's original layout algorithm for comparison const CARD_W = 120; const CARD_GAP = 8; const MAX_HAND_WIDTH = 1200; // GAME_W - 80 diff --git a/tests/ui/shakeIllegalMove.test.ts b/tests/ui/shakeIllegalMove.test.ts index 618451c3..7ca54a21 100644 --- a/tests/ui/shakeIllegalMove.test.ts +++ b/tests/ui/shakeIllegalMove.test.ts @@ -191,10 +191,10 @@ describe('shakeIllegalMove', () => { expect(sprite.setX).toHaveBeenCalledWith(300); }); - it('reproduces TheMind showInvalidPlayFeedback pattern', () => { - // TheMind: setTint(0xff4444), x: originalX - 5, duration: 50, - // yoyo: true, repeat: 2, clearTint + setX(originalX) on complete - // TheMind did not specify ease (used Phaser default), but our default + it('reproduces The Mind showInvalidPlayFeedback pattern', () => { + // The Mind: setTint(0xff4444), x: originalX - 5, duration: 50, + // yoyo: true, repeat: 2, clearTint + setX(originalX) on complete + // The Mind did not specify ease (used Phaser default), but our default // 'Sine.inOut' harmonizes both games. The animation is visually identical. const sprite = createMockTarget(450); diff --git a/tests/ui/visibility-ownership-runtime.test.ts b/tests/ui/visibility-ownership-runtime.test.ts new file mode 100644 index 00000000..badc8d61 --- /dev/null +++ b/tests/ui/visibility-ownership-runtime.test.ts @@ -0,0 +1,326 @@ +/** + * Visibility Ownership Runtime Tests + * + * Unit tests for the layout ownership runtime. The core controller lives in + * `src/core-engine/VisibilityOwnership` and is re-exported from `src/ui` for + * UI-layer consumers. These tests exercise the contract directly from the + * core-engine module (same as the existing test in + * `tests/core-engine/VisibilityOwnership.test.ts`) and add a re-export check. + * + * - Register targets to ownership groups (shell, scene, shared, ungrouped) + * - Toggle visibility based on active layout mode + * - Default behavior for ungrouped targets + * - Diagnostic reporting for ungrouped and unknown groups + * - Re-exported from the UI barrel + * + * @module tests/ui/visibility-ownership-runtime + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + VisibilityOwnershipController, + type VisibilityOwnershipIssue, +} from '../../src/core-engine/VisibilityOwnership'; + +// Note: The VisibilityOwnershipController is re-exported from +// src/ui for UI-layer consumers. The re-export is verified by the +// TypeScript build (it compiles without errors) and by the fact that +// GymSllScene imports it via the UI barrel path. The unit tests below +// import directly from core-engine to avoid pulling in the full Phaser- +// dependent UI barrel in headless Vitest runs. + +type MockTarget = { + visible: boolean; + setVisible: (visible: boolean) => void; +}; + +function createTarget(initialVisible = false): MockTarget { + const target: MockTarget = { + visible: initialVisible, + setVisible: vi.fn((visible: boolean) => { + target.visible = visible; + }), + }; + return target; +} + +function createController( + issues: VisibilityOwnershipIssue[] = [], + groupRules: Record> = { + shell: { 'shell-only': true, 'composed': true }, + scene: { 'scene-only': true, 'composed': true }, + shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, + }, +): VisibilityOwnershipController { + return new VisibilityOwnershipController({ + groupRules, + reportIssue: (issue) => issues.push(issue), + }); +} + +describe('VisibilityOwnershipController (UI barrel re-export)', () => { + it('is available from the UI barrel', () => { + expect(VisibilityOwnershipController).toBeDefined(); + expect(VisibilityOwnershipController).toBeInstanceOf(Function); + }); + + it('registers targets to groups and toggles visibility by mode', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const shellTarget = createTarget(); + const sceneTarget = createTarget(); + const sharedTarget = createTarget(); + + controller.register(shellTarget, 'shell'); + controller.register(sceneTarget, 'scene'); + controller.register(sharedTarget, 'shared'); + + // shell-only: shell and shared visible, scene hidden + controller.setMode('shell-only'); + expect(shellTarget.visible).toBe(true); + expect(sceneTarget.visible).toBe(false); + expect(sharedTarget.visible).toBe(true); + + // scene-only: scene and shared visible, shell hidden + controller.setMode('scene-only'); + expect(shellTarget.visible).toBe(false); + expect(sceneTarget.visible).toBe(true); + expect(sharedTarget.visible).toBe(true); + + // composed: all visible + controller.setMode('composed'); + expect(shellTarget.visible).toBe(true); + expect(sceneTarget.visible).toBe(true); + expect(sharedTarget.visible).toBe(true); + + expect(issues).toHaveLength(0); + }); + + it('registers targets to multiple groups (OR logic)', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const multiTarget = createTarget(); + controller.register(multiTarget, ['shell', 'scene']); + + // shell-only: visible (shell group allows) + controller.setMode('shell-only'); + expect(multiTarget.visible).toBe(true); + + // scene-only: visible (scene group allows) + controller.setMode('scene-only'); + expect(multiTarget.visible).toBe(true); + + // composed: visible + controller.setMode('composed'); + expect(multiTarget.visible).toBe(true); + + expect(issues).toHaveLength(0); + }); + + it('hides ungrouped targets by default and reports a diagnostic', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const ungrouped = createTarget(true); + controller.register(ungrouped); // no group + + controller.setMode('shell-only'); + expect(ungrouped.visible).toBe(false); + + controller.setMode('scene-only'); + expect(ungrouped.visible).toBe(false); + + controller.setMode('composed'); + expect(ungrouped.visible).toBe(false); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + code: 'UNGROUPED_TARGET', + severity: 'warning', + }); + expect(issues[0].message).toContain('ungrouped'); + }); + + it('handles unknown groups with warnings', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const unknown = createTarget(); + controller.register(unknown, 'experimental'); + + controller.setMode('composed'); + expect(unknown.visible).toBe(false); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + code: 'UNKNOWN_GROUP', + severity: 'warning', + }); + expect(issues[0].message).toContain('experimental'); + + // Defining the group makes it visible in the configured mode + controller.setGroupRules('experimental', { + 'composed': true, + }); + + controller.setMode('composed'); + expect(unknown.visible).toBe(true); + }); + + it('supports dynamic group rule updates', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const shellTarget = createTarget(); + controller.register(shellTarget, 'shell'); + + // Initially visible in composed mode + controller.setMode('composed'); + expect(shellTarget.visible).toBe(true); + + // Disable shell in composed mode + controller.setGroupRules('shell', { + 'shell-only': true, + 'composed': false, + }); + + controller.setMode('composed'); + expect(shellTarget.visible).toBe(false); + + controller.setMode('shell-only'); + expect(shellTarget.visible).toBe(true); + + // Restore + controller.setGroupRules('shell', { + 'shell-only': true, + 'composed': true, + }); + + controller.setMode('composed'); + expect(shellTarget.visible).toBe(true); + }); + + it('registerAll registers multiple targets', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const targets = [createTarget(), createTarget(), createTarget()]; + controller.registerAll(targets, 'shell'); + + controller.setMode('shell-only'); + expect(targets[0].visible).toBe(true); + expect(targets[1].visible).toBe(true); + expect(targets[2].visible).toBe(true); + + controller.setMode('scene-only'); + expect(targets[0].visible).toBe(false); + expect(targets[1].visible).toBe(false); + expect(targets[2].visible).toBe(false); + + expect(issues).toHaveLength(0); + }); + + it('clear() removes all registered targets', () => { + const controller = new VisibilityOwnershipController({ + groupRules: { + shell: { 'shell-only': true, 'composed': true }, + scene: { 'scene-only': true, 'composed': true }, + shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, + }, + }); + + const target = createTarget(); + controller.register(target, 'shell'); + controller.setMode('composed'); + expect(target.visible).toBe(true); + + controller.clear(); + + // After clearing, the target is no longer managed + controller.setMode('scene-only'); + // The target should retain its last state since it's unregistered + expect(target.visible).toBe(true); + }); + + it('getMode() returns the current mode', () => { + const controller = new VisibilityOwnershipController({ + groupRules: { + shell: { 'shell-only': true, 'composed': true }, + scene: { 'scene-only': true, 'composed': true }, + shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, + }, + }); + + expect(controller.getMode()).toBe('composed'); + controller.setMode('shell-only'); + expect(controller.getMode()).toBe('shell-only'); + controller.setMode('scene-only'); + expect(controller.getMode()).toBe('scene-only'); + }); + + it('supports string group names (not just arrays)', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const target = createTarget(); + controller.register(target, 'scene'); + + controller.setMode('scene-only'); + expect(target.visible).toBe(true); + + controller.setMode('composed'); + expect(target.visible).toBe(true); + + controller.setMode('shell-only'); + expect(target.visible).toBe(false); + + expect(issues).toHaveLength(0); + }); + + it('emits diagnostics for multiple ungrouped targets', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + controller.register(createTarget()); + controller.register(createTarget()); + controller.register(createTarget()); + + expect(issues).toHaveLength(3); + for (const issue of issues) { + expect(issue.code).toBe('UNGROUPED_TARGET'); + expect(issue.severity).toBe('warning'); + } + }); + + it('throws on empty group name in setGroupRules', () => { + const controller = new VisibilityOwnershipController({ + groupRules: { + shell: { 'shell-only': true, 'composed': true }, + scene: { 'scene-only': true, 'composed': true }, + shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, + }, + }); + + expect(() => controller.setGroupRules('', {})).toThrow( + 'Group name must not be empty.', + ); + }); + + it('trims whitespace from group names', () => { + const issues: VisibilityOwnershipIssue[] = []; + const controller = createController(issues); + + const target = createTarget(); + controller.register(target, ' shell '); + + controller.setMode('shell-only'); + expect(target.visible).toBe(true); + + controller.setMode('scene-only'); + expect(target.visible).toBe(false); + + expect(issues).toHaveLength(0); + }); +});