diff --git a/.gitignore b/.gitignore index e859ec10..eba48ae0 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ Worklog Specific Ignores .opencode/tmp/ ### End of Worklog Specific Ignores +.pi/ diff --git a/README.md b/README.md index 5f479461..bdc3411a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ tableau-card-engine/ | Sushi Go! | `example-games/sushi-go/` | Card drafting game (human vs. AI). Pick and pass hands over 3 rounds, collect sets of sushi dishes, and score the most points | | Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win. Checkpoint autosaves after each turn (human + AI) with startup recovery | | Lost Cities | `example-games/lost-cities/` | Two-player expedition card game (human vs. AI). Bet on up to 5 colored expeditions across a 3-round match with investment multipliers, ascending-play rules, and cumulative scoring | -| Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns. Tutorial overlay zones are defined in a separate SLL layout file (`main-street-tutorial.layout.json`) composed with the base layout. | +| Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns. **Multi-Use Card Economy**: cards can be held in hand for synergy bonuses; staff cards expand hand capacity with ongoing costs. Market cycles each turn. Tutorial overlay zones are defined in a separate SLL layout file (`main-street-tutorial.layout.json`) composed with the base layout. | | Scenario: Tutorial | `example-games/main-street/scenes/MainStreetTutorialScene.ts` | Guided introduction to Main Street. Non-interactive tutorial overlays walk through the market, street placement, synergies, events, and scoring. Easy difficulty, 25 turns. Accessible from the Game Selector. | More games are planned: Coloretto. @@ -96,7 +96,7 @@ When a Business card is upgraded (level > 0), the renderer applies visual overla |---------|----------|-------------| | **Level badge** | Top-right | Gold bold text showing "Lvl N" (e.g., "Lvl 2") | | **Income display** | Bottom-center | Green bold text showing combined income (baseIncome + incomeBonus), e.g., "+8" | -| **Name overlay** | Top-center | White bold text with dark semi-transparent background showing the upgraded card name (e.g., "Library" instead of "Bookshop") | +| **Name overlay** | Top-center | White bold text with dark semi-transparent background showing the upgraded card name (e.g., "Reader's Café" instead of "Bookshop") | | **Upgrade border** | Card perimeter | 3px golden stroke (`#ffaa22`) for visual distinction from base cards | ### Architecture diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 303e4175..1c501aa0 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -125,14 +125,17 @@ MONTE_SEEDS=50 npm run monte-carlo MONTE_SEEDS=200 MONTE_MIN_WIN_RATE=0.20 MONTE_MAX_WIN_RATE=0.80 npm test ``` -Tests use [Vitest](https://vitest.dev/) configured inline in `vite.config.ts` with two test projects: +Tests use [Vitest](https://vitest.dev/) configured inline in `vite.config.ts` with three test projects: | Project | Environment | File Pattern | Purpose | |---------|-------------|-------------|---------| | `unit` | Node.js | `tests/**/*.test.ts` | Logic, data, and integration tests | -| `browser` | Chromium (Playwright) | `tests/**/*.browser.test.ts` | Phaser UI and rendering tests | +| `browser` | Chromium (Playwright) | `tests/**/*.browser.test.ts` (excludes tutorial E2E) | Phaser UI and rendering tests | +| `tutorial` | Chromium (Playwright) | `tests/e2e/main-street-tutorial-e2e-*.browser.test.ts` | Main Street tutorial E2E tests (run separately to avoid GPU context exhaustion) | -Both projects run together via `npm test`. The browser project runs in headless Chromium using `@vitest/browser` with the Playwright provider. +All three projects run together via `npm test`. The browser and tutorial projects run in headless Chromium using `@vitest/browser` with the Playwright provider. + +The tutorial E2E tests are split into 6 part files (1-6 tests per file) and run via `scripts/run-tutorial-tests.sh`, which spawns a separate Chromium instance for each part to avoid the Phaser 4 RC GPU/Canvas context exhaustion that occurs after ~8 game create/destroy cycles in a single browser process. The helper module at `tests/helpers/main-street-tutorial-e2e.ts` contains shared game lifecycle utilities (`bootGameWithTutorial`, `destroyGame` with CanvasPool drain) and click helpers for tutorial step advancement. During Vitest runs, the dev-only transcript persistence middleware (`POST /api/transcripts`) is intentionally disabled even though Vitest browser mode uses an internal Vite server. This prevents file-system side effects and reduces harness noise/flakiness during test execution. @@ -212,6 +215,7 @@ src/ │ ├── GameState.ts GameState, createGameState (deprecated for setup — use SetupOptions) │ ├── SetupOptions.ts BaseSetupOptions, MultiplayerSetupOptions, resolveSetupOptions │ ├── SeededRng.ts createSeededRng — deterministic PRNG (LCG) for shuffles and AI +│ ├── ActiveEffect.ts Duration-based modifier system (create, decay, apply, query) │ ├── CheckpointManager.ts Checkpoint save-and-resume abstraction (save, load, clear, checkAndResume) │ ├── CheckpointResumeOverlay.ts Built-in default resume overlay component │ ├── TranscriptRecorder.ts BaseTranscript interface, TranscriptRecorderBase abstract base class @@ -615,6 +619,48 @@ The `CheckpointManager` delegates all storage to `SaveLoadStore` (IndexedDB with localStorage fallback). See `src/core-engine/CheckpointManager.ts` for full API documentation. +## ActiveEffect System + +The `ActiveEffect` module (`src/core-engine/ActiveEffect.ts`) provides a +duration-based modifier system that tracks ongoing effects over multiple turns. + +### Core Types + +- **`ActiveEffect`** – interface with `effectType`, `multiplier`, `turnsRemaining`, + `sourceEventId`, and `description`. +- **`DecayResult`** – result of a decay operation with `active`, `expired`, and + `effects` arrays. + +### API Functions + +All functions are exported from `@core-engine/index`: + +| Function | Purpose | +|----------|---------| +| `createActiveEffect(type, mult, turns, sourceId, desc)` | Create a new effect | +| `decayActiveEffects(effects)` | Decrement all effects, return active/expired sets | +| `applyActiveEffectMultiplier(effects, type, baseValue)` | Apply matching multipliers (rounded) | +| `hasActiveEffectOfType(effects, type)` | Check if any effect of given type exists | + +### Usage Pattern + +Duration-based Event cards (e.g. `evt-flu-outbreak`) extend `EventCard` with +`duration`, `effectType`, and `multiplier` fields. The engine's `resolveEvent()` +function detects `DurationEventCard` instances via the `isDurationEventCard()` +type guard and creates an `ActiveEffect` instead of applying one-shot deltas. + +Income-modifier effects are applied per-slot during `applyIncome()` _before_ +the reputation coin multiplier. Effects decay at the end of each turn during +`EndCheck` in `processEndOfTurn()`. + +### Main Street Integration + +- `MainStreetState.activeEffects` stores the active effects array +- Serialization/deserialization includes `activeEffects` with migration for + old saves (missing field defaults to `[]`) +- Duration computation for `evt-flu-outbreak` scans the street grid for + Clinic/Medical Center cards + ## Replay Tool The replay tool (`scripts/replay.ts`) replays a fixture transcript through the game's Phaser scene in a headless browser, capturing per-turn screenshots. It is the foundation for thumbnail generation and visual regression testing. @@ -1106,7 +1152,7 @@ this.applyUpgradeOverlays(cardContainer, biz, renderW, renderH); ``` ┌─────────────────────────────────────────────────────────────────┐ │ Game State Update │ -│ Player upgrades Bookshop → Library (level 1→2, income +3→+8) │ +│ Player upgrades Bookshop → Reader's Café (level 1→2, income +3→+8) │ └──────────────────────────┬──────────────────────────────────────┘ │ ▼ @@ -1610,7 +1656,7 @@ The tutorial layout defines these zones (all use normalized coordinates with opt |---------|-------------|-----------------| | `hud` | HUD strip (top bar with coins, reputation, score) | Yes (full-width bounding box) | | `marketBusinessRow` | Business card row in the market area | Yes | -| `streetGrid` | The 2×5 street grid for placing businesses | Yes (full-width) | +| `streetGrid` | The 2×5 street grid for placing businesses | Yes (stops before right column) | | `endTurnButton` | End Turn action button area | Yes | | `incidentQueue` | Scrollable incident cards queue | Yes | | `investmentsRow` | Investment/upgrade card row | Yes | @@ -1917,6 +1963,8 @@ wl close --reason "..." --json # close when done **Vite dev server won't start:** - Check port 3000 is not already in use: `lsof -i :3000` - Try `npm run dev -- --port 3001` for an alternate port +- **Stale lock file:** If port 3000 appears free but the dev server fails, remove any stale lock file: `rm -f tmp/dev-server-lock.json` +- **Orphaned Vite process:** If `lsof -i :3000` shows a Node.js process, kill it: `kill -9 $(lsof -t -i :3000)` **TypeScript errors on build:** - Run `npx tsc --noEmit` to see detailed errors @@ -1932,6 +1980,7 @@ wl close --reason "..." --json # close when done - Check that `@vitest/browser` version matches `vitest` version - Browser tests boot a real Phaser game and may take 8-10 seconds each - If tests hang, check for unresolved game instances (ensure `afterEach` destroys the game) +- **Process/resource leak cleanup:** All browser tests should clean up Phaser.Game instances in `afterEach` using `game.destroy(true, false)` and remove the game container div. The dev server utilities (`scripts/dev-server-utils.ts`) include SIGTERM/SIGINT handlers to clean up orphaned Vite processes and stale lock files on forced exit. **Large bundle warning:** - The Phaser library is ~1.4 MB minified -- this is expected @@ -1941,6 +1990,7 @@ wl close --reason "..." --json # close when done - The replay tool (`npm run replay`) and transcript export (`npm run transcripts:export`) auto-start the dev server if `localhost:3000` is not responding - If auto-start fails, start the dev server manually: `npm run dev` - Check port 3000 availability: `lsof -i :3000` +- **Port conflict detection:** The `ensureDevServer()` helper now checks for existing processes on port 3000 before starting, logs warnings for potential conflicts, and cleans up stale lock files automatically. **Replay tool: Unsupported transcript version error:** - The transcript schema includes a `version` field; the replay tool validates this and exits with a clear error if the version is unsupported diff --git a/docs/main-street/card-catalog-baseline.json b/docs/main-street/card-catalog-baseline.json index 86935803..3a3356ef 100644 --- a/docs/main-street/card-catalog-baseline.json +++ b/docs/main-street/card-catalog-baseline.json @@ -1,16 +1,16 @@ { "source": "Tier 1 baseline from example-games/main-street/MainStreetTiers.ts", - "capturedAt": "2026-05-12T00:15:46.437Z", + "capturedAt": "2026-06-23T09:24:24.962Z", "perTier": { "tier1": { - "business": 7, + "business": 6, "event": 6, - "upgrade": 5, - "total": 18 + "upgrade": 8, + "total": 20 } }, "totals": { - "baselineTotal": 18, - "targetAtLeast": 36 + "baselineTotal": 20, + "targetAtLeast": 40 } } diff --git a/docs/main-street/card-catalog.md b/docs/main-street/card-catalog.md index bbb823d2..144591c8 100644 --- a/docs/main-street/card-catalog.md +++ b/docs/main-street/card-catalog.md @@ -9,22 +9,22 @@ This document lists every card template in the Main Street card pool, organised | Family | Templates | Copies each | Total cards | |----------|-----------|-------------|-------------| -| Business | 17 | 3 | 51 | -| Event | 17 | 3 | 51 | -| Upgrade | 25 | 2 | 50 | +| Business | 18 | 3 | 54 | +| Event | 18 | 3 | 54 | +| Upgrade | 27 | 2 | 54 | -**Synergy types:** Food, Culture, Commerce, Service (M2), Entertainment (M2) +**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`) | 17 | 17 | 25 | 59 | -| Net increase | +10 | +11 | +20 | +41 | +| Current catalog (`MainStreetCards.ts`) | 18 | 18 | 27 | 63 | +| Net increase | +11 | +12 | +22 | +45 | - 2x target from baseline: `>= 36` templates -- Current total: `59` templates (`3.28x` baseline) +- Current total: `63` templates (`3.50x` baseline) - Non-baseline card IDs are tracked in `docs/main-street/expanded-card-manifest.json` ### Guidance: adding more cards safely @@ -71,7 +71,14 @@ Park has been reclassified as a **Community Space** card (see below). |----|------|------|--------|---------|--------------|-------------|-----------| | `biz-laundromat` | Laundromat | 3 | 2 | Service | Laundromat | Self-serve laundry. +1/adj Service. | Budget Service entry point. | | `biz-barbershop` | Barbershop | 3 | 2 | Service | Barbershop | Classic cuts. +1/adj Service. | Pairs with Laundromat for early Service cluster. | -| `biz-clinic` | Clinic | 5 | 3 | Service | Clinic | Walk-in medical care. +1/adj Service. | Premium Service; high cost/income mirrors Hardware Store. | + +#### Health (new synergy type) + +| ID | Name | Cost | Income | Synergy | Upgrade Path | Description | Rationale | +|----|------|------|--------|---------|--------------|-------------|-----------| +| `biz-clinic` | Clinic | 10 | 0 (rep +0.2/turn) | Health | Clinic | Walk-in medical care. Provides +0.2 rep/turn. | Non-profit community health provider; reputation instead of income. | +| `biz-private-clinic` | Private Clinic | 8 | 2 | Health | Private Clinic | Private medical practice. Gains +1 coin per adjacent Health business. | For-profit counterpart to Clinic; income-focused. | +| `biz-pharmacy` | Pharmacy | 6 | 1 | Health | — | Provides essential medications. Gains +1 coin per adjacent Health business. | Standalone Health card (no upgrade). | #### Entertainment (new synergy type) @@ -153,6 +160,7 @@ Events fall into two categories: | `evt-construction` | Road Construction | Incident | 0 | All | -- | -1 | 0 | -1 coin to all biz. | Mild universal disruption. | | `evt-viral-review` | Viral Review | Incident | 0 | All | -- | +2 | +1 | +2 coins, +1 rep from online fame. | Positive windfall; universal. | | `evt-vandalism` | Vandalism | Incident | 0 | All | -- | -1 | -1 | -1 coin, -1 rep. | Dual-penalty universal disruption. | +| `evt-flu-outbreak` | Flu Outbreak | Incident | 0 | All | -- | 0 | 0 | 80% income for 5 turns. Duration reduced by Clinic/Medical Center. | Duration-based modifier (see ActiveEffect system). | ### Event Balance Summary @@ -164,6 +172,9 @@ Events fall into two categories: | M1 Incident (positive) | 1 | 0.0 | +2.0 | | M2 Incident (negative) | 5 | -1.4 | -0.4 | | M2 Incident (positive) | 3 | +1.33 | +1.0 | +| M2 Incident (duration) | 1 | 0.0 | 0.0 | + +> Duration-based incidents (e.g. `evt-flu-outbreak`) apply an ActiveEffect instead of a one-shot delta. Their impact is listed as 0 coin/rep delta because the effect is applied over multiple turns via an income multiplier. The M2 incident pool is more balanced than M1: 5 negative vs. 3 positive incidents (compared to M1's 3 negative vs. 1 positive). This reduces the punishing feel while maintaining strategic tension. @@ -179,7 +190,7 @@ Each Upgrade targets a specific Business by name. Applying an upgrade increments |----|------|--------|------|---------|--------|-------------|-----------| | `upg-patisserie` | Upgrade to Patisserie | Bakery | 4 | +1 | +1 | Bakery -> Patisserie. | Classic upgrade; income + range. | | `upg-bistro` | Upgrade to Bistro | Diner | 4 | +1 | +1 | Diner -> Bistro. | Matches Patisserie in cost/power. | -| `upg-library` | Upgrade to Library | Bookshop | 3 | +1 | +0 | Bookshop -> Library. | Cheaper; income only, no range. | +| `upg-readers-cafe` | Upgrade to Reader's Café | Bookshop | 3 | +1 | +0 | Bookshop -> Reader's Café (+0.1 rep/turn). | Cheaper; income only, no range; reputation bonus. | ### M2 Standard Upgrade Templates (14) @@ -199,7 +210,8 @@ Each Upgrade targets a specific Business by name. Applying an upgrade increments | `upg-museum` | Upgrade to Museum | Art Gallery | 4 | +1 | +1 | Art Gallery -> Museum. | Premium bridge upgrade. | | `upg-resort-spa` | Upgrade to Resort Spa | Day Spa | 5 | +2 | +1 | Day Spa -> Resort Spa. | Tied with IMAX for highest cost/power. | | `upg-garden-center` | Upgrade to Garden Center | Florist | 3 | +1 | +1 | Florist -> Garden Center. | Budget bridge upgrade with range. | -| `upg-medical-center` | Upgrade to Medical Center | Clinic | 5 | +2 | +1 | Clinic -> Medical Center. | Premium Service upgrade. | +| `upg-medical-center` | Upgrade to Medical Center | Clinic | 5 | 0 (rep +0.1/turn) | +1 | Clinic -> Medical Center. Provides +0.1 rep/turn. | Reputation bonus upgrade; no income. | +| `upg-private-medical-center` | Upgrade to Private Medical Center | Private Clinic | 4 | +2 | 0 | Private Clinic -> Private Medical Center. | Income-focused upgrade; no range or reputation. | ### M2 Branching Upgrade Templates (4) @@ -228,7 +240,7 @@ Multi-level upgrades require the business to already be at Level 1 (`requiredLev | Cost | Count | Cards | |------|-------|-------| | 2 | 1 | Gourmet Truck | -| 3 | 9 | Library (Bookshop upgrade), Garden, Vintage Shop, Dry Cleaners, Salon, Roastery, Garden Center, Bread Factory, Fast Food | +| 3 | 9 | Reader's Café (Bookshop upgrade), Garden, Vintage Shop, Dry Cleaners, Salon, Roastery, Garden Center, Bread Factory, Fast Food | | 4 | 8 | Patisserie, Bistro, Designer Store, Gaming Lounge, Museum, Drive-In Theater, Wellness Center, Community Hub | | 5 | 6 | Home Improvement, IMAX Theater, Resort Spa, Medical Center, Grand Bakehouse, Restaurant | | 6 | 2 | Multiplex, Luxury Retreat | @@ -248,8 +260,9 @@ M2 introduces 5 bridge cards that belong to two synergy types simultaneously. Th | Food | 2 (Bakery, Diner) | 2 (Cafe, Food Truck) | 4 | | Culture | 1 (Bookshop) | 3 (Cafe, Art Gallery, Florist) | 4 (plus Park as Community Space) | | Commerce | 3 (Hardware, Pawn Shop, Boutique) | 1 (Florist) | 4 | -| Service | 3 (Laundromat, Barbershop, Clinic) | 1 (Day Spa) | 4 | +| Service | 2 (Laundromat, Barbershop) | 1 (Day Spa) | 3 | | Entertainment | 2 (Arcade, Cinema) | 3 (Food Truck, Art Gallery, Day Spa) | 5 | +| Health | 3 (Clinic, Private Clinic, Pharmacy) | 0 | 3 | Culture and Entertainment have the most bridge-card representation, making them easiest to chain synergies with. Commerce and Service rely more on dedicated single-type cards. diff --git a/docs/main-street/content-design-and-progression.md b/docs/main-street/content-design-and-progression.md index bdf9532a..e4003008 100644 --- a/docs/main-street/content-design-and-progression.md +++ b/docs/main-street/content-design-and-progression.md @@ -11,7 +11,7 @@ The **Main Street** game uses three distinct card families. Below is the current |------|--------------|--------------------------|----------------|--------------|-------------| | Bakery | 3 | 2 | Food | Bakery → Patisserie | Provides warm pastries. Gains +1 coin for each adjacent Food business. | | Diner | 4 | 3 | Food | Diner → Bistro | Serves quick meals. Gains +1 coin per adjacent Food business. | -| Bookshop | 4 | 2 | Culture | Bookshop → Library | Sells books. Gains +1 coin per adjacent Culture business. | +| Bookshop | 4 | 2 | Culture | Bookshop → Reader's Café | Sells books. Gains +1 coin per adjacent Culture business. | | Park | 2 | 1 | Culture | Park → Garden | Offers leisure. Gains +1 coin per adjacent Culture business. | | Hardware Store | 5 | 3 | Commerce | Hardware Store → Home Improvement | Supplies tools. Gains +1 coin per adjacent Commerce business. | | ... *(additional business cards may be added later)* | @@ -38,7 +38,7 @@ Event cards are split into two trigger types: |------|----------------|--------------|--------------|----------------------|-------------| | Upgrade to Patisserie | Bakery | 4 | +1 | +1 (adjacency range) | Turns a Bakery into a Patisserie, increasing income and allowing synergy with businesses two slots away. | | Upgrade to Bistro | Diner | 4 | +1 | +1 | Turns a Diner into a Bistro with higher foot‑traffic. | -| Upgrade to Library | Bookshop | 3 | +1 | 0 | Adds a cultural boost to adjacent Culture businesses. | +| Upgrade to Reader's Café | Bookshop | 3 | +1 | 0 | Transforms the Bookshop into a Reader's Café, blending books with café culture for +0.1 reputation per turn. | | ... *(more upgrades as new businesses are introduced)* | --- diff --git a/docs/main-street/core-rules-and-mechanics.md b/docs/main-street/core-rules-and-mechanics.md index 439cf746..e26dfa90 100644 --- a/docs/main-street/core-rules-and-mechanics.md +++ b/docs/main-street/core-rules-and-mechanics.md @@ -128,7 +128,8 @@ interface GameState { - **AdjacencyResolver** – computes synergy bonuses based on shared `synergyTypes` and proximity (default range 1, can be extended by upgrades) via `@core-engine/SpatialRules`. - **Market** – two rows: Business row (4 face‑up cards from the Business deck) and Investments row (2 Upgrades + 1 Investment event = 3 slots). Cards are replenished after purchase. - **Incident Queue** – visible FIFO queue of 2 Incident cards drawn from the event deck. The front card resolves each turn during IncidentPhase; a replacement is drawn from the deck afterward. If the deck runs out, the queue shrinks naturally. -- **ResourceBank** – tracks `coins` (start 8) and `reputation` (start 3). Reputation is a multiplier applied at final score calculation (`finalScore = coins + reputation * 5 + challengeBonuses`). +- **ActiveEffect System** – some events (e.g. `evt-flu-outbreak`) create duration-based modifiers instead of one-shot deltas. ActiveEffects are tracked in `state.activeEffects: ActiveEffect[]` and decay each turn during EndCheck. See [ActiveEffect System](#-activeeffect-system) below. +- **ResourceBank** – tracks `coins` (start 8) and `reputation` (start 3). Reputation can increase during the IncomePhase via `reputationPerTurn` from certain Health-synergy cards (e.g. Clinic provides +0.2 rep/turn). Reputation is also a multiplier applied at final score calculation (`finalScore = coins + reputation * 5 + challengeBonuses`). ### Spatial API migration note @@ -164,6 +165,8 @@ stateDiagram-v2 5. **IncomePhase** – For each placed Business, compute: - `totalIncome = baseIncome + synergyBonus` where `synergyBonus = countMatchingNeighbors * 1`. - `resourceBank.coins += totalIncome`. + - `totalReputationPerTurn` is calculated from all placed cards (some Health-synergy cards like the Clinic provide `reputationPerTurn`). Upgrades may also contribute `reputationBonus`. + - `resourceBank.reputation += totalReputationPerTurn`. 6. **IncidentPhase** – Resolve the front Incident card from the visible FIFO incident queue. After resolution, draw a replacement Incident from the event deck to the back of the queue (maintaining queue size of 2). If the deck has no more Incidents, the queue shrinks naturally. 7. **EndCheck** – Evaluate win/loss conditions. 8. Loop back to **DayStart** for the next turn. @@ -250,6 +253,43 @@ flowchart TD --- +## 9. ActiveEffect System (Duration-Based Modifiers) + +Certain events (e.g., `evt-flu-outbreak`) create **ActiveEffect** instances that modify game parameters over multiple turns instead of applying one-shot coin/reputation deltas. + +### ActiveEffect Data Structure + +Each ActiveEffect tracks: +- **`effectType`** – discriminator (e.g. `income-multiplier`) +- **`multiplier`** – scalar applied (e.g. `0.8` for 80% income) +- **`turnsRemaining`** – number of turns before the effect expires +- **`sourceEventId`** – the card/event ID that created the effect +- **`description`** – human-readable summary for logging/UI + +### Storage + +ActiveEffects are stored in `state.activeEffects: ActiveEffect[]` (part of `MainStreetState`). The array is serialized/deserialized for save/load; missing field in old saves defaults to `[]`. + +### Turn Flow + +1. **IncomePhase** – `applyIncome()` checks `state.activeEffects` for `income-multiplier` effects and applies the multiplier per-slot *before* the reputation multiplier. +2. **EndCheck** – `decayActiveEffects()` decrements `turnsRemaining` on all active effects. Effects that reach 0 are removed and logged. + +### Example: Flu Outbreak (`evt-flu-outbreak`) + +- **Trigger**: Incident (automatic draw from incident queue) +- **Base duration**: 5 turns +- **Effect**: All businesses generate 80% income (0.8× multiplier) +- **Duration reduction**: If a Clinic (`biz-clinic`) is on the street grid, duration → 3 turns. If a Medical Center (`upg-medical-center`) is present, duration → 2 turns. Only the stronger reduction applies. +- **Minimum duration**: 1 turn (floor) +- **Income application**: The 0.8× multiplier is applied to each slot's base + synergy income *before* the reputation coin multiplier. + +### Extensibility + +The ActiveEffect system is designed for future duration-based events. New effect types can be added by using a new `effectType` string and implementing the corresponding modifier in the relevant game computation function. + +--- + **Document status**: AWAITING PRODUCER REVIEW. *Prepared by*: `opencode` – implementation of work item **CG-0MM4RC1K81JU4U5D**. diff --git a/docs/main-street/expanded-card-manifest.json b/docs/main-street/expanded-card-manifest.json index 8b467f09..e1213651 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-15T12:00:00.000Z", + "generatedAt": "2026-06-24T23:00:00.000Z", "baselineTier1CardIds": [ "biz-bakery", "biz-bookshop", @@ -8,8 +8,8 @@ "biz-hardware", "biz-laundromat", "biz-pawnshop", - "cs-park", "cs-library", + "cs-park", "evt-award", "evt-festival", "evt-grand-opening", @@ -19,7 +19,7 @@ "upg-bistro", "upg-community-hub", "upg-garden", - "upg-library", + "upg-readers-cafe", "upg-patisserie", "upg-vintage-shop" ], @@ -34,19 +34,40 @@ "biz-florist", "biz-food-truck", "biz-gallery", + "biz-pharmacy", + "biz-private-clinic", "biz-spa" ], "event": [ "evt-block-party", + "evt-book-fair", + "evt-bulk-purchase", "evt-charity-drive", + "evt-community-garden", "evt-construction", + "evt-cultural-grant", + "evt-festival-season", + "evt-flu-outbreak", "evt-food-critic", + "evt-good-press", + "evt-harvest-festival", + "evt-health-campaign", + "evt-heatwave", "evt-noise-complaint", + "evt-pest-infestation", "evt-pipe-burst", "evt-power-outage", + "evt-power-surge", + "evt-protest", "evt-shoplifting", + "evt-slow-season", + "evt-street-performer", + "evt-strike", + "evt-supply-chain", + "evt-tourist-bus", "evt-vandalism", "evt-viral-review", + "evt-volunteer-day", "evt-wellness-fair" ], "upgrade": [ @@ -65,6 +86,7 @@ "upg-medical-center", "upg-multiplex", "upg-museum", + "upg-private-medical-center", "upg-resort-spa", "upg-restaurant", "upg-roastery", diff --git a/docs/main-street/monte-carlo-baseline.json b/docs/main-street/monte-carlo-baseline.json index 437b5660..5801c49b 100644 --- a/docs/main-street/monte-carlo-baseline.json +++ b/docs/main-street/monte-carlo-baseline.json @@ -1,11 +1,11 @@ { "source": "Generated from MainStreetMonteCarlo.runMonteCarlo", - "generatedAt": "2026-06-15T12:02:00.000Z", + "generatedAt": "2026-06-30T14:25:29.089Z", "seeds": 200, "maxTurns": 25, "strategy": "greedy", "metrics": { - "winRate": 0.275, - "averageCoinsPerTurn": 0.8971994609492668 + "winRate": 0.505, + "averageCoinsPerTurn": 2.0112805746240356 } } diff --git a/docs/main-street/prd-milestone-1.md b/docs/main-street/prd-milestone-1.md index 0c1075ec..79b0bdf9 100644 --- a/docs/main-street/prd-milestone-1.md +++ b/docs/main-street/prd-milestone-1.md @@ -39,7 +39,7 @@ Deliver a playable walking skeleton of Main Street that a human player can compl | Area | Details | |------|---------| | **Game State Model** | TypeScript types/interfaces for `MainStreetState` including street grid (10 slots), resource bank (coins + reputation), market, deck state, turn counter, day/night phase, and challenges completed. | -| **Card Types** | 5 Business cards (Bakery, Diner, Bookshop, Park, Hardware Store), 5 Event cards (Local Festival, Rainy Day, Tax Audit, Community Award, Health Inspection), 3 Upgrade cards (Patisserie, Bistro, Library). Defined as typed JSON fixtures. | +| **Card Types** | 5 Business cards (Bakery, Diner, Bookshop, Park, Hardware Store), 5 Event cards (Local Festival, Rainy Day, Tax Audit, Community Award, Health Inspection), 3 Upgrade cards (Patisserie, Bistro, Reader's Café). Defined as typed JSON fixtures. | | **Turn Structure** | 6-phase day cycle: DayStart -> MarketPhase -> InvestmentResolution -> IncomePhase -> IncidentPhase -> EndCheck. Implemented via `PhaseManager`. | | **Core Actions** | Buy Business, Buy Upgrade, Buy Event, Place Business, End Turn. All with legality validation returning `LegalityResult`. | | **Win/Loss Detection** | Score threshold (>=150), all-challenges-complete, turn-limit victory (turn 20 with reputation > 0 and coins >= 0). Loss: bankruptcy (coins < 0), reputation collapse (reputation <= 0), turn exhaustion without victory. | @@ -547,7 +547,7 @@ The walking skeleton emits the following events through the `GameEventEmitter` f |----|------|------|-------------|---------------|--------------| | `biz-bakery` | Bakery | 3 | 2 | Food | Bakery -> Patisserie | | `biz-diner` | Diner | 4 | 3 | Food | Diner -> Bistro | -| `biz-bookshop` | Bookshop | 4 | 2 | Culture | Bookshop -> Library | +| `biz-bookshop` | Bookshop | 4 | 2 | Culture | Bookshop -> Reader's Café | | `biz-park` | Park | 2 | 1 | Culture | Park -> Garden | | `biz-hardware` | Hardware Store | 5 | 3 | Commerce | Hardware Store -> Home Improvement | @@ -563,11 +563,11 @@ The walking skeleton emits the following events through the `GameEventEmitter` f ### Upgrade Cards -| ID | Name | Target | Cost | Income Bonus | Synergy Range Bonus | -|----|------|--------|------|-------------|-------------------| -| `upg-patisserie` | Upgrade to Patisserie | Bakery | 4 | +1 | +1 | -| `upg-bistro` | Upgrade to Bistro | Diner | 4 | +1 | +1 | -| `upg-library` | Upgrade to Library | Bookshop | 3 | +1 | 0 | +| ID | Name | Target | Cost | Income Bonus | Synergy Range Bonus | Reputation Bonus | +|----|------|--------|------|-------------|-------------------|------------------| +| `upg-patisserie` | Upgrade to Patisserie | Bakery | 4 | +1 | +1 | 0 | +| `upg-bistro` | Upgrade to Bistro | Diner | 4 | +1 | +1 | 0 | +| `upg-readers-cafe` | Upgrade to Reader's Café | Bookshop | 3 | +1 | 0 | +0.1 | ### Deck Composition (for shuffling) diff --git a/docs/main-street/prd-milestone-2.md b/docs/main-street/prd-milestone-2.md index a4507134..e2362aa7 100644 --- a/docs/main-street/prd-milestone-2.md +++ b/docs/main-street/prd-milestone-2.md @@ -92,7 +92,7 @@ Run N+1 starts with expanded card pool |------|-------| | Business (5) | Bakery (`biz-bakery`), Diner (`biz-diner`), Bookshop (`biz-bookshop`), Park (`biz-park`), Hardware Store (`biz-hardware`) | | Event (5) | Local Festival (`evt-festival`), Rainy Day (`evt-rainy`), Tax Audit (`evt-tax`), Community Award (`evt-award`), Health Inspection (`evt-inspection`) | -| Upgrade (3) | Patisserie (`upg-patisserie`), Bistro (`upg-bistro`), Library (`upg-library`) | +| Upgrade (3) | Patisserie (`upg-patisserie`), Bistro (`upg-bistro`), Reader's Café (`upg-readers-cafe`) | **Total: 13 card templates (5 Business + 5 Event + 3 Upgrade)** @@ -283,7 +283,7 @@ interface MilestoneRecord { "unlockedCardIds": [ "biz-bakery", "biz-diner", "biz-bookshop", "biz-park", "biz-hardware", "evt-festival", "evt-rainy", "evt-tax", "evt-award", "evt-inspection", - "upg-patisserie", "upg-bistro", "upg-library" + "upg-patisserie", "upg-bistro", "upg-readers-cafe" ], "milestoneHistory": [], "persistentReputation": 0, @@ -307,7 +307,7 @@ interface MilestoneRecord { "evt-festival", "evt-rainy", "evt-tax", "evt-award", "evt-inspection", "evt-grand-opening", "evt-wellness-fair", - "upg-patisserie", "upg-bistro", "upg-library", + "upg-patisserie", "upg-bistro", "upg-readers-cafe", "upg-garden" ], "milestoneHistory": [ @@ -355,7 +355,7 @@ interface MilestoneRecord { "evt-wellness-fair", "evt-block-party", "evt-charity-drive", - "upg-patisserie", "upg-bistro", "upg-library", + "upg-patisserie", "upg-bistro", "upg-readers-cafe", "upg-garden", "upg-bread-factory", "upg-grand-bakehouse" @@ -812,7 +812,7 @@ Verification artifacts: | `evt-inspection` | Health Inspection | Event (Incident) | Food | 0 | | `upg-patisserie` | Patisserie | Upgrade | Bakery | 4 | | `upg-bistro` | Bistro | Upgrade | Diner | 4 | -| `upg-library` | Library | Upgrade | Bookshop | 3 | +| `upg-readers-cafe` | Reader's Café | Upgrade | Bookshop | 3 | ### Tier 2 -- Rising Street (+3 templates) @@ -899,7 +899,7 @@ Challenge-based unlock paths are designed to be achievable by skilled players wh 1. The game includes at least 10 distinct Business card templates (up from 5 in M1). 2. Each Business template has all required fields: id, name, cost, baseIncome, synergyTypes, maxLevel, and description. -3. Every synergy type (Food, Culture, Commerce, Service, Entertainment) is represented by at least 2 single-type Business cards. +3. Every synergy type (Food, Culture, Commerce, Service, Entertainment, Health) is represented by at least 2 single-type Business cards. 4. `createBusinessDeck(3)` produces 3 copies of each template and all cards are valid BusinessCard instances. 5. The market refill logic draws from the expanded Business deck without errors or infinite loops across 200+ seeded runs. 6. All existing M1 Business cards remain in the pool with unchanged attributes. @@ -909,7 +909,7 @@ Challenge-based unlock paths are designed to be achievable by skilled players wh - `BUSINESS_TEMPLATES.length >= 10`. - Given `createBusinessDeck(3)`, the resulting deck has `BUSINESS_TEMPLATES.length * 3` cards. - Every Business template has non-empty `id`, `name`, `description`; `cost > 0`; `baseIncome >= 0`; `synergyTypes.length >= 1`. -- For each synergy type S in `['Food', 'Culture', 'Commerce', 'Service', 'Entertainment']`, at least 2 Business templates have S in their `synergyTypes` (counting bridge cards). +- For each synergy type S in `['Food', 'Culture', 'Commerce', 'Service', 'Entertainment', 'Health']`, at least 2 Business templates have S in their `synergyTypes` (counting bridge cards). - Monte Carlo sweep of 200 seeds over 25 turns completes with no deck starvation or refill errors. ### US-9: Diverse Event Card Pool @@ -969,7 +969,7 @@ Challenge-based unlock paths are designed to be achievable by skilled players wh **Testable Conditions:** -- `SynergyType` union includes `'Service'` and `'Entertainment'`. +- `SynergyType` union includes `'Service'`, `'Entertainment'`, and `'Health'`. - At least 2 Business templates have `synergyTypes: ['Service']` (single-type Service cards). - At least 2 Business templates have `synergyTypes: ['Entertainment']` (single-type Entertainment cards). - Given two adjacent Service businesses, `computeSynergyBonus()` returns `synergyBonusPerNeighbor` (1 on Medium). diff --git a/docs/main-street/prd-multi-use-card-economy.md b/docs/main-street/prd-multi-use-card-economy.md new file mode 100644 index 00000000..f7a902a4 --- /dev/null +++ b/docs/main-street/prd-multi-use-card-economy.md @@ -0,0 +1,139 @@ +# Multi-Use Card Economy — Digital Adaptation + +**Feature Area:** Core Economy & Hand Management +**Game:** Main Street +**Status:** Implemented (v0.1.2+) +**Related Work Items:** CG-0MQRXN2CT0076OW7 (Epic), CGD-0MQRCBDMC005MWRP (Solution Reference) + +## Overview + +Main Street's economy has been extended from a single-use card model (cards purchased from the market are placed directly on the tableau) to a **multi-use card economy** where every card has dual purpose — tableau placement **OR** hand-held synergy. This adds meaningful strategic trade-offs without requiring a separate currency track. + +The multi-use economy is **additive** — the existing coin-based and reputation-based systems remain unchanged. All new mechanics are layered on top. + +## Core Mechanics + +### 1. Player Hand Management + +| Property | Value | +|----------|-------| +| Initial hand size | 2 cards | +| Max hand size | 2 + staff card bonuses | +| Hand location | Below the tableau in the UI | +| Card types allowed | Business cards (face-up) | + +Players may choose to place a purchased business card into their hand instead of onto the tableau. Cards in hand are held for future placement or synergy generation. + +### 2. Hand Card Synergy Bonus + +During the **IncomePhase**, each card held in hand contributes **`Math.floor(card.baseIncome / 3)`** coins to every tableau business that shares a synergy type with the hand card. + +**Examples:** +- A Food hand card (baseIncome=3) adds +1 coin to each Food business on the tableau +- A Food+Culture hand card (baseIncome=3) adds +1 coin to each Food AND each Culture business +- Multiple hand cards of the same synergy type **stack**: 2 Food cards (baseIncome=3 each) = +2 coins per Food business +- Pawn Shop cards do **not** receive hand card synergy (special rule) + +**Formula:** +``` +perBusinessBonus = Math.floor(card.baseIncome / 3) +totalHandSynergy = Σ perBusinessBonus for each (handCard, tableauBusiness) synergy match +``` + +### 3. Card Placement & Sell + +| Action | Cost/Value | Destination | +|--------|-----------|-------------| +| Place from hand to tableau | 80% of purchase cost (coins deducted) | Tableau slot | +| Sell from hand | 75% of purchase value (coins credited) | Discard pile | +| Sell from tableau | 75% of purchase value (coins credited) | Discard pile (slot freed) | + +These actions replace the dedicated Sell action from the physical game design. + +### 4. Market Cycling + +At the end of each **MarketPhase**, all unpurchased market cards move to their respective discard piles. The market is then refilled from the decks. This ensures fresh cards are available each turn and prevents market stagnation. + +- Development row cards → business/community-space discards +- Investments row cards → upgrade/event discards +- Uses existing seeded RNG for reshuffles +- Player-owned cards (hand, tableau) are unaffected + +### 5. Staff Cards + +Staff cards are a new card family (`family: 'staff'`) that expand the player's hand capacity at an ongoing coin cost. + +#### Templates + +| Card | Cost | Ongoing Cost | Slots Added | Description | +|------|------|-------------|-------------|-------------| +| Assistant | 3 | 1 | +1 | Low-cost, low upkeep | +| Manager | 6 | 2 | +2 | Mid-range balanced option | +| Director | 10 | 3 | +3 | High investment, high capacity | + +#### Rules + +- Staff cards **do not** occupy hand slots +- Staff cards have an **ongoing cost** deducted from coins each IncomePhase +- Staff cards can be **laid off** at any time (removed from active staff) +- Layoff removes a random selection of hand cards equal to `handSlotsAdded` +- If the hand has fewer cards than slots to remove, all hand cards are removed +- Random selection uses the game's seeded RNG for determinism +- Laid-off staff cards return to the staff card market +- Insufficient coins for ongoing cost: deducts what's available (down to 0) + +#### Market + +Staff cards are available for purchase from a dedicated `staffCardMarket` section. Three templates are available at game start (shuffled deterministically). + +## Economy Changes Summary + +| Old Mechanic | New Mechanic | Status | +|-------------|-------------|--------| +| Stock/Tuck mechanic | Replaced by multi-use economy | **Removed** (explicitly out of scope) | +| Dedicated Sell action | Card sell for 75% value | **Removed** | +| Renovate action | Replaced by card sell | **Removed** | +| Restock action | Replaced by market cycling | **Removed** | +| Open Shop action | Replaced by playing card to tableau | **Removed** | +| Single-use card purchase | Purchase to hand OR tableau | **Added** | + +## Rationale: Digital vs Physical + +The physical card game uses a card-discard economy where cards are single-use and immediately consumed. The digital adaptation adds a hybrid approach: + +1. **No physical constraints** — Digital tracking of hands, synergy, and staff costs is effortless +2. **Deeper strategy** — Players must decide between immediate tableau income and future synergy potential +3. **Staff as strategic lever** — Ongoing costs create tension between hand capacity and net income +4. **Deterministic RNG** — All random elements use the seeded RNG for reproducibility + +## Implementation Architecture + +All multi-use economy features are implemented in the following files: + +- `MainStreetCards.ts` — StaffCard interface, templates, `createStaffDeck()` +- `MainStreetState.ts` — `hand`, `maxHandSize`, `discardPile`, `staffCards`, `staffCardMarket` +- `MainStreetMarket.ts` — `purchaseBusinessToHand()`, `canAddToHand()`, `purchaseStaffCard()`, `cycleMarketCards()` +- `MainStreetAdjacency.ts` — `computeHandCardSynergyBonus()`, updated `computeIncome()` with hand param +- `MainStreetEngine.ts` — `applyStaffOngoingCosts()`, `layoffStaffCard()`, `BuyBusinessToHandAction` +- `MainStreetCommands.ts` — `buyBusinessToHandCommand()` +- `MainStreetRenderer.ts` — Hand card rendering, hand size indicator +- `MainStreetScene.ts` — `handBusinessContainer`, `handSizeText` + +## Save/Load Compatibility + +Old saved games (without hand/staff/discard fields) load correctly with defaults: +- `hand` → `[]` +- `maxHandSize` → `2` +- `discardPile` → `[]` +- `staffCards` → `[]` +- `staffCardMarket` → `[]` + +## Test Coverage + +| Test File | Tests | Coverage | +|-----------|-------|----------| +| `MainStreetHandState.test.ts` | 22 | Hand initialization, purchase-to-hand, capacity, serialization, migration | +| `MainStreetHandSynergy.test.ts` | 17 | 1/3 synergy calculation, stacking, matching, breakdown | +| `MainStreetMarketCycling.test.ts` | 17 | Cycling, reshuffle, deterministic RNG, edge cases | +| `MainStreetStaffCards.test.ts` | 25 | Templates, purchase, stacking, costs, layoff, RNG determinism | +| `MainStreetIntegration.test.ts` | 12 | Full game loop, net income, lifecycle, migration | diff --git a/docs/main-street/the-build-gdd.md b/docs/main-street/the-build-gdd.md index 02b0ca9f..c12f417e 100644 --- a/docs/main-street/the-build-gdd.md +++ b/docs/main-street/the-build-gdd.md @@ -253,7 +253,7 @@ The **Main Street** game uses three distinct card families. Below is the current |------|--------------|--------------------------|----------------|--------------|-------------| | Bakery | 3 | 2 | Food | Bakery → Patisserie | Provides warm pastries. Gains +1 coin for each adjacent Food business. | | Diner | 4 | 3 | Food | Diner → Bistro | Serves quick meals. Gains +1 coin per adjacent Food business. | -| Bookshop | 4 | 2 | Culture | Bookshop → Library | Sells books. Gains +1 coin per adjacent Culture business. | +| Bookshop | 4 | 2 | Culture | Bookshop → Reader's Café | Sells books. Gains +1 coin per adjacent Culture business. | | Park | 2 | 1 | Culture | Park → Garden | Offers leisure. Gains +1 coin per adjacent Culture business. | | Hardware Store | 5 | 3 | Commerce | Hardware Store → Home Improvement | Supplies tools. Gains +1 coin per adjacent Commerce business. | | ... *(additional business cards may be added later)* | @@ -280,7 +280,7 @@ Event cards are split into two trigger types: |------|----------------|--------------|--------------|----------------------|-------------| | Upgrade to Patisserie | Bakery | 4 | +1 | +1 (adjacency range) | Turns a Bakery into a Patisserie, increasing income and allowing synergy with businesses two slots away. | | Upgrade to Bistro | Diner | 4 | +1 | +1 | Turns a Diner into a Bistro with higher foot‑traffic. | -| Upgrade to Library | Bookshop | 3 | +1 | 0 | Adds a cultural boost to adjacent Culture businesses. | +| Upgrade to Reader's Café | Bookshop | 3 | +1 | 0 | Transforms the Bookshop into a Reader's Café, blending books with café culture for +0.1 reputation per turn. | | ... *(more upgrades as new businesses are introduced)* | --- @@ -471,6 +471,64 @@ All tests are run via `npm test` (Vitest). Build validation via `npm run build`. --- +## Appendix D: Multi-Use Card Economy (Digital Adaptation) + +*Added in CG-0MQRXN2CT0076OW7 (v0.1.2+)* + +Main Street's economy was extended with a **multi-use card economy** where every purchased business card has dual purpose: tableau placement OR hand-held synergy generation. This section summarises the digital-only mechanics. + +### D.1 Player Hand + +- Each player has a **hand** holding business cards (initial capacity: 2). +- Cards purchased from the market can be placed in the hand instead of on the tableau. +- Hand capacity is displayed in the UI as "Hand: N/M". + +### D.2 Hand Card Synergy + +- During IncomePhase, each hand card provides `Math.floor(card.baseIncome / 3)` coins to each tableau business sharing a synergy type. +- Multiple hand cards stack their bonuses. +- Pawn Shop cards are excluded from receiving hand synergy. + +### D.3 Market Cycling + +- At the end of each MarketPhase, all unpurchased market cards move to their respective discard piles. +- The market is then refilled from the decks. +- Discard piles reshuffle into decks when depleted. + +### D.4 Staff Cards + +Staff cards are a new purchasable card type that increase hand capacity: + +| Tier | Cost | Ongoing Cost | Slots | +|------|------|-------------|-------| +| Assistant | 3 | 1 | +1 | +| Manager | 6 | 2 | +2 | +| Director | 10 | 3 | +3 | + +- Staff cards do NOT occupy hand slots. +- Ongoing costs are deducted each IncomePhase. +- Staff can be laid off, removing random hand cards equal to slots provided. + +### D.5 Removed Mechanics + +The following mechanics from the physical design are **removed** in the digital adaptation: + +| Mechanic | Replacement | +|----------|-------------| +| Stock/Tuck | Multi-use hand economy | +| Dedicated Sell action | Card sell at 75% value | +| Renovate action | Card sell | +| Restock action | Market cycling | +| Open Shop action | Tableau placement from hand | + +### D.6 Implementation + +See `docs/main-street/prd-multi-use-card-economy.md` for the complete technical specification, architecture, and test coverage details. + +--- + *Document status*: AWAITING PRODUCER REVIEW. *Prepared by*: `opencode` – implementation of work item **CG-0MM4RDIMT1HLP2DE**. + +*Multi-Use Card Economy section by*: `Map` – implementation of work item **CG-0MQRXN2CT0076OW7**. diff --git a/docs/main-street/tutorial-localization.md b/docs/main-street/tutorial-localization.md new file mode 100644 index 00000000..a41c58b0 --- /dev/null +++ b/docs/main-street/tutorial-localization.md @@ -0,0 +1,186 @@ +# Tutorial Localization Guide + +The Main Street tutorial system externalises all user-facing copy through the +core engine's [i18n module](../../src/core-engine/I18n.ts). This means tutorial +text can be translated and reviewed without editing gameplay code. + +## Architecture + +Tutorial step definitions in [`TutorialFlow.ts`](../../example-games/main-street/TutorialFlow.ts) +no longer contain inline string literals for titles and bodies. Instead, each +step carries an i18n **key**: + +```ts +// Before (hardcoded) +{ id: 'T1', title: 'Welcome to Main Street', body: '...' } + +// After (i18n key) +{ id: 'T1', titleKey: 'tutorial.T1.title', bodyKey: 'tutorial.T1.body' } +``` + +The actual string values are stored in **locale bundles** — currently just +English in [`tutorial-en.ts`](../../example-games/main-street/i18n/tutorial-en.ts). + +At runtime, the overlay manager ([`MainStreetTutorialHints`](../../example-games/main-street/scenes/MainStreetTutorialHints.ts)) +calls `t(key)` to resolve the active locale's string for each step. The +English bundle is registered at module load time, so it is always available +as a fallback. + +### Key naming convention + +All tutorial step keys follow the pattern: + +``` +tutorial.. +``` + +Where: +- `` is the step identifier (e.g. `T1`, `T3`, `T13`) +- `` is `title` or `body` + +Examples: +- `tutorial.T1.title` +- `tutorial.T3.body` +- `tutorial.T13.title` + +The `tutorialKey()` helper function constructs these keys: + +```ts +import { tutorialKey } from '../../example-games/main-street/i18n/tutorial-en'; + +tutorialKey('T3', 'title'); // → 'tutorial.T3.title' +``` + +### Offer modal and overlay button keys + +The tutorial offer modal and overlay button labels are also externalized: + +| Key pattern | Description | Example value (English) | +|------------|-------------|------------------------| +| `tutorial.modal.title` | Offer modal title | `Welcome to Main Street!` | +| `tutorial.modal.body` | Offer modal body text | `Would you like a tour to learn the basics of Main Street?` | +| `tutorial.modal.skipBtn` | Skip button label | `Skip` | +| `tutorial.modal.startBtn` | Start Tutorial button | `Start Tutorial` | +| `tutorial.overlay.dismiss` | Dismiss overlay button | `Dismiss` | +| `tutorial.overlay.next` | Next step button | `Next >` | +| `tutorial.overlay.exit` | Exit tutorial button | `Exit Tutorial` | +| `tutorial.overlay.startFullGame` | Start full game button | `Start Full Game` | + +Helper functions: + +```ts +import { modalKey, overlayKey } from '../../example-games/main-street/i18n/tutorial-en'; + +modalKey('title'); // → 'tutorial.modal.title' +overlayKey('dismiss'); // → 'tutorial.overlay.dismiss' +``` + +## Updating Tutorial Copy + +### Plain-language guidelines + +Tutorial text follows these editorial principles: + +- **Reading level:** ~10-year-old reading level (Flesch-Kincaid Grade Level ≤ 5-6) +- **Word count:** Each step body under 50 words (soft boundary — conciseness preferred) +- **Concepts:** At most 1–2 distinct gameplay concepts per step (soft boundary) +- **Plain language:** Short sentences, common words, active voice, no jargon without explanation +- **Consistency:** Use consistent terminology across all steps (e.g. "Coins" not "gold", "turns" not "days") + +### Changing existing text + +1. Open [`i18n/tutorial-en.ts`](../../example-games/main-street/i18n/tutorial-en.ts). +2. Find the key for the string you want to update (e.g. `tutorial.T3.body`). +3. Change the string value. +4. The change takes effect immediately — no gameplay code changes needed. + +For offer modal or button label changes: + +1. Open [`i18n/tutorial-en.ts`](../../example-games/main-street/i18n/tutorial-en.ts). +2. Find the relevant `tutorial.modal.*` or `tutorial.overlay.*` key. +3. Update the value. +4. The change takes effect immediately. + +### Verification + +Run the i18n tests to confirm all keys resolve: + +```bash +npx vitest run tests/main-street/tutorial-i18n.test.ts +npx vitest run tests/main-street/tutorial-text-updates.test.ts +npx vitest run tests/main-street/tutorial-flow.test.ts +``` + +The build will also fail if any step key is missing from the English bundle. + +## Adding a New Language + +1. Create a new locale bundle file, e.g. `example-games/main-street/i18n/tutorial-fr.ts`: + + ```ts + import { tutorialKey } from './tutorial-en'; + + export const TUTORIAL_FR_BUNDLE: Record = { + [tutorialKey('T1', 'title')]: 'Bienvenue à Main Street', + [tutorialKey('T1', 'body')]: 'Construisez la meilleure rue...', + // ... all other steps + }; + ``` + +2. Register the bundle at a suitable startup point. The overlay manager + currently registers English at module load time. You can register additional + locales alongside it, or switch the active locale based on a user setting: + + ```ts + import { registerLocale } from '../../../src/core-engine/I18n'; + import { TUTORIAL_FR_BUNDLE } from '../i18n/tutorial-fr'; + + registerLocale('fr', TUTORIAL_FR_BUNDLE); + ``` + +3. Switch the active locale: + + ```ts + import { setLocale } from '../../../src/core-engine/I18n'; + setLocale('fr'); + ``` + + All tutorial `t(key)` calls will now resolve to the French bundle. + Any keys not present in the French bundle will fall back to English. + +### Partial translations + +Locale bundles can be partial — missing keys automatically fall back to +the English bundle. This lets translators incrementally localise steps +without needing to provide every string upfront. + +Example (French with only T1 translated): + +```ts +registerLocale('fr', { + [tutorialKey('T1', 'title')]: 'Bienvenue à Main Street', +}); +setLocale('fr'); +t(tutorialKey('T1', 'title')); // → 'Bienvenue à Main Street' +t(tutorialKey('T2', 'title')); // → 'Resource HUD' (English fallback) +``` + +## Test Coverage + +The following test files cover tutorial localization: + +| Test file | What it verifies | +|-----------|-----------------| +| `tests/main-street/tutorial-i18n.test.ts` | All keys exist in English bundle; locale switching; `resolveTutorialStepText()` correctness | +| `tests/main-street/tutorial-text-updates.test.ts` | Specific text content via i18n (e.g. "Development Row", "Laundromat") | +| `tests/main-street/tutorial-flow.test.ts` | Step definitions have non-empty `titleKey`/`bodyKey`; `resolveTutorialStepText()` returns non-empty text | + +## Overview of Relevant Files + +| File | Purpose | +|------|---------| +| `example-games/main-street/TutorialFlow.ts` | Step definitions (keys only), controller logic | +| `example-games/main-street/i18n/tutorial-en.ts` | English locale bundle with all title/body values | +| `example-games/main-street/scenes/MainStreetTutorialHints.ts` | Overlay manager — resolves keys via `t()` at render time | +| `src/core-engine/I18n.ts` | Core i18n lookup module | +| `tests/main-street/tutorial-i18n.test.ts` | i18n-specific test coverage | diff --git a/docs/ui-animations.md b/docs/ui-animations.md index 9d692ced..930323e3 100644 --- a/docs/ui-animations.md +++ b/docs/ui-animations.md @@ -8,7 +8,9 @@ The Tableau Card Engine provides reusable animation helpers in `src/ui/` for com |------------|-------------|-------------------| | `dealCard` | Card dealing animation with arc motion | 400ms | | `placeCard` | Card placement animation with "snap" effect | 350ms | -| `placeCard` | Card discard animation with fade/shrink | 400ms | +| `discardCard` | Card discard animation with fade/shrink | 400ms | +| `flipCard` | Two-phase card flip (scaleX → 0 → texture swap → scaleX → 1) | 300ms | +| `moveGameObject` | Positional movement tween | 700ms | ## dealCard @@ -53,6 +55,7 @@ dealCard({ | `gameEvents` | `GameEventEmitter` | undefined | Event emitter | | `cardId` | `string` | undefined | Card ID for event payload | | `playerIndex` | `number` | undefined | Player index for event payload | +| `reducedMotion` | `boolean` | undefined | When true, animation is skipped and snaps to destination instantly | ### Events @@ -100,6 +103,7 @@ placeCard({ | `cardId` | `string` | undefined | Card ID for event | | `playerIndex` | `number` | undefined | Player index | | `slotIndex` | `number` | undefined | Slot index | +| `reducedMotion` | `boolean` | undefined | When true, animation is skipped and snaps to destination instantly | ### Events @@ -143,14 +147,116 @@ discardCard({ | `cardId` | `string` | undefined | Card ID for event | | `playerIndex` | `number` | undefined | Player index | | `destroyAfter` | `boolean` | true | Destroy sprite after | +| `reducedMotion` | `boolean` | undefined | When true, animation is skipped and target is hidden/destroyed instantly | ### Events Emits `card:discarded` event on completion. +## flipCard + +Performs the classic "scaleX → 0 → change texture → scaleX → 1" card flip animation. Optionally translates the sprite to a destination during the flip. + +### Import + +```ts +import { flipCard } from '@ui/flipCard'; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `scene` | `Phaser.Scene` | required | The Phaser scene | +| `target` | `Image | Sprite` | required | The sprite to flip | +| `newTexture` | `string` | required | Texture key for the face side | +| `duration` | `number` | 300 | Total duration in ms | +| `easeClose` | `string` | 'Power2' | Easing for close phase | +| `easeOpen` | `string` | easeClose | Easing for open phase | +| `destX` | `number` | undefined | Destination X (for flip + translate) | +| `destY` | `number` | undefined | Destination Y | +| `onMidpoint` | `function` | undefined | Called at midpoint after texture swap | +| `onComplete` | `function` | undefined | Called after flip completes | +| `reducedMotion` | `boolean` | undefined | When true, texture swaps instantly without animation | + +## moveGameObject + +Animates a Phaser game object from its current position to a target (x, y) with configurable duration, easing, and an onComplete callback. Position-only translation. + +### Import + +```ts +import { moveGameObject } from '@ui/moveGameObject'; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `scene` | `Phaser.Scene` | required | The Phaser scene | +| `target` | `GameObject` | required | The object to move | +| `destX` | `number` | required | Destination X | +| `destY` | `number` | required | Destination Y | +| `duration` | `number` | 700 | Duration in ms | +| `ease` | `string` | 'Quad.easeOut' | Easing function | +| `onComplete` | `function` | undefined | Called after movement completes | +| `reducedMotion` | `boolean` | undefined | When true, target snaps to destination instantly | +| `sfx` | `object` | undefined | Optional SFX configuration (start/move/end keys) | + +## popTextOrIcon + +Animate a pop-up text or icon (rises, fades, and scales up briefly). + +### Import + +```ts +import { popTextOrIcon } from '@ui/popTextOrIcon'; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `scene` | `Phaser.Scene` | required | The Phaser scene | +| `target` | `GameObject` | undefined | Existing text/icon object | +| `label` | `string` | undefined | Text label (created if no target provided) | +| `reducedMotion` | `boolean` | undefined | When true, destroys target immediately without animation | + +## runSceneTransition + +Animate a scene transition (fade or slide enter/exit). + +### Import + +```ts +import { runSceneTransition } from '@ui/sceneTransition'; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `scene` | `Phaser.Scene` | required | The Phaser scene | +| `mode` | `'enter' | 'exit'` | required | Transition direction | +| `type` | `'fade' | 'slide'` | 'fade' | Transition type | +| `duration` | `number` | 300 | Duration in ms | +| `reducedMotion` | `boolean` | undefined | When true, transition completes instantly | + ## Accessibility -All animation helpers respect the `prefers-reduced-motion` media query. When reduced motion is preferred, animations complete instantly (50ms) without the visual effects. +All animation helpers respect the reduced motion preference with the following priority: + +1. **Explicit `reducedMotion` parameter** — when passed directly to the animation call, takes highest priority. +2. **SettingsStore preference** — the in-game "Reduced Motion" toggle in the Settings panel (persisted to `localStorage` under `tce-ui-reduced-motion`). +3. **CSS media query** — `prefers-reduced-motion: reduce` as fallback when no explicit preference is set. + +The utility function `getEffectiveReducedMotion(storage?)` in `src/ui/ReducedMotion.ts` implements this priority chain and can be used directly by any code that needs to check the preference. + +When reduced motion is enabled: +- All tweens are skipped and sprites snap to their final position/state instantly +- All sound effects (SFX) are suppressed +- Callbacks (`onComplete`) fire synchronously +- Events (e.g., `card:placed`) are emitted correctly ## Event Types @@ -174,4 +280,4 @@ interface CardDiscardedPayload { } ``` -See `src/core-engine/GameEventEmitter.ts` for the full event type definitions. \ No newline at end of file +See `src/core-engine/GameEventEmitter.ts` for the full event type definitions. diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts index 50392726..e3f19888 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleRenderer.ts @@ -9,7 +9,7 @@ import type { BeleagueredCastleState } from '../BeleagueredCastleState'; import { FOUNDATION_COUNT, TABLEAU_COUNT } from '../BeleagueredCastleState'; import { HandView, PileView } from '../../../src/ui'; import { GAME_W, GAME_H } from '../../../src/ui'; -import { createSceneTitle, createSceneMenuButton } from '@ui/Renderer'; +import { createSceneTitle } from '@ui/Renderer'; import { createBcHudText } from '../../../src/ui/Renderer/adapters/BeleagueredCastleAdapter'; import { BC_CARD_W, BC_CARD_H, CARD_GAP, CASCADE_OFFSET_Y, @@ -36,6 +36,9 @@ export interface CardSpriteData { } export class BeleagueredCastleRenderer { + /** When true, deal animation is skipped and all cards appear immediately. */ + reducedMotion = false; + private scene: Phaser.Scene; private state: BeleagueredCastleState; @@ -86,7 +89,6 @@ export class BeleagueredCastleRenderer { // ── UI creation ───────────────────────────────────────── createTitle(): void { - createSceneMenuButton(this.scene, { y: this.layout.headerY }); createSceneTitle(this.scene, 'Beleaguered Castle', { y: this.layout.headerY }); } @@ -227,6 +229,11 @@ export class BeleagueredCastleRenderer { // ── Deal animation ────────────────────────────────────── dealTableauAnimated(): void { + if (this.reducedMotion) { + this.syncTableauHandViews(); + this.onDealComplete?.(); + return; + } const centerX = GAME_W / 2; const centerY = GAME_H / 2; @@ -392,6 +399,11 @@ export class BeleagueredCastleRenderer { snapBack(sprite: Phaser.GameObjects.Image): void { const data = sprite.getData('cardData') as CardSpriteData | undefined; if (!data) return; + if (this.reducedMotion) { + sprite.setPosition(data.originX, data.originY); + sprite.setDepth(data.originDepth); + return; + } this.scene.tweens.add({ targets: sprite, x: data.originX, diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts index 2e52c184..f2aa4afc 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts @@ -41,7 +41,7 @@ import { BeleagueredCastleTurnController } from './BeleagueredCastleTurnControll import { moveGameObject, cardTextureKey } from '../../../src/ui'; import { GAME_W, GAME_H, FONT_FAMILY, - createOverlayButton, createOverlayMenuButton, + createOverlayButton, } from '../../../src/ui'; import { createHudText } from '../../../src/ui/Renderer/adapters/BeleagueredCastleAdapter'; import { SaveLoadStore } from '../../../src/core-engine'; @@ -107,7 +107,7 @@ export class BeleagueredCastleScene extends CardGameScene { const seedParam = params.get('seed'); this.seed = seedParam ? parseInt(seedParam, 10) : Date.now(); - this.detectReplayMode(); + super.create(); // Create a placeholder game state; will be replaced if resuming from checkpoint this.gameState = deal(this.seed); @@ -152,9 +152,6 @@ export class BeleagueredCastleScene extends CardGameScene { }; this.bcRenderer.onCardClick = (col) => this.handleCardClick(col); - this.initEventSystem(); - this.initHUDContainer(); - if (!this.replayMode) { this.initHelpPanel(helpContent as HelpSection[]); const mapping: EventSoundMapping = { @@ -174,6 +171,10 @@ export class BeleagueredCastleScene extends CardGameScene { }; this.initSoundSystem(Object.values(SFX_KEYS), mapping, { namespace: 'beleaguered-castle' }); this.initSettingsPanel(); + // Propagate reduced motion preference to the renderer + if (this.settingsPanel) { + this.bcRenderer.reducedMotion = this.settingsPanel.reducedMotion; + } this.initUndoRedoButtons( () => this.turnController.performUndo(), () => this.turnController.performRedo(), @@ -728,9 +729,6 @@ export class BeleagueredCastleScene extends CardGameScene { 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 menuBtn = createOverlayMenuButton(this, GAME_W / 2 + 150, GAME_H / 2 + OVERLAY_BUTTON_Y_OFFSET, BUTTON_DEPTH); - this.overlayManager.add(menuBtn); } private showNoMovesOverlay(): void { @@ -761,8 +759,5 @@ export class BeleagueredCastleScene extends CardGameScene { const restartBtn = createOverlayButton(this, GAME_W / 2 + 110, GAME_H / 2 + 30, '[ Restart ]', BUTTON_DEPTH); restartBtn.on('pointerdown', () => this.onRestart?.()); this.overlayManager.add(restartBtn); - - const menuBtn = createOverlayMenuButton(this, GAME_W / 2 + 230, GAME_H / 2 + 30, BUTTON_DEPTH); - this.overlayManager.add(menuBtn); } } diff --git a/example-games/feudalism/scenes/FeudalismAnimator.ts b/example-games/feudalism/scenes/FeudalismAnimator.ts index ae231ad0..a38ee253 100644 --- a/example-games/feudalism/scenes/FeudalismAnimator.ts +++ b/example-games/feudalism/scenes/FeudalismAnimator.ts @@ -17,6 +17,9 @@ import { import { moveGameObject } from '../../../src/ui'; export class FeudalismAnimator { + /** When true, all animations are skipped. */ + reducedMotion = false; + private scene: Phaser.Scene; private session: FeudalismSession; @@ -107,6 +110,7 @@ export class FeudalismAnimator { destX: destPos.x, destY: destPos.y, duration: MOVE_DURATION, + reducedMotion: this.reducedMotion, onComplete: () => { flyingCard.destroy(); if (marketSlot) { @@ -136,6 +140,7 @@ export class FeudalismAnimator { destX: slotPos.x, destY: slotPos.y, duration: MOVE_DURATION * 0.7, + reducedMotion: this.reducedMotion, onComplete: () => { flyingBack.destroy(); onRefreshMarket(); @@ -171,6 +176,7 @@ export class FeudalismAnimator { destX: patronDest.x, destY: patronDest.y, duration: MOVE_DURATION, + reducedMotion: this.reducedMotion, onComplete: () => { flyingPatron.destroy(); onRefreshPatronsAndPlayer(); diff --git a/example-games/feudalism/scenes/FeudalismOverlays.ts b/example-games/feudalism/scenes/FeudalismOverlays.ts index 40f500a4..b60776ca 100644 --- a/example-games/feudalism/scenes/FeudalismOverlays.ts +++ b/example-games/feudalism/scenes/FeudalismOverlays.ts @@ -10,7 +10,7 @@ import { FeudalismTranscriptRecorder } from '../GameTranscript'; import { autoSaveTranscript, TranscriptStore } from '../../../src/core-engine/transcript'; import { GAME_W, GAME_H, FONT_FAMILY, - createOverlayButton, createOverlayMenuButton, + createOverlayButton, OverlayManager, } from '../../../src/ui'; import { SFX_KEYS } from './FeudalismConstants'; @@ -67,16 +67,13 @@ export class FeudalismOverlayHelper { .setDepth(11); this.overlayManager.add(text); - const playBtn = createOverlayButton(this.scene, GAME_W / 2 - 80, GAME_H / 2 + 110, '[ Play Again ]'); + 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(); }); this.overlayManager.add(playBtn); - - const menuBtn = createOverlayMenuButton(this.scene, GAME_W / 2 + 80, GAME_H / 2 + 110); - this.overlayManager.add(menuBtn); } showCardActionMenu( diff --git a/example-games/feudalism/scenes/FeudalismScene.ts b/example-games/feudalism/scenes/FeudalismScene.ts index 948a5764..f305ae2b 100644 --- a/example-games/feudalism/scenes/FeudalismScene.ts +++ b/example-games/feudalism/scenes/FeudalismScene.ts @@ -20,7 +20,7 @@ import { CardGameScene, GAME_W, GAME_H, OverlayManager, - createSceneTitle, createSceneMenuButton, + createSceneTitle, audioPathWithFallback, } from '../../../src/ui'; import type { HelpSection } from '../../../src/ui'; @@ -28,6 +28,7 @@ import helpContent from '../help-content.json'; import { SFX_KEYS, + ANIM_DURATION, type TurnPhase, } from './FeudalismConstants'; import { FeudalismRenderer } from './FeudalismRenderer'; @@ -79,9 +80,7 @@ export class FeudalismScene extends CardGameScene { this.cameras.main.setBackgroundColor('#1a2a1a'); this.replayStepIndex = -1; - this.detectReplayMode(); - this.initEventSystem(); - this.initHUDContainer(); + super.create(); if (this.replayMode) { this.createHeader(); @@ -165,6 +164,10 @@ export class FeudalismScene extends CardGameScene { this.feudRenderer.createInfluenceDisplay(); this.initHelpPanel(helpContent as HelpSection[]); this.initSettingsPanel(); + // Propagate reduced motion preference to the animator + if (this.settingsPanel) { + this.animator.reducedMotion = this.settingsPanel.reducedMotion; + } this.refreshAll(); this.turnController.setPhase('player-turn'); @@ -174,7 +177,6 @@ export class FeudalismScene extends CardGameScene { } private createHeader(): void { - createSceneMenuButton(this); createSceneTitle(this, 'Feudalism'); } @@ -452,15 +454,28 @@ export class FeudalismScene extends CardGameScene { /** * Restore the game state from a saved checkpoint. - * Rebuilds the turn controller, renderer, and interactions. + * + * Mutates the existing session in-place and reuses the existing renderer + * and animator (following the Beleaguered Castle pattern). This avoids + * creating duplicate Phaser game objects that caused the original bug: + * see CG-0MQMEH2IX0050WNR. + * + * Only the turn controller and checkpoint manager are rebuilt, since they + * hold transient state (undo stack, serializer) that must be fresh. */ private restoreFromCheckpoint(savedState: FeudalismSession): void { - this.session = savedState; + // Mutate existing session in-place so the existing renderer and animator + // (which hold references to this.session) see the restored state. + // This avoids creating duplicate containers / game objects. + Object.assign(this.session, savedState); + this.aiPlayer = new FeudalismAiPlayer(GreedyStrategy); this.recorder = new FeudalismTranscriptRecorder(this.session); - this.feudRenderer = new FeudalismRenderer(this, this.session); - this.animator = new FeudalismAnimator(this, this.session); + // Reuse existing renderer and animator — their containers and text objects + // are already in the scene's display list. refreshAll() calls removeAll(true) + // on each container before re-populating, so no orphaned objects remain. + // Do NOT call createContainers / createInstructions / createInfluenceDisplay. const checkpointManager = new CheckpointManager( this.saveLoadStore, @@ -501,23 +516,26 @@ export class FeudalismScene extends CardGameScene { this.turnController.setRecorder(this.recorder); - this.feudRenderer.createContainers(); - this.feudRenderer.createInstructions(); - this.feudRenderer.createInfluenceDisplay(); + // Clear old selection state and re-render from the mutated session. + // refreshAll() (Scene method) calls feudRenderer.refreshAll() which + // calls removeAll(true) on each container, clearing children before + // re-creating them from the restored session data. + this.feudRenderer.clearMarketSelection(); this.refreshAll(); - this.turnController.setPhase('player-turn'); - // Wire up interactions for the restored state - this.feudRenderer.refreshAll({ - onMarketCardClick: (card) => this.onMarketCardClick(card), - onReserveDeck: (tier) => this.onReserveDeck(tier), - onSupplyTokenClick: (color) => this.onSupplyTokenClick(color), - onTakeTokens: () => this.onTakeTokens(), - onTakeSame: (color) => this.turnController.executeTakeSame(color), - onConfirmDifferent: () => this.onConfirmDifferent(), - onCancelSelection: () => this.onCancelSelection(), - onReservedCardClick: (card) => this.onReservedCardClick(card), - }); + // Determine whose turn it is and set the phase accordingly. + // Previously this unconditionally set 'player-turn', which caused the + // bug where resuming into the AI's turn allowed human interaction. + // See CG-0MQZYDCMY007DHTI. + const currentPlayer = this.session.players[this.session.currentPlayerIndex]; + if (currentPlayer.isAI) { + this.turnController.setPhase('ai-turn'); + this.time.delayedCall(ANIM_DURATION + 200, () => { + this.turnController.executeAiTurn(); + }); + } else { + this.turnController.setPhase('player-turn'); + } } // ── Cleanup ───────────────────────────────────────────── diff --git a/example-games/golf/AiStrategy.ts b/example-games/golf/AiStrategy.ts index 8224e7cc..d9966199 100644 --- a/example-games/golf/AiStrategy.ts +++ b/example-games/golf/AiStrategy.ts @@ -25,6 +25,7 @@ import type { GolfAction, } from './GolfGame'; import { enumerateAiLegalMoves, enumerateAiDrawSources } from './GolfGame'; +import { GRID_ROWS, GRID_COLS } from './GolfGrid'; import type { AiStrategyBase } from '../../src/ai'; import { AiPlayer as AiPlayerBase, pickRandom, pickBest } from '../../src/ai'; @@ -125,11 +126,14 @@ export const GreedyStrategy: AiStrategy = { const drawSource = chooseDrawSource(playerState, shared, rng); if (drawSource === 'discard' && shared.discardTop) { + // Compute visible rank counts for column-feasibility weighting + const visibleRanks = countVisibleRanks(playerState, shared); // We know the discard card — evaluate moves with it const move = chooseMoveForCard( playerState.grid, shared.discardTop, rng, + visibleRanks, ); return { drawSource, move }; } @@ -201,10 +205,155 @@ export function chooseDrawSource( return 'discard'; } + // Even if discard doesn't immediately improve the score, check if it + // helps build a column match and unknown copies of that rank remain. + const visibleRanks = countVisibleRanks(playerState, shared); + + // Check if any legal swap move with the discard card would build toward + // a column match (2 matching cards in column) with feasible potential + for (const move of legalMoves) { + const bonus = computeColumnBonus( + playerState.grid, + discardCard, + move, + visibleRanks, + ); + if (bonus < 0) { + // Discard card helps build a column with feasible potential + return 'discard'; + } + } + // Discard card doesn't help — draw from stock (unknown, might be better) return 'stock'; } +// ── Visible rank counting (for column-feasibility weighting) ── + +/** + * Maximum copies of any rank in a standard 52-card deck. + */ +const MAX_RANK_COPIES = 4; + +/** + * 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. + */ +const COLUMN_BONUS_WEIGHT = 2; + +/** + * Count how many instances of each card rank are visible to the AI. + * + * Visible cards include: + * - Face-up cards in the AI's own grid + * - The discard pile top card (visible to all players) + * + * Face-down cards are NOT counted (the AI doesn't know their ranks). + * + * The counts are used by the GreedyStrategy to determine whether + * pursuing a column match is feasible: if all 4 copies of a rank + * are already visible, no more unknown copies remain, so trying + * to complete a column of that rank is futile. + * + * Information boundary: uses only AI-visible state projections. + * + * @param playerState AI-visible per-player state + * @param shared AI-visible shared state (discard top, stock flag) + * @returns Record mapping rank strings to their visible count + */ +export function countVisibleRanks( + playerState: AiVisiblePlayerState, + shared: AiVisibleSharedState, +): Record { + const counts: Record = {}; + + // Count face-up cards in the AI's own grid + for (const slot of playerState.grid) { + if (slot.faceUp && 'rank' in slot) { + const rank = (slot as Card).rank; + counts[rank] = (counts[rank] || 0) + 1; + } + } + + // Count the discard top card (visible to all players) + if (shared.discardTop && 'rank' in shared.discardTop) { + const rank = shared.discardTop.rank; + counts[rank] = (counts[rank] || 0) + 1; + } + + return counts; +} + +/** + * Compute a column-building feasibility 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. + * + * 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). + * + * @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 + * @returns A negative score adjustment (better) or 0 if no bonus applies + */ +export function computeColumnBonus( + grid: AiVisibleGrid, + drawnCard: Card, + move: GolfMove, + visibleRanks: Record, +): number { + // Only swap moves can contribute to column matches + if (move.kind === 'discard-and-flip') return 0; + + const col = move.col; + const idx = move.row * GRID_COLS + move.col; + const matchingRank = drawnCard.rank; + let matchingCount = 1; // The drawn card itself + let unknownInColumn = 0; + + for (let row = 0; row < GRID_ROWS; row++) { + const flatIdx = row * GRID_COLS + col; + if (flatIdx === idx) continue; + + const slot = grid[flatIdx]; + if ( + slot.faceUp && + 'rank' in slot && + (slot as Card).rank === matchingRank + ) { + matchingCount++; + } + if (!slot.faceUp) { + unknownInColumn++; + } + } + + // 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) { + const visibleCount = visibleRanks[matchingRank] || 0; + 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; + } + + return 0; +} + /** * Phase 2: Given a drawn card (now known), choose the best move. * @@ -213,24 +362,39 @@ export function chooseDrawSource( * - Discard-and-flip discards the drawn card and flips a face-down * card (whose value is unknown, estimated as the average). * + * When {@link visibleRanks} data is provided, a column-building + * feasibility bonus is applied: moves that build toward a column + * match get a score bonus (lower score) proportional to remaining + * unknown copies of the target rank. If all 4 copies are already + * visible, no bonus is applied (pursuit is futile). + * * Picks the move that minimizes the resulting score. Ties are broken * randomly. + * + * @param visibleRanks Optional. Count of visible instances per rank. + * When provided, enables column-feasibility weighting. */ export function chooseMoveForCard( grid: AiVisibleGrid, drawnCard: Card, rng: () => number, + visibleRanks?: Record, ): GolfMove { const legalMoves = enumerateAiLegalMoves(grid); if (legalMoves.length === 0) { throw new Error('No legal moves available'); } - // Score each legal move - const scored = legalMoves.map((move) => ({ - move, - score: simulateAiMoveScore(grid, drawnCard, move), - })); + // Score each legal move with optional column bonus + const scored = legalMoves.map((move) => { + let score = simulateAiMoveScore(grid, drawnCard, move); + + if (visibleRanks) { + score += computeColumnBonus(grid, drawnCard, move, visibleRanks); + } + + return { move, score }; + }); // Pick the best (lowest score), breaking ties randomly const best = pickBest(scored, (c) => -c.score, rng); @@ -273,8 +437,15 @@ export class AiPlayer extends AiPlayerBase { /** * Phase 2: Given a drawn card, choose the best move. * Used by the scene after the actual draw for stock draws. + * + * @param visibleRanks Optional. When provided, enables column-feasibility + * weighting in move selection. */ - chooseMoveForCard(grid: AiVisibleGrid, drawnCard: Card): GolfMove { - return chooseMoveForCard(grid, drawnCard, this.rng); + chooseMoveForCard( + grid: AiVisibleGrid, + drawnCard: Card, + visibleRanks?: Record, + ): GolfMove { + return chooseMoveForCard(grid, drawnCard, this.rng, visibleRanks); } } diff --git a/example-games/golf/scenes/GolfAnimator.ts b/example-games/golf/scenes/GolfAnimator.ts index d3ea58d4..9dbd550b 100644 --- a/example-games/golf/scenes/GolfAnimator.ts +++ b/example-games/golf/scenes/GolfAnimator.ts @@ -15,6 +15,9 @@ import type { GolfRenderer } from './GolfRenderer'; import type { GolfSession } from '../GolfGame'; export class GolfAnimator { + /** When true, all animations are skipped and sprites snap to final state. */ + reducedMotion = false; + constructor( private scene: Phaser.Scene, private session: GolfSession, @@ -31,6 +34,13 @@ export class GolfAnimator { drawnCard: Card | null, onComplete: () => void, ): void { + // When reduced motion is enabled, skip all animations + if (this.reducedMotion) { + this.renderer.hideDrawnCard(); + onComplete(); + return; + } + const playerKey = result.playerIndex === 0 ? 'human' : 'ai'; const sprites = playerKey === 'human' ? this.renderer.humanCardSprites @@ -163,6 +173,18 @@ export class GolfAnimator { // ── Drawn card display ────────────────────────────────── showDrawnCard(card: Card, source: 'stock' | 'discard' = 'stock'): void { + if (this.reducedMotion) { + // Just create the sprite at the destination without animation + const faceTexture = cardTextureKey(card.rank, card.suit); + const destX = this.layout.discardPileCenterX + GOLF_CARD_W * 3 / 4 + 33; + const destY = this.layout.discardPileCenterY; + const sprite = this.scene.add.image(destX, destY, faceTexture); + sprite.setDepth(0); + this.renderer.setDrawnCardSprite(sprite); + this.renderer.turnText.setText(`Drew: ${card.rank} of ${card.suit}`); + return; + } + // Destination: to the right of the discard pile, between piles and AI grid. // Original position (discardPileCenterX + GOLF_CARD_W + 24) was too far right. // Moved left by half the distance from the deck right edge, but that was too far. @@ -265,6 +287,17 @@ export class GolfAnimator { return; } + if (this.reducedMotion) { + this.renderer.hideDrawnCard(); + if (drawnCard) { + this.renderer.discardSprite.setTexture(cardTextureKey(drawnCard.rank, drawnCard.suit)); + this.renderer.discardSprite.setAlpha(1); + this.renderer.discardSprite.setVisible(true); + } + onComplete(); + return; + } + drawnCardSprite.setDepth(15); let lastMove = 0; diff --git a/example-games/golf/scenes/GolfRenderer.ts b/example-games/golf/scenes/GolfRenderer.ts index 022888cd..e6d35359 100644 --- a/example-games/golf/scenes/GolfRenderer.ts +++ b/example-games/golf/scenes/GolfRenderer.ts @@ -10,7 +10,7 @@ import { scoreVisibleCards, scoreGrid } from '../GolfScoring'; import type { Card } from '../../../src/card-system/Card'; import type { GolfSession } from '../GolfGame'; import { GAME_W, GAME_H } from '../../../src/ui'; -import { createSceneTitle, createSceneMenuButton } from '@ui/Renderer'; +import { createSceneTitle } from '@ui/Renderer'; import { PileView } from '../../../src/ui/PileView'; import { createGolfHudText, @@ -76,11 +76,6 @@ export class GolfRenderer { // ── UI creation ───────────────────────────────────────── createLabels(): void { - // Menu button (top-left) -- returns to game selector - if (!this.replayMode) { - createSceneMenuButton(this.scene); - } - createSceneTitle(this.scene, '9-Card Golf'); // Player labels above each grid diff --git a/example-games/golf/scenes/GolfScene.ts b/example-games/golf/scenes/GolfScene.ts index 85152689..faee5557 100644 --- a/example-games/golf/scenes/GolfScene.ts +++ b/example-games/golf/scenes/GolfScene.ts @@ -135,17 +135,12 @@ export class GolfScene extends CardGameScene { this.drawnCard = null; this.drawSource = null; - // Check for replay mode via URL parameter (?mode=replay) - this.detectReplayMode(); + super.create(); // Select AI strategy const strategy: AiStrategy = this.aiStrategyName === 'random' ? RandomStrategy : GreedyStrategy; - // Event system: create emitter and bridge to Phaser scene events - this.initEventSystem(); - this.initHUDContainer(); - // Sound system: wrap Phaser's sound manager as a SoundPlayer if (!this.replayMode) { const mapping: EventSoundMapping = { @@ -235,6 +230,10 @@ export class GolfScene extends CardGameScene { if (!this.replayMode) { this.initHelpPanel(helpContent as HelpSection[]); this.initSettingsPanel(); + // Propagate reduced motion preference to the animator + if (this.settingsPanel) { + this.animator.reducedMotion = this.settingsPanel.reducedMotion; + } } // Initial render diff --git a/example-games/golf/scenes/GolfSceneHelpers.ts b/example-games/golf/scenes/GolfSceneHelpers.ts index a344b9e1..99c2d800 100644 --- a/example-games/golf/scenes/GolfSceneHelpers.ts +++ b/example-games/golf/scenes/GolfSceneHelpers.ts @@ -9,7 +9,6 @@ import { GAME_W, GAME_H, OverlayManager } from '../../../src/ui'; import { createActionButton } from '@ui/Renderer'; import { createGolfHudText, - createGolfMenuButton, } from '../../../src/ui/Renderer/adapters/GolfAdapter'; import { SFX_KEYS } from './GolfConstants'; import type { GolfSession } from '../GolfGame'; @@ -111,12 +110,6 @@ export class GolfOverlayHelper { ); this.overlayManager.add(playBtn); - // Menu button - const menuBtn = createGolfMenuButton(this.scene, GAME_W / 2 + 85, GAME_H / 2 + 85, 80, { - depth: 11, - }); - this.overlayManager.add(menuBtn); - // Export Transcript button const exportBtn = createActionButton( this.scene, diff --git a/example-games/gym/layouts/gym-scene.layout.json b/example-games/gym/layouts/gym-scene.layout.json index 3c208594..e200c568 100644 --- a/example-games/gym/layouts/gym-scene.layout.json +++ b/example-games/gym/layouts/gym-scene.layout.json @@ -30,10 +30,10 @@ }, "action": { "x": 0.5, - "y": 0.175, + "y": 0.27, "pixelOverride": { "x": 580, - "y": 126 + "y": 195 } } } diff --git a/example-games/gym/scenes/GymAudioFeedbackScene.ts b/example-games/gym/scenes/GymAudioFeedbackScene.ts index 3b9c1b51..13d166fe 100644 --- a/example-games/gym/scenes/GymAudioFeedbackScene.ts +++ b/example-games/gym/scenes/GymAudioFeedbackScene.ts @@ -201,17 +201,21 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.logCall('Pop text triggered (reduced-motion: ' + this.reducedMotion + ')'); } - private showPopText(eventName: string, sfxKey: string): void { - const displayText = `${eventName}: ${sfxKey}`; + private showPopText(eventName: string, _sfxKey: string): void { + // Display a music note icon with the event name as visual feedback + // that a sound was triggered. The sound key is omitted since it's + // internal debug info — the event name is sufficient to indicate + // which action produced the sound. + const displayText = `♪ ${eventName}`; popTextOrIcon({ scene: this, label: displayText, x: GAME_W / 2 + (Math.random() - 0.5) * 100, y: 220, - duration: this.reducedMotion ? 100 : 400, + duration: this.reducedMotion ? 500 : 1800, reducedMotion: this.reducedMotion, style: { - fontSize: '14px', + fontSize: '20px', color: '#88ff88', fontFamily: 'monospace', }, diff --git a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts index 37ef8ff6..0be0e413 100644 --- a/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts @@ -27,6 +27,8 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { private eventLogResult!: EventLogResult; private lightingAvailable = false; private lightActive = true; + /** Reference to the LightsManager Light object created via addLight(). */ + private light: Phaser.GameObjects.Light | null = null; constructor() { super({ key: GYM_GRAPHICS_LIGHTING_SPIKE_KEY }); @@ -75,18 +77,21 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { this.lightingAvailable = true; this.logEvent('WebGL renderer detected. Lighting may be available.'); - // Create sprites for the lit scene - const spriteA = this.add.image(cx - 150, y + 120, 'lighting-sprite-a'); - const spriteB = this.add.image(cx + 150, y + 120, 'lighting-sprite-b'); - - // Try to set Light2D pipeline (may not be available in all builds) - try { (spriteA as any).setPipeline('Light2D'); } catch (_e) { /* pipeline not available */ } - try { (spriteB as any).setPipeline('Light2D'); } catch (_e) { /* pipeline not available */ } + // 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'); + spriteA.setLighting(true); + } catch (_e) { /* lighting component not available */ } + try { + const spriteB = this.add.image(cx + 150, y + 120, 'lighting-sprite-b'); + spriteB.setLighting(true); + } catch (_e) { /* lighting component not available */ } - // Try to add a point light + // Try to add a Light via the LightsManager try { - this.lights.enable().addLight(cx, y + 100, 300, 0xffffff, 1.0); - this.logEvent('Point light added successfully.'); + this.lights.enable(); + this.light = this.lights.addLight(cx, y + 100, 300, 0xffffff, 1.0); + this.logEvent('Light added successfully via LightsManager.'); } catch (e) { this.logEvent(`Light add error: ${(e as Error).message}`); this.lightingAvailable = false; @@ -138,8 +143,12 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { try { this.lightActive = !this.lightActive; - this.lights.enable(); - this.logEvent(`Light: ${this.lightActive ? 'ON' : 'OFF'}`); + if (this.light && this.light.intensity !== undefined) { + this.light.setIntensity(this.lightActive ? 1.0 : 0.0); + this.logEvent(`Light ${this.lightActive ? 'enabled' : 'disabled'} (intensity=${this.lightActive ? '1.0' : '0.0'}).`); + } else { + this.logEvent('No light reference available to toggle.'); + } } catch (e) { this.logEvent(`Light toggle error: ${(e as Error).message}`); } @@ -156,17 +165,13 @@ export class GymGraphicsLightingSpikeScene extends GymSceneBase { const cx = GAME_W / 2; const newX = cx + (Math.random() - 0.5) * 300; const newY = 160 + Math.random() * 200; - // Phaser 4 lights API: try to move any existing point lights - try { - const pointLights = this.lights.lights; - if (Array.isArray(pointLights) && pointLights.length > 0) { - pointLights[0].setPosition(newX, newY); - this.logEvent(`Light moved to (${Math.round(newX)}, ${Math.round(newY)})`); - } else { - this.logEvent('No point lights to move.'); - } - } catch (err) { - this.logEvent('Could not access light list for movement.'); + // Move the stored light reference directly + if (this.light) { + this.light.x = newX; + this.light.y = newY; + this.logEvent(`Light moved to (${Math.round(newX)}, ${Math.round(newY)})`); + } else { + this.logEvent('No light reference available to move.'); } } catch (e) { this.logEvent(`Move light error: ${(e as Error).message}`); diff --git a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts index 1783f882..94cdb5c3 100644 --- a/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts +++ b/example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts @@ -48,6 +48,7 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { private eventLogResult!: EventLogResult; private shaderAttempted = false; private shaderResult = ''; + private statusLineText!: Phaser.GameObjects.Text; constructor() { super({ key: GYM_GRAPHICS_SHADER_SPIKE_KEY }); @@ -116,7 +117,7 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { this.addButton(cx + 120, y, '[ Attempt Shader ]', () => this.attemptShader()); y += 30; - createHudText(this, cx, y, 'Blend: NORMAL | Tint: None', '#88ff88', { fontSize: '12px' }).setOrigin(0.5); + this.statusLineText = createHudText(this, cx, y, 'Blend: NORMAL | Tint: None', '#88ff88', { fontSize: '12px' }).setOrigin(0.5); y += 30; // Create sample sprites @@ -146,6 +147,7 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { for (const sprite of this.sprites) { sprite.setTint(tint.value); } + this.updateStatusLine(); this.logEvent(`Tint: ${tint.name} (0x${tint.value.toString(16)})`); } @@ -163,6 +165,7 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { for (const sprite of this.sprites) { sprite.setBlendMode(modeValue); } + this.updateStatusLine(); this.logEvent(`Blend: ${modeName}`); } @@ -171,9 +174,16 @@ export class GymGraphicsShaderSpikeScene extends GymSceneBase { for (const sprite of this.sprites) { sprite.clearTint(); } + this.updateStatusLine(); this.logEvent('Tint reset (none/white)'); } + private updateStatusLine(): void { + const modeName = BLEND_MODES[this.blendModeIndex]; + const tint = TINT_COLORS[this.tintColorIndex]; + this.statusLineText.setText(`Blend: ${modeName} | Tint: ${tint.name}`); + } + private attemptShader(): void { if (this.shaderAttempted) { this.logEvent('Shader already attempted. Result: ' + this.shaderResult); diff --git a/example-games/gym/scenes/GymHandPileScene.ts b/example-games/gym/scenes/GymHandPileScene.ts index 08f9609b..86b095b9 100644 --- a/example-games/gym/scenes/GymHandPileScene.ts +++ b/example-games/gym/scenes/GymHandPileScene.ts @@ -61,6 +61,13 @@ export class GymHandPileScene extends GymSceneBase { private highlightManager!: HighlightManager; // Active move tween reference (for cancellation) private activeMoveTween: Phaser.Tweens.Tween | null = null; + // State for tracking the moved card so Cancel Move can return it + private movedCardIndex: number = -1; + private movedCardOrigX: number = 0; + private movedCardOrigY: number = 0; + private cardMoved: boolean = false; + // Discard pile visual drop zone graphics + private discardZoneGraphics!: Phaser.GameObjects.Graphics; // Pile position constants — deck and discard on the right side private readonly DECK_X = GAME_W - 300; @@ -81,7 +88,7 @@ export class GymHandPileScene extends GymSceneBase { private readonly ROTATION_DEGREES_DEFAULT = 25; // Cascade / vertical layout state private readonly CASCADE_SPACING = 42; - private readonly CASCADE_X = 120; + private readonly CASCADE_X = GAME_W / 2; private readonly CASCADE_TOP_Y = 220; private isVerticalLayout = false; private layoutLabel!: Phaser.GameObjects.Text; @@ -129,6 +136,7 @@ export class GymHandPileScene extends GymSceneBase { if (idx >= 0 && idx < this.hand.length) { this.selectedIdx = idx; this.logEvent(`Selected card ${idx}: ${this.hand[idx].rank}${this.hand[idx].suit}`); + this.showDiscardZoneHighlight(); } }); @@ -173,7 +181,18 @@ export class GymHandPileScene extends GymSceneBase { y: this.PILE_Y, label: 'Discard', }); - this.discardView.onClick(() => this.recallFromDiscard()); + + // Create a visual drop zone indicator behind the discard pile + this.createDiscardZoneIndicator(); + + // Click handler: if a card is selected, discard it; otherwise recall from discard + this.discardView.onClick(() => { + if (this.selectedIdx >= 0 && this.selectedIdx < this.hand.length) { + this.discardSelected(); + } else { + this.recallFromDiscard(); + } + }); 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.' }, @@ -269,6 +288,13 @@ export class GymHandPileScene extends GymSceneBase { // Initialize highlight manager for drop-zone rendering this.highlightManager = new HighlightManager(this); + // Also wire selection change to update discard zone highlight + this.handView.on('selectionchange', (index: number | null) => { + if (index === null) { + this.hideDiscardZoneHighlight(); + } + }); + // Register shutdown lifecycle handler for explicit cleanup this.events.on('shutdown', this.shutdown, this); @@ -561,6 +587,12 @@ export class GymHandPileScene extends GymSceneBase { return; } + // Store original position and index so Cancel Move can return the card + this.movedCardIndex = this.selectedIdx; + this.movedCardOrigX = (sprite as any).x; + this.movedCardOrigY = (sprite as any).y; + this.cardMoved = true; + const destX = GAME_W / 2 + 200; const destY = 200; @@ -576,6 +608,7 @@ export class GymHandPileScene extends GymSceneBase { duration: 500, onComplete: () => { this.activeMoveTween = null; + // cardMoved remains true so Cancel Move can still return the card this.logEvent('Move completed (animated)'); }, }); @@ -583,10 +616,36 @@ export class GymHandPileScene extends GymSceneBase { } private cancelMove(): void { + // Stop any active move tween if (this.activeMoveTween) { this.activeMoveTween.stop(); this.activeMoveTween = null; - this.logEvent('Move cancelled'); + } + + // If a card was moved, return it to its original hand position + if (this.cardMoved && this.movedCardIndex >= 0) { + const sprite = this.handView.getSpriteAt(this.movedCardIndex); + if (sprite) { + if (this.reducedMotion) { + (sprite as any).setPosition(this.movedCardOrigX, this.movedCardOrigY); + this.logEvent('Move cancelled — card returned to hand (instant)'); + } else { + this.tweens.add({ + targets: sprite as any, + x: this.movedCardOrigX, + y: this.movedCardOrigY, + duration: 250, + ease: 'Quad.easeOut', + onComplete: () => { + this.logEvent('Move cancelled — card returned to hand'); + }, + }); + } + } + + // Clear the moved state + this.cardMoved = false; + this.movedCardIndex = -1; } else { this.logEvent('No active move to cancel'); } @@ -706,6 +765,65 @@ export class GymHandPileScene extends GymSceneBase { this.highlightManager.clearAll(); } + // ── Discard pile visual zone ───────────────────────────── + + /** + * Create a persistent visual drop zone indicator behind the discard pile. + * + * Draws a semi-transparent rounded rectangle that serves as a visual + * cue for where the discard pile is located, making it clear that + * clicking here performs a discard action. + */ + private createDiscardZoneIndicator(): void { + this.discardZoneGraphics = this.add.graphics(); + + // Zone slightly larger than a card for a generous drop target + const zoneW = CARD_W + 24; + const zoneH = CARD_H + 24; + const zoneX = this.DISCARD_X - zoneW / 2; + const zoneY = this.PILE_Y - zoneH / 2; + + // Default subtle outline (shown when no card is selected) + this.discardZoneGraphics.lineStyle(2, 0x88aa88, 0.5); + this.discardZoneGraphics.fillStyle(0x335533, 0.15); + this.discardZoneGraphics.fillRoundedRect(zoneX, zoneY, zoneW, zoneH, 10); + this.discardZoneGraphics.strokeRoundedRect(zoneX, zoneY, zoneW, zoneH, 10); + + // Move the discard pile sprite in front of the zone + this.discardView.getSprite().setDepth(1); + this.discardView.getCountText().setDepth(1); + } + + /** + * Show a green highlight on the discard drop zone when a card + * is selected, indicating it can be clicked to discard. + */ + private showDiscardZoneHighlight(): void { + const highlightW = CARD_W + 24; + const highlightH = CARD_H + 24; + const zoneX = this.DISCARD_X - highlightW / 2; + const zoneY = this.PILE_Y - highlightH / 2; + + this.highlightManager.addZone('discard-click-target', { + x: zoneX, + y: zoneY, + w: highlightW, + h: highlightH, + style: 'fill', + color: 0x44ff44, + alpha: 0.25, + strokeColor: 0x44ff44, + strokeWidth: 3, + }); + } + + /** + * Remove the discard zone highlight when the card selection is cleared. + */ + private hideDiscardZoneHighlight(): void { + this.highlightManager.removeZone('discard-click-target'); + } + // ── Drag-and-drop demo helpers ────────────────────────── /** Toggle drag-and-drop mode on/off. */ @@ -822,6 +940,9 @@ export class GymHandPileScene extends GymSceneBase { // Destroy highlight manager try { this.highlightManager?.destroy(); } catch (_) { /* ignore */ } + // Destroy discard zone graphics + try { this.discardZoneGraphics?.destroy(); } catch (_) { /* ignore */ } + // Destroy sliders (each has a built-in destroy() that cleans up // sub-objects — track, fill, handle, valueText, hitArea — and // removes any self-registered pointermove/pointerup listeners) diff --git a/example-games/gym/scenes/GymHudComponentsScene.ts b/example-games/gym/scenes/GymHudComponentsScene.ts index 7da65112..b18ca0f8 100644 --- a/example-games/gym/scenes/GymHudComponentsScene.ts +++ b/example-games/gym/scenes/GymHudComponentsScene.ts @@ -21,6 +21,7 @@ import { SettingsButton } from '../../../src/ui/SettingsButton'; import { createHudText } from '../../../src/ui/Renderer'; import { createEventLog } from '../../../src/ui/GymSceneUtils'; import type { EventLogResult } from '../../../src/ui/GymSceneUtils'; +import type Phaser from 'phaser'; // ── Mock SoundManager for SettingsPanel demo ───────────────── @@ -81,6 +82,10 @@ export class GymHudComponentsScene extends GymSceneBase { private eventLog: string[] = []; private readonly mockSound = new MockSoundManager(); + // Status line text references for update on panel toggle + private helpStatusText!: Phaser.GameObjects.Text; + private settingsStatusText!: Phaser.GameObjects.Text; + // Track panel visibility manually since HelpPanel/SettingsPanel // do not expose a public isOpen property. private _helpOpen = false; @@ -116,16 +121,19 @@ export class GymHudComponentsScene extends GymSceneBase { this.addButton(cx - 250, y, '[ Open HelpPanel ]', () => { this.helpPanel!.open(); this._helpOpen = true; + this.updateStatusLines(); this.logEvent('HelpPanel: open() called'); }); this.addButton(cx - 110, y, '[ Close HelpPanel ]', () => { this.helpPanel!.close(); this._helpOpen = false; + this.updateStatusLines(); this.logEvent('HelpPanel: close() called'); }); this.addButton(cx + 30, y, '[ Toggle HelpPanel ]', () => { this.helpPanel!.toggle(); this._helpOpen = !this._helpOpen; + this.updateStatusLines(); this.logEvent(`HelpPanel: toggle() → ${this._helpOpen ? 'open' : 'closed'}`); }); @@ -134,26 +142,29 @@ export class GymHudComponentsScene extends GymSceneBase { this.addButton(cx - 130, y, '[ Open Settings ]', () => { this.settingsPanel.open(); this._settingsOpen = true; + this.updateStatusLines(); this.logEvent('SettingsPanel: open() called'); }); this.addButton(cx + 10, y, '[ Close Settings ]', () => { this.settingsPanel.close(); this._settingsOpen = false; + this.updateStatusLines(); this.logEvent('SettingsPanel: close() called'); }); this.addButton(cx + 150, y, '[ Toggle Settings ]', () => { this.settingsPanel.toggle(); this._settingsOpen = !this._settingsOpen; + this.updateStatusLines(); this.logEvent(`SettingsPanel: toggle() → ${this._settingsOpen ? 'open' : 'closed'}`); }); // ── Panel state indicators ────────────────────────── y += 36; - createHudText( - this, 60, y, 'HelpPanel: closed', '#88ff88', { fontSize: '14px' }, + this.helpStatusText = createHudText( + this, 460, y, 'HelpPanel: closed', '#88ff88', { fontSize: '14px' }, ); - createHudText( + this.settingsStatusText = createHudText( this, 440, y, 'SettingsPanel: closed', '#ffcc44', { fontSize: '14px' }, ); @@ -242,6 +253,15 @@ export class GymHudComponentsScene extends GymSceneBase { return this._settingsOpen; } + /** + * Update both panel status line text objects to reflect + * the current open/closed state of each panel. + */ + private updateStatusLines(): void { + this.helpStatusText.setText(`HelpPanel: ${this._helpOpen ? 'open' : 'closed'}`); + this.settingsStatusText.setText(`SettingsPanel: ${this._settingsOpen ? 'open' : 'closed'}`); + } + private logEvent(msg: string): void { this.eventLog.push(msg); if (this.eventLog.length > 12) this.eventLog.shift(); diff --git a/example-games/gym/scenes/GymSaveLoadScene.ts b/example-games/gym/scenes/GymSaveLoadScene.ts index 62e2f7b8..8032a0e4 100644 --- a/example-games/gym/scenes/GymSaveLoadScene.ts +++ b/example-games/gym/scenes/GymSaveLoadScene.ts @@ -73,10 +73,11 @@ const HAND_SPACING = 74; const HAND_ARC_RADIUS = 350; /* - * HandView baseX is computed at runtime so the full hand is centred. + * HandView baseX is the horizontal centre of the hand. * baseY is the Y centre of the first card; we place it a card-height * below the previous position. */ +const HAND_BASE_X = GAME_W / 2; const HAND_BASE_Y = GAME_H * 0.65 + CARD_H; // ~598 /** Full-screen RenderTexture for the screenshot, displayed at this scale. */ @@ -144,11 +145,9 @@ export class GymSaveLoadScene extends GymSceneBase { this.store = new SaveLoadStore({ dbName: 'gym-save-load', localStoragePrefix: 'gym-sl' }); // ── HandView (lower centre, arc, full-size cards) ───────── - const handWidth = (STARTING_HAND_SIZE - 1) * HAND_SPACING; - const handBaseX = GAME_W / 2 - handWidth / 2; this.handView = new HandView(this, { - baseX: handBaseX, + baseX: HAND_BASE_X, baseY: HAND_BASE_Y, spacing: HAND_SPACING, cardWidth: CARD_W, diff --git a/example-games/gym/scenes/GymSllScene.ts b/example-games/gym/scenes/GymSllScene.ts index 92efae63..eee4dc58 100644 --- a/example-games/gym/scenes/GymSllScene.ts +++ b/example-games/gym/scenes/GymSllScene.ts @@ -124,8 +124,6 @@ const LAYOUT_PROFILES: LayoutProfile[] = [ }, ]; -const OVERLAY_COLORS = [0x66ddff, 0x66ff99, 0xffcc66, 0xff8899, 0xd9a5ff, 0x99ffdd]; - const SHELL_ONLY_PLACEMENT: PlacementMapping = { title: { zone: 'shell', anchor: 'title' }, help: { zone: 'shell', anchor: 'help' }, @@ -156,11 +154,9 @@ const COMPOSED_PLACEMENT: PlacementMapping = { export class GymSllScene extends GymSceneBase { private layouts: LayoutOption[] = []; private layoutIndex = 0; - private profileIndex = 0; private overlayVisible = false; - private shellVisible = false; + private shellVisible = true; - private layoutButton!: Phaser.GameObjects.Text; private profileButton!: Phaser.GameObjects.Text; private overlayButton!: Phaser.GameObjects.Text; private shellToggleButton!: Phaser.GameObjects.Text; @@ -195,7 +191,7 @@ export class GymSllScene extends GymSceneBase { { heading: 'Controls', body: - '[ Layout ] starts on the composed shell + scene example, then cycles through the shell-only example, the scene-only layout, and the pixel override layout. [ Toggle Shell ] hides or restores the shared shell chrome without changing the selected layout. [ Profile ] simulates viewport + DPR combinations. [ Overlay ] toggles zone and anchor debug visualization.', + '[ 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.', }, { heading: 'Notes', @@ -326,12 +322,7 @@ export class GymSllScene extends GymSceneBase { private createControlRow(): void { const y1 = 58; const y2 = 82; - this.layoutButton = this.addButton(28, y1, '[ Layout ]', () => this.cycleLayout(), { - fontSize: '13px', - color: '#88ffcc', - }); - - this.profileButton = this.addButton(320, y1, '[ Profile ]', () => this.cycleProfile(), { + this.profileButton = this.addButton(28, y1, '[ Profile ]', () => this.cycleProfile(), { fontSize: '13px', color: '#88ddff', }); @@ -392,13 +383,8 @@ export class GymSllScene extends GymSceneBase { .setDepth(35); } - private cycleLayout(): void { - this.layoutIndex = (this.layoutIndex + 1) % this.layouts.length; - this.applyLayout(); - } - private cycleProfile(): void { - this.profileIndex = (this.profileIndex + 1) % LAYOUT_PROFILES.length; + this.layoutIndex = (this.layoutIndex + 1) % this.layouts.length; this.applyLayout(); } @@ -457,7 +443,6 @@ export class GymSllScene extends GymSceneBase { this.visibilityController.register(this.helpButton, 'shell'); } - this.visibilityController.register(this.layoutButton, 'shell'); this.visibilityController.register(this.profileButton, 'shell'); this.visibilityController.register(this.overlayButton, 'shell'); this.visibilityController.register(this.statusLine, 'shell'); @@ -487,7 +472,7 @@ export class GymSllScene extends GymSceneBase { private applyLayout(): void { const currentLayout = this.layouts[this.layoutIndex]!; - const currentProfile = LAYOUT_PROFILES[this.profileIndex]!; + const currentProfile = LAYOUT_PROFILES[0]!; const resolved = currentLayout.kind === 'direct' @@ -510,12 +495,6 @@ export class GymSllScene extends GymSceneBase { y: point.y * previewScaleY, }); - // Zones are position-only; overlay shows anchors, not zone rectangles. - const _toDisplayRect = (rect: PixelPoint): PixelPoint => ({ - x: rect.x * previewScaleX, - y: rect.y * previewScaleY, - }); - const titleAnchorPx = this.getAnchorPoint(resolved, currentLayout.placement.title); const helpAnchorPx = this.getAnchorPoint(resolved, currentLayout.placement.help); const actionAnchorPx = this.getAnchorPoint(resolved, currentLayout.placement.action); @@ -548,16 +527,36 @@ export class GymSllScene extends GymSceneBase { this.contentLabel.setVisible(false); } - this.redrawOverlay(resolved, _toDisplayRect, toDisplayPoint); + // Collect element positions for overlay visualization + const elementColors = [0x66ddff, 0x66ff99, 0xffcc66, 0xff8899]; + const elementPositions: Array<{ + name: string; + pixel: PixelPoint; + display: PixelPoint; + color: number; + }> = [ + { name: 'Title', pixel: titleAnchorPx, display: titleAnchorDisplay, color: elementColors[0] }, + { name: 'Help', pixel: helpAnchorPx, display: helpAnchorDisplay, color: elementColors[1] }, + { name: 'Action', pixel: actionAnchorPx, display: actionAnchorDisplay, color: elementColors[2] }, + ]; - this.layoutButton.setText( - `[ Layout: ${currentLayout.kind === 'composed' ? 'Shell+Scene' : currentLayout.name} ]`, - ); - this.profileButton.setText(`[ Profile: ${currentProfile.id} ]`); + if (currentLayout.showContent && contentPlacement) { + const contentPx = this.getAnchorPoint(resolved, contentPlacement); + const contentDisplay = toDisplayPoint(contentPx); + elementPositions.push({ + name: 'Content', + pixel: contentPx, + display: contentDisplay, + color: elementColors[3], + }); + } + + this.redrawOverlay(elementPositions); + + const layoutLabel = currentLayout.kind === 'composed' ? 'Shell+Scene' : currentLayout.name; + this.profileButton.setText(`[ Profile: ${layoutLabel} ]`); this.statusLine.setText( - currentLayout.kind === 'composed' - ? `Composed shell + scene | ${currentProfile.label} | previewScale x${previewScaleX.toFixed(3)} y${previewScaleY.toFixed(3)}` - : `${currentLayout.name} | ${currentProfile.label} | previewScale x${previewScaleX.toFixed(3)} y${previewScaleY.toFixed(3)}`, + `${layoutLabel} | ${currentProfile.label} | previewScale x${previewScaleX.toFixed(3)} y${previewScaleY.toFixed(3)}`, ); this.publishReadyMarker({ @@ -579,9 +578,12 @@ export class GymSllScene extends GymSceneBase { } private redrawOverlay( - resolved: ReturnType, - toDisplayPointFn: (point: PixelPoint) => PixelPoint, - toDisplayPoint: (point: PixelPoint) => PixelPoint, + elementPositions: Array<{ + name: string; + pixel: PixelPoint; + display: PixelPoint; + color: number; + }>, ): void { this.overlayGraphics.clear(); for (const label of this.overlayLabels) label.destroy(); @@ -591,36 +593,28 @@ export class GymSllScene extends GymSceneBase { return; } - let colorIndex = 0; const legendLines: string[] = ['Overlay legend']; + const separator = '─'.repeat(36); - for (const [zoneName, zone] of Object.entries(resolved.zones)) { - const color = OVERLAY_COLORS[colorIndex % OVERLAY_COLORS.length]; - colorIndex += 1; - - const pixelPos = zone.rect; - const displayPos = toDisplayPointFn(pixelPos); - - // Zones are position-only; show anchor points but not zone rectangles - this.overlayGraphics.lineStyle(2, color, 0.95); - this.overlayGraphics.strokeCircle(displayPos.x, displayPos.y, 6); + for (const elem of elementPositions) { + // Draw a dot at the element's display position + this.overlayGraphics.fillStyle(elem.color, 0.95); + this.overlayGraphics.fillCircle(elem.display.x, elem.display.y, 5); + this.overlayGraphics.lineStyle(1.5, elem.color, 0.8); + this.overlayGraphics.strokeCircle(elem.display.x, elem.display.y, 8); legendLines.push( - `${zoneName}: [${pixelPos.x.toFixed(0)}, ${pixelPos.y.toFixed(0)}]`, + `${elem.name}: (${elem.pixel.x.toFixed(0)}, ${elem.pixel.y.toFixed(0)})`, ); - - for (const [anchorName, anchorPixel] of Object.entries(zone.anchors)) { - const anchorDisplay = toDisplayPoint(anchorPixel); - - this.overlayGraphics.fillStyle(color, 0.95); - this.overlayGraphics.fillCircle(anchorDisplay.x, anchorDisplay.y, 4); - - legendLines.push( - ` ${anchorName}: (${anchorPixel.x.toFixed(0)}, ${anchorPixel.y.toFixed(0)})`, - ); - } } + legendLines.push(''); + legendLines.push(separator); + legendLines.push('Positions shown are the pixel'); + legendLines.push('coordinates of each placed element'); + legendLines.push('as determined by the current SLL'); + legendLines.push('layout and placement mapping.'); + const legendPanelX = 864; const legendPanelY = 122; const legendPanelWidth = 392; diff --git a/example-games/lost-cities/scenes/LostCitiesAnimator.ts b/example-games/lost-cities/scenes/LostCitiesAnimator.ts index ebc4dd47..3b877331 100644 --- a/example-games/lost-cities/scenes/LostCitiesAnimator.ts +++ b/example-games/lost-cities/scenes/LostCitiesAnimator.ts @@ -32,6 +32,9 @@ import { LostCitiesRenderer } from './LostCitiesRenderer'; import { flipCard, moveGameObject, shakeIllegalMove, FONT_FAMILY } from '../../../src/ui'; export class LostCitiesAnimator { + /** When true, all animations are skipped and sprites snap to final state. */ + reducedMotion = false; + private scene: Phaser.Scene; private session: LostCitiesSession; private renderer: LostCitiesRenderer; @@ -47,6 +50,10 @@ export class LostCitiesAnimator { } animatePhase1(action: Phase1Action, handIndex: number, onComplete: () => void): void { + if (this.reducedMotion) { + onComplete(); + return; + } const handSprites = this.renderer.handSpriteList; if (handSprites.length === 0 || handIndex < 0 || handIndex >= handSprites.length) { onComplete(); @@ -92,6 +99,10 @@ export class LostCitiesAnimator { } animatePhase2(action: Phase2Action, onComplete: () => void): void { + if (this.reducedMotion) { + onComplete(); + return; + } let sourceX: number; let sourceY: number; let textureKey: string; @@ -143,6 +154,10 @@ export class LostCitiesAnimator { } animateAiPhase1(action: Phase1Action, onComplete: () => void): void { + if (this.reducedMotion) { + onComplete(); + return; + } const sprites = this.renderer.aiHandSpriteList; if (sprites.length === 0) { onComplete(); @@ -230,6 +245,7 @@ export class LostCitiesAnimator { destX: AI_HAND_CENTER, destY: newY, duration: 200, + reducedMotion: this.reducedMotion, }); } sprites[i].setDepth(i + 1); @@ -237,6 +253,10 @@ export class LostCitiesAnimator { } animateAiPhase2(action: Phase2Action, onComplete: () => void): void { + if (this.reducedMotion) { + onComplete(); + return; + } let sourceX: number; let sourceY: number; let annotationText: string; @@ -294,6 +314,7 @@ export class LostCitiesAnimator { destX: targetX, destY: targetY, duration: AI_ANIM_DURATION, + reducedMotion: this.reducedMotion, onComplete: () => { tempSprite.destroy(); onComplete(); diff --git a/example-games/lost-cities/scenes/LostCitiesOverlays.ts b/example-games/lost-cities/scenes/LostCitiesOverlays.ts index ce8b2603..3fa7e995 100644 --- a/example-games/lost-cities/scenes/LostCitiesOverlays.ts +++ b/example-games/lost-cities/scenes/LostCitiesOverlays.ts @@ -9,14 +9,20 @@ import { autoSaveTranscript, TranscriptStore } from '../../../src/core-engine/tr import { GAME_W, GAME_H, OverlayManager } from '../../../src/ui'; import { createLcHudText, - createOverlayButton, - createLcMenuButton, + createActionButton, } from '../../../src/ui/Renderer/adapters/LostCitiesAdapter'; import { SFX_KEYS } from './LostCitiesConstants'; import type { LCTranscriptRecorder } from '../GameTranscript'; const transcriptStore = new TranscriptStore(); +// ── Column layout constants ────────────────────────────────────── +// Fixed pixel offsets relative to screen center (cx = GAME_W / 2 = 640) +// These are chosen to fit within the 600px-wide overlay box centered at cx=640. +const COL_LABEL_X_OFFSET = -230; // Left-aligned label column (color name, round, etc.) +const COL_P0_X_OFFSET = -20; // Right-aligned Player 0 score column +const COL_P1_X_OFFSET = 160; // Right-aligned Player 1 score column + export class LostCitiesOverlayHelper { constructor( private scene: Phaser.Scene, @@ -64,12 +70,12 @@ export class LostCitiesOverlayHelper { let y = topY + 50; - const header = createLcHudText(this.scene, cx, y, 'Color You AI', '#aaaaaa', { - fontSize: '14px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(header); + const hdrColor = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, 'Color', '#aaaaaa', { fontSize: '14px', originX: 0, originY: 0 }); + const hdrYou = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, 'You', '#aaaaaa', { fontSize: '14px', originX: 1, originY: 0 }); + const hdrAi = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, 'AI', '#aaaaaa', { fontSize: '14px', originX: 1, originY: 0 }); + this.overlayManager.add(hdrColor); + this.overlayManager.add(hdrYou); + this.overlayManager.add(hdrAi); y += 26; for (let i = 0; i < EXPEDITION_COLORS.length; i++) { @@ -85,36 +91,34 @@ export class LostCitiesOverlayHelper { const p0Str = p0Cards > 0 ? `${p0Score}` : '-'; const p1Str = p1Cards > 0 ? `${p1Score}` : '-'; - const row = createLcHudText(this.scene, cx, y, `${colorName.padEnd(14)}${p0Str.padStart(8)}${p1Str.padStart(8)}`, '#dddddd', { - fontSize: '14px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(row); + const lbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, colorName, '#dddddd', { fontSize: '14px', originX: 0, originY: 0 }); + const p0Txt = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, p0Str, '#dddddd', { fontSize: '14px', originX: 1, originY: 0 }); + const p1Txt = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, p1Str, '#dddddd', { fontSize: '14px', originX: 1, originY: 0 }); + this.overlayManager.add(lbl); + this.overlayManager.add(p0Txt); + this.overlayManager.add(p1Txt); y += 22; } y += 8; - const totalRow = createLcHudText(this.scene, cx, y, `Round Total${String(p0Total).padStart(11)}${String(p1Total).padStart(8)}`, '#ffffff', { - fontSize: '16px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(totalRow); + const totalLbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, 'Round Total', '#ffffff', { fontSize: '16px', originX: 0, originY: 0 }); + const totalP0 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, String(p0Total), '#ffffff', { fontSize: '16px', originX: 1, originY: 0 }); + const totalP1 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, String(p1Total), '#ffffff', { fontSize: '16px', originX: 1, originY: 0 }); + this.overlayManager.add(totalLbl); + this.overlayManager.add(totalP0); + this.overlayManager.add(totalP1); y += 30; const [cum0, cum1] = this.session.cumulativeScores; - const cumRow = createLcHudText(this.scene, cx, y, `Cumulative${String(cum0).padStart(12)}${String(cum1).padStart(8)}`, '#f0c040', { - fontSize: '16px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(cumRow); + const cumLbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, 'Cumulative', '#f0c040', { fontSize: '16px', originX: 0, originY: 0 }); + const cumP0 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, String(cum0), '#f0c040', { fontSize: '16px', originX: 1, originY: 0 }); + const cumP1 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, String(cum1), '#f0c040', { fontSize: '16px', originX: 1, originY: 0 }); + this.overlayManager.add(cumLbl); + this.overlayManager.add(cumP0); + this.overlayManager.add(cumP1); y += 50; - const btn = createOverlayButton(this.scene, cx, y, '[ Next Round ]'); - try { btn.setDepth(11); } catch { /* ignore */ } - btn.on('pointerdown', () => { + const btn = createActionButton(this.scene, cx - 75, y, 150, '[ Next Round ]', () => { try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } this.dismiss(); // Advance to the next round now that the overlay is dismissed. @@ -123,7 +127,7 @@ export class LostCitiesOverlayHelper { // with the correct round-final state.) startNextRound(this.session); this.onNextRound?.(); - }); + }, { depth: 11 }); this.overlayManager.add(btn); } @@ -163,33 +167,33 @@ export class LostCitiesOverlayHelper { let y = topY + 55; - const header = createLcHudText(this.scene, cx, y, 'Round You AI', '#aaaaaa', { - fontSize: '14px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(header); + const hdrRound = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, 'Round', '#aaaaaa', { fontSize: '14px', originX: 0, originY: 0 }); + const hdrYou2 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, 'You', '#aaaaaa', { fontSize: '14px', originX: 1, originY: 0 }); + const hdrAi2 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, 'AI', '#aaaaaa', { fontSize: '14px', originX: 1, originY: 0 }); + this.overlayManager.add(hdrRound); + this.overlayManager.add(hdrYou2); + this.overlayManager.add(hdrAi2); y += 26; for (let r = 0; r < this.session.roundScores.length; r++) { const rs = this.session.roundScores[r]; - const row = createLcHudText(this.scene, cx, y, `Round ${r + 1}${String(rs.totals[0]).padStart(14)}${String(rs.totals[1]).padStart(8)}`, '#dddddd', { - fontSize: '14px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(row); + const roundLbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, `Round ${r + 1}`, '#dddddd', { fontSize: '14px', originX: 0, originY: 0 }); + const roundP0 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, String(rs.totals[0]), '#dddddd', { fontSize: '14px', originX: 1, originY: 0 }); + const roundP1 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, String(rs.totals[1]), '#dddddd', { fontSize: '14px', originX: 1, originY: 0 }); + this.overlayManager.add(roundLbl); + this.overlayManager.add(roundP0); + this.overlayManager.add(roundP1); y += 22; } y += 10; const [cum0, cum1] = this.session.cumulativeScores; - const totalRow = createLcHudText(this.scene, cx, y, `Final Total${String(cum0).padStart(11)}${String(cum1).padStart(8)}`, '#ffffff', { - fontSize: '18px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(totalRow); + const finalLbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, 'Final Total', '#ffffff', { fontSize: '18px', originX: 0, originY: 0 }); + const finalP0 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, String(cum0), '#ffffff', { fontSize: '18px', originX: 1, originY: 0 }); + const finalP1 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, String(cum1), '#ffffff', { fontSize: '18px', originX: 1, originY: 0 }); + this.overlayManager.add(finalLbl); + this.overlayManager.add(finalP0); + this.overlayManager.add(finalP1); y += 40; const detailsTitle = createLcHudText(this.scene, cx, y, `Round ${this.session.roundNumber} Breakdown`, '#aaccaa', { @@ -208,25 +212,21 @@ export class LostCitiesOverlayHelper { const p1Score = p1Bd && p1Bd.cardCount > 0 ? `${p1Bd.score}` : '-'; const colorName = color.charAt(0).toUpperCase() + color.slice(1); - const row = createLcHudText(this.scene, cx, y, `${colorName.padEnd(14)}${p0Score.padStart(8)}${p1Score.padStart(8)}`, '#bbbbbb', { - fontSize: '12px', - originX: 0.5, - originY: 0, - }); - this.overlayManager.add(row); + const brkLbl = createLcHudText(this.scene, cx + COL_LABEL_X_OFFSET, y, colorName, '#bbbbbb', { fontSize: '12px', originX: 0, originY: 0 }); + const brkP0 = createLcHudText(this.scene, cx + COL_P0_X_OFFSET, y, p0Score, '#bbbbbb', { fontSize: '12px', originX: 1, originY: 0 }); + const brkP1 = createLcHudText(this.scene, cx + COL_P1_X_OFFSET, y, p1Score, '#bbbbbb', { fontSize: '12px', originX: 1, originY: 0 }); + this.overlayManager.add(brkLbl); + this.overlayManager.add(brkP0); + this.overlayManager.add(brkP1); y += 18; } y += 20; - const newMatchBtn = createOverlayButton(this.scene, cx - 85, y, '[ New Match ]'); - newMatchBtn.on('pointerdown', () => { + const newMatchBtn = createActionButton(this.scene, cx - 70, y, 140, '[ New Match ]', () => { try { this.scene.sound.play?.(SFX_KEYS.UI_CLICK); } catch { /* ignore */ } this.dismiss(); this.onRestart?.(); - }); + }, { depth: 11 }); this.overlayManager.add(newMatchBtn); - - const menuBtn = createLcMenuButton(this.scene, cx + 85, y, 60, { depth: 11 }); - this.overlayManager.add(menuBtn); } } diff --git a/example-games/lost-cities/scenes/LostCitiesRenderer.ts b/example-games/lost-cities/scenes/LostCitiesRenderer.ts index d21294ea..51f54394 100644 --- a/example-games/lost-cities/scenes/LostCitiesRenderer.ts +++ b/example-games/lost-cities/scenes/LostCitiesRenderer.ts @@ -139,7 +139,7 @@ function lcDrawPileTextureFn(scene: Phaser.Scene): () => string { * A PileView that uses the card back texture and supports * lazy card-back texture updates for Lost Cities. */ -class DrawPileView extends PileView { +export class DrawPileView extends PileView { private scene: Phaser.Scene; private cardW: number; private cardH: number; @@ -155,6 +155,7 @@ class DrawPileView extends PileView { label: 'Draw Pile', emptyTexture: 'card_back', cardTextureFn: lcDrawPileTextureFn(scene), + countOffsetY: opts.cardH / 2 + 5.5, }); this.scene = scene; this.cardW = opts.cardW; diff --git a/example-games/lost-cities/scenes/LostCitiesScene.ts b/example-games/lost-cities/scenes/LostCitiesScene.ts index 44095567..268a7e3a 100644 --- a/example-games/lost-cities/scenes/LostCitiesScene.ts +++ b/example-games/lost-cities/scenes/LostCitiesScene.ts @@ -113,9 +113,7 @@ export class LostCitiesScene extends CardGameScene { create(): void { this.cameras.main.setBackgroundColor('#1a2a1a'); - this.detectReplayMode(); - this.initEventSystem(); - this.initHUDContainer(); + super.create(); this.session = setupLostCitiesGame({ playerNames: ['You', 'AI'], @@ -202,6 +200,10 @@ export class LostCitiesScene extends CardGameScene { }; this.initSoundSystem(Object.values(SFX_KEYS), mapping, { namespace: 'lost-cities' }); this.initSettingsPanel(); + // Propagate reduced motion preference to the animator + if (this.settingsPanel) { + this.animator.reducedMotion = this.settingsPanel.reducedMotion; + } } this.lcRenderer.refreshAll((idx) => this.turnController.onHandCardClick(idx)); diff --git a/example-games/main-street/MainStreetAdjacency.ts b/example-games/main-street/MainStreetAdjacency.ts index de7baf1c..630a9502 100644 --- a/example-games/main-street/MainStreetAdjacency.ts +++ b/example-games/main-street/MainStreetAdjacency.ts @@ -10,10 +10,11 @@ */ import type { BusinessCard, CommunitySpaceCard, SynergyType } from './MainStreetCards'; -import { GRID_SIZE, SYNERGY_BONUS_PER_NEIGHBOR } from './MainStreetCards'; +import { GRID_SIZE, SYNERGY_BONUS_PER_NEIGHBOR, isPawnShopCard } from './MainStreetCards'; import type { MainStreetState } from './MainStreetState'; import { addLog, syncResourceBankToLedger } from './MainStreetState'; import { applyReputationMultiplier } from './MainStreetDifficulty'; +import { applyActiveEffectMultiplier } from '../../src/core-engine/ActiveEffect'; // ── Adjacency Resolver ────────────────────────────────────── @@ -66,6 +67,11 @@ export function neighbors(index: number, range: number = 1): number[] { * 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). + * * @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). @@ -79,6 +85,11 @@ export function computeSynergyBonus( 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; + const range = 1 + business.synergyRangeBonus; const neighborIndices = neighbors(index, range); @@ -87,6 +98,9 @@ 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), @@ -104,6 +118,8 @@ export function computeSynergyBonus( * * totalIncome = baseIncome + incomeBonus (from upgrades) + synergyBonus * + * Pawn Shop cards receive no synergy bonus (see computeSynergyBonus). + * * @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). @@ -123,21 +139,71 @@ export function computeBusinessIncome( } /** - * Computes the total income across all businesses on the street grid. + * 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. + * + * @param grid The street grid (tableau businesses). + * @param hand Cards held in the player's hand. + * @returns The total hand card synergy bonus added to all tableau businesses. + */ +export function computeHandCardSynergyBonus( + grid: (BusinessCard | CommunitySpaceCard | null)[], + hand: BusinessCard[], +): number { + if (!hand || hand.length === 0) return 0; + + let total = 0; + + for (const handCard of hand) { + if (!handCard.synergyTypes || handCard.synergyTypes.length === 0) continue; + + // Each hand card provides floor(baseIncome/3) to each matching synergy business + const bonusPerMatch = Math.floor(handCard.baseIncome / 3); + if (bonusPerMatch <= 0) continue; + + for (let i = 0; i < grid.length; i++) { + const business = grid[i]; + if (!business) continue; + + // Pawn Shop cards do not receive synergy from any source + if (isPawnShopCard(business)) 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), + ); + if (hasMatch) { + total += bonusPerMatch; + } + } + } + + return total; +} + +/** + * Computes the total income across all businesses on the street grid, + * optionally including synergy bonuses from hand cards. * * 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 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, + hand?: BusinessCard[], ): IncomeResult { const breakdown: SlotIncome[] = []; let total = 0; + // Compute per-slot tableau income for (let i = 0; i < grid.length; i++) { const business = grid[i]; if (!business) continue; @@ -157,7 +223,66 @@ export function computeIncome( total += slotTotal; } - return { total, breakdown }; + // Add hand card synergy bonuses to the total + let handSynergyTotal = 0; + if (hand && hand.length > 0) { + handSynergyTotal = computeHandCardSynergyBonus(grid, hand); + total += handSynergyTotal; + + // Add hand synergy to each slot's total in the breakdown + // Distribute proportionally for accurate per-slot display + if (handSynergyTotal > 0) { + for (let i = 0; i < grid.length; i++) { + const business = grid[i]; + if (!business) continue; + + // Calculate hand synergy contribution per business + 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( + (st: SynergyType) => business.synergyTypes.includes(st), + ); + if (hasMatch) { + perSlotHandSynergy += Math.floor(handCard.baseIncome / 3); + } + } + + if (perSlotHandSynergy > 0) { + const slot = breakdown.find(s => s.slotIndex === i); + if (slot) { + slot.total += perSlotHandSynergy; + } + } + } + } + } + + return { total, breakdown, handSynergyTotal }; +} + +/** + * Computes total reputation per turn from all occupied grid slots. + * + * Each business/community-space card may contribute: + * - Its base `reputationPerTurn` (from the card definition) + * - Its accumulated `reputationBonus` (from applied upgrades) + * + * @param grid The street grid. + * @returns Total reputation per turn. + */ +export function computeReputationPerTurn( + grid: (BusinessCard | CommunitySpaceCard | null)[], +): number { + let total = 0; + for (const slot of grid) { + if (!slot) continue; + total += slot.reputationPerTurn ?? 0; + total += slot.reputationBonus; + } + return total; } /** @@ -168,42 +293,143 @@ export function computeIncome( * Income is scaled by the reputation coin multiplier (CG-0MMLR38NJ1N11DOS) * so that higher reputation yields proportionally more income. * + * Reputation-per-turn from cards (e.g. Clinic) is applied during this phase. + * * @param state Current game state (mutated). * @returns The IncomeResult for UI display (pre-multiplier breakdown, * but total reflects the multiplied amount actually credited). */ export function applyIncome(state: MainStreetState): IncomeResult { - const result = computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor); + const hand = state.hand ?? []; + const result = computeIncome(state.streetGrid, state.config.synergyBonusPerNeighbor, hand); + + // Apply active effect income modifiers per-slot, before reputation multiplier. + // Each slot's income is individually multiplied, then summed. + let modifiedTotal = 0; + for (const slot of result.breakdown) { + const modifiedSlotIncome = applyActiveEffectMultiplier( + state.activeEffects, + 'income-multiplier', + slot.total, + ); + modifiedTotal += modifiedSlotIncome; + } + const multiplied = applyReputationMultiplier( - result.total, + modifiedTotal, state.resourceBank.reputation, state.config, ); state.resourceBank.coins += multiplied; + + // Apply reputation per turn from cards + const repPerTurn = computeReputationPerTurn(state.streetGrid); + if (repPerTurn !== 0) { + state.resourceBank.reputation += repPerTurn; + } + syncResourceBankToLedger(state); if (multiplied > 0) { addLog(state, `Income: +${multiplied} coins`, 'gain'); } else { addLog(state, `Income: +0 coins`, 'neutral'); } + if (repPerTurn > 0) { + addLog(state, `Reputation from cards: +${repPerTurn}`, 'gain'); + } + if (result.handSynergyTotal > 0) { + addLog(state, `Hand card synergy: +${result.handSynergyTotal} coins`, 'gain'); + } return result; } +// ── Synergy Pairs for Visual Lines ────────────────────────── + +/** + * Represents a synergy connection between two adjacent slots on the street grid. + * Used by the renderer to draw visual lines between synergistic businesses. + */ +export interface SynergyPair { + /** The lower slot index of the pair. */ + fromIndex: number; + /** The higher slot index of the pair. */ + toIndex: number; + /** The shared synergy type used to determine line color. */ + sharedSynergy: SynergyType; +} + +/** + * Computes all synergy pairs on the street grid for visual line rendering. + * + * 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. + * + * Community-space cards are included in the same manner as business cards. + * + * @param grid The street grid. + * @returns Array of synergy pairs for visual line drawing. + */ +export function computeSynergyPairs( + grid: (BusinessCard | CommunitySpaceCard | null)[], +): SynergyPair[] { + const pairs: SynergyPair[] = []; + const seen = new Set(); + + for (let i = 0; i < grid.length; i++) { + const card = grid[i]; + if (!card) continue; + if (isPawnShopCard(card)) continue; + + const range = 1 + card.synergyRangeBonus; + const neighborIndices = neighbors(i, range); + + for (const ni of neighborIndices) { + if (ni <= i) continue; // avoid duplicates and self-pairs + const neighbor = grid[ni]; + if (!neighbor) continue; + if (isPawnShopCard(neighbor)) continue; + + // Find the first shared synergy type + const shared = card.synergyTypes.find( + (st: SynergyType) => neighbor.synergyTypes.includes(st), + ); + if (shared) { + const key = `${Math.min(i, ni)}-${Math.max(i, ni)}`; + if (!seen.has(key)) { + seen.add(key); + pairs.push({ + fromIndex: Math.min(i, ni), + toIndex: Math.max(i, ni), + sharedSynergy: shared, + }); + } + } + } + } + + return pairs.sort((a, b) => a.fromIndex - b.fromIndex || a.toIndex - b.toIndex); +} + // ── Result Types ──────────────────────────────────────────── -/** Income breakdown for a single occupied slot. */ +/** Per-slot income breakdown. */ export interface SlotIncome { slotIndex: number; businessName: string; baseIncome: number; synergyBonus: number; + /** Total income from this slot including hand synergy contributions. */ total: number; } /** Full income computation result. */ export interface IncomeResult { - /** Total coins earned from all businesses. */ + /** Total coins earned from all businesses (includes hand synergy if provided). */ total: number; /** Per-slot breakdown. */ breakdown: SlotIncome[]; + /** Total synergy contributed by hand cards. */ + handSynergyTotal: number; } diff --git a/example-games/main-street/MainStreetAiStrategy.ts b/example-games/main-street/MainStreetAiStrategy.ts index d0cc05ad..ce6677fd 100644 --- a/example-games/main-street/MainStreetAiStrategy.ts +++ b/example-games/main-street/MainStreetAiStrategy.ts @@ -362,6 +362,8 @@ export function scoreAction(state: MainStreetState, action: PlayerAction): numbe return scoreEventAction(state, action); case 'play-event': return PLAY_EVENT_SCORE; + case 'buy-business-to-hand': + return 0; case 'end-turn': return 0; } diff --git a/example-games/main-street/MainStreetCards.ts b/example-games/main-street/MainStreetCards.ts index 577612c3..1ae44dbb 100644 --- a/example-games/main-street/MainStreetCards.ts +++ b/example-games/main-street/MainStreetCards.ts @@ -14,7 +14,7 @@ // ── Synergy & Phase Enums ─────────────────────────────────── /** Synergy types used by Business cards for adjacency bonuses. */ -export type SynergyType = 'Food' | 'Culture' | 'Commerce' | 'Service' | 'Entertainment'; +export type SynergyType = 'Food' | 'Culture' | 'Commerce' | 'Service' | 'Entertainment' | 'Health'; /** When an Event card resolves. */ export type EventTrigger = 'Investment' | 'Incident'; @@ -22,8 +22,8 @@ export type EventTrigger = 'Investment' | 'Incident'; /** Scope of an Event card's effect. */ export type EventTarget = 'All' | 'SpecificSynergy' | 'RandomBusiness'; -/** Discriminator for the four card families (business, event, upgrade, community-space). */ -export type CardFamily = 'business' | 'event' | 'upgrade' | 'community-space'; +/** Discriminator for the card families (business, event, upgrade, community-space, staff). */ +export type CardFamily = 'business' | 'event' | 'upgrade' | 'community-space' | 'staff'; // ── Card Interfaces ───────────────────────────────────────── @@ -47,6 +47,16 @@ export interface BusinessCard { incomeBonus: number; /** Cumulative synergy range extension from applied upgrades. */ synergyRangeBonus: number; + /** + * Cumulative reputation bonus from applied upgrades. + * Initialized to 0 for all cards. + */ + reputationBonus: number; + /** + * Base reputation generated per turn by this business (without upgrades). + * Fractional values are supported (e.g. 0.2 for the Clinic). + */ + reputationPerTurn?: number; /** * IDs of upgrade cards that have been applied to this business instance, * in application order. Used to enforce multi-level chain requirements and @@ -74,6 +84,41 @@ export interface EventCard { readonly reputationDelta: number; } +/** + * A Duration-based Event card that creates an ActiveEffect rather than + * applying a one-shot coin/reputation delta. + * + * Extends EventCard with fields needed for duration-based modifiers: + * - `duration`: number of turns the effect lasts + * - `effectType`: discriminator for which aspect of the game is modified + * (e.g. 'income-multiplier', 'rep-multiplier') + * - `multiplier`: the scalar value applied each turn (e.g. 0.8 for 80% income) + */ +export interface DurationEventCard extends EventCard { + readonly duration: number; + readonly effectType: string; + readonly multiplier: number; +} + +/** + * Type guard: returns true if the given card is a DurationEventCard. + * + * Checks for the presence of the `duration` field (an optional field not + * present on regular EventCard instances). + * + * @param card Any card object to check. + * @returns true if the card has DurationEventCard-specific fields. + */ +export function isDurationEventCard(card: unknown): card is DurationEventCard { + if (card === null || card === undefined) return false; + if (typeof card !== 'object') return false; + const maybe = card as Record; + return ( + maybe.family === 'event' && + typeof maybe.duration === 'number' + ); +} + /** * An Upgrade card that enhances a specific Business card. * @@ -101,10 +146,31 @@ export interface UpgradeCard { * Omitting this field is equivalent to setting it to 0. */ readonly requiredLevel?: number; + /** + * Additional reputation generated per turn when this upgrade is applied. + * Works like incomeBonus but for reputation instead of coins. + * Fractional values are supported (e.g. 0.1 for the Medical Center upgrade). + */ + readonly reputationBonus?: number; +} + +/** + * A Staff card that increases hand capacity. + * Staff cards are a new card family distinct from business/event/upgrade. + * They do NOT occupy hand slots and have an ongoing per-turn coin cost. + */ +export interface StaffCard { + readonly family: 'staff'; + readonly id: string; + readonly name: string; + readonly cost: number; + readonly ongoingCost: number; + readonly handSlotsAdded: number; + readonly description: string; } /** Union of all card types in Main Street. */ -export type AnyCard = BusinessCard | CommunitySpaceCard | EventCard | UpgradeCard; +export type AnyCard = BusinessCard | CommunitySpaceCard | EventCard | DurationEventCard | UpgradeCard | StaffCard; // ── Constants ─────────────────────────────────────────────── @@ -153,6 +219,9 @@ export const INCIDENT_QUEUE_SIZE = 2; /** Fixed coin cost to refresh the investments row (buy new opportunities). */ 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. */ export const SYNERGY_BONUS_PER_NEIGHBOR = 1; @@ -162,18 +231,27 @@ export const REPUTATION_SCORE_MULTIPLIER = 5; /** Points awarded per completed challenge. */ export const CHALLENGE_BONUS_POINTS = 10; +// ── Multi-Use Card Economy Ratios ─────────────────────────── + +/** Cost ratio when placing a card from hand to tableau (80% of purchase cost). */ +export const PLACE_COST_RATIO = 0.8; + +/** Value ratio when selling a card (75% of purchase value). */ +export const SELL_VALUE_RATIO = 0.75; + // ── Card Fixture Data ─────────────────────────────────────── /** * Creates a fresh copy of a BusinessCard from template data. * Mutable fields (level, incomeBonus, synergyRangeBonus, appliedUpgrades) are reset. */ -function makeBusiness(template: Omit): BusinessCard { +function makeBusiness(template: Omit): BusinessCard { return { family: 'business', level: 0, incomeBonus: 0, synergyRangeBonus: 0, + reputationBonus: 0, appliedUpgrades: [], ...template, }; @@ -201,6 +279,16 @@ export interface CommunitySpaceCard { incomeBonus: number; /** Cumulative synergy range extension from applied upgrades. */ synergyRangeBonus: number; + /** + * Cumulative reputation bonus from applied upgrades. + * Initialized to 0 for all cards. + */ + reputationBonus: number; + /** + * Base reputation generated per turn by this community space (without upgrades). + * Fractional values are supported (e.g. 0.2). + */ + reputationPerTurn?: number; /** * IDs of upgrade cards that have been applied to this community space instance, * in application order. @@ -214,19 +302,20 @@ export interface CommunitySpaceCard { * Creates a fresh copy of a CommunitySpaceCard from template data. * Mutable fields (level, incomeBonus, synergyRangeBonus, appliedUpgrades) are reset. */ -function makeCommunitySpace(template: Omit): CommunitySpaceCard { +function makeCommunitySpace(template: Omit): CommunitySpaceCard { return { family: 'community-space', level: 0, incomeBonus: 0, synergyRangeBonus: 0, + reputationBonus: 0, appliedUpgrades: [], ...template, }; } /** Template data for all Business cards (M1 + M2 pool). */ -const BUSINESS_TEMPLATES: Omit[] = [ +const BUSINESS_TEMPLATES: Omit[] = [ { id: 'biz-bakery', name: 'Bakery', @@ -277,7 +366,7 @@ const BUSINESS_TEMPLATES: Omit[] = [ +const COMMUNITY_SPACE_TEMPLATES: Omit[] = [ { id: 'cs-park', name: 'Park', @@ -621,6 +730,234 @@ const EVENT_TEMPLATES: EventCard[] = [ 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). */ @@ -650,14 +987,15 @@ const UPGRADE_TEMPLATES: UpgradeCard[] = [ }, { family: 'upgrade', - id: 'upg-library', - name: 'Upgrade to Library', + id: 'upg-readers-cafe', + name: "Upgrade to Reader's Café", targetBusiness: 'Bookshop', cost: 3, incomeBonus: 1, synergyRangeBonus: 0, + reputationBonus: 0.1, requiredLevel: 0, - description: 'Adds a cultural boost to the Bookshop.', + description: 'Transforms the Bookshop into a Reader\'s Café, blending books with café culture for a reputation boost.', }, // ── M2 Expanded Upgrade Templates ─────────────────────────── { @@ -809,10 +1147,22 @@ const UPGRADE_TEMPLATES: UpgradeCard[] = [ name: 'Upgrade to Medical Center', targetBusiness: 'Clinic', cost: 5, - incomeBonus: 2, + incomeBonus: 0, synergyRangeBonus: 1, + reputationBonus: 0.1, requiredLevel: 0, - description: 'Upgrades the Clinic to a comprehensive Medical Center.', + 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) @@ -926,6 +1276,55 @@ const UPGRADE_TEMPLATES: UpgradeCard[] = [ }, ]; +// ── 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.', + }, +]; + +/** + * Creates the full Staff deck for a game. + * + * @param copies Number of copies per template (default 1). + * @returns Array of StaffCard instances. + */ +export function createStaffDeck(copies: number = 1): StaffCard[] { + const deck: StaffCard[] = []; + for (let c = 0; c < copies; c++) { + for (const template of STAFF_CARD_TEMPLATES) { + deck.push({ ...template, id: `${template.id}-${c}` }); + } + } + return deck; +} + // ── Deck Building ─────────────────────────────────────────── /** @@ -1111,6 +1510,7 @@ export function synergyColor(type: SynergyType): number { case 'Commerce': return 0x27AE60; // Green case 'Service': return 0x9B59B6; // Purple case 'Entertainment': return 0xE74C3C; // Red + case 'Health': return 0x1ABC9C; // Teal/Cyan } } @@ -1123,9 +1523,29 @@ export function cardLabel(card: AnyCard): string { case 'community-space': return `${card.name} ($${card.cost})`; case 'event': return card.cost > 0 ? `${card.name} ($${card.cost})` : card.name; case 'upgrade': return `${card.name} ($${card.cost})`; + case 'staff': return `${card.name} ($${card.cost})`; } } +/** + * 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 // --------------------------------------------------------------------------- diff --git a/example-games/main-street/MainStreetCommands.ts b/example-games/main-street/MainStreetCommands.ts index f0434dc5..ab2347f3 100644 --- a/example-games/main-street/MainStreetCommands.ts +++ b/example-games/main-street/MainStreetCommands.ts @@ -13,8 +13,10 @@ import { toCommand, type ReversibleAction } from '../../src/core-engine/ActionCo import type { MainStreetState } from './MainStreetState'; import { purchaseBusiness, + purchaseBusinessToHand, purchaseUpgrade, purchaseEvent, + refreshDevelopment, refreshInvestments, } from './MainStreetMarket'; import { playHeldEvent } from './MainStreetEngine'; @@ -125,6 +127,20 @@ export function buyUpgradeCommand( ); } +/** Command: Buy Business to Hand */ +export function buyBusinessToHandCommand( + state: MainStreetState, + cardId: string, +) { + return toCommand( + state, + snapshotAction( + (s) => purchaseBusinessToHand(s, cardId), + `BuyBusinessToHand ${cardId}`, + ), + ); +} + /** Command: Buy Event (Investment) */ export function buyEventCommand( state: MainStreetState, @@ -150,6 +166,17 @@ export function playEventCommand(state: MainStreetState) { ); } +/** Command: Refresh Development Row */ +export function refreshDevelopmentCommand(state: MainStreetState) { + return toCommand( + state, + snapshotAction( + (s) => refreshDevelopment(s), + 'RefreshDevelopment', + ), + ); +} + /** Command: Refresh Investments Row */ export function refreshInvestmentsCommand(state: MainStreetState) { return toCommand( diff --git a/example-games/main-street/MainStreetEngine.ts b/example-games/main-street/MainStreetEngine.ts index 30a75c17..6f16c6c4 100644 --- a/example-games/main-street/MainStreetEngine.ts +++ b/example-games/main-street/MainStreetEngine.ts @@ -18,13 +18,18 @@ import type { MainStreetState, DayPhase } from './MainStreetState'; import { PHASE_ORDER, addLog, syncResourceBankToLedger } from './MainStreetState'; import type { EventCard, SynergyType } from './MainStreetCards'; +import { PLACE_COST_RATIO, SELL_VALUE_RATIO, isDurationEventCard, type DurationEventCard } from './MainStreetCards'; +import { createActiveEffect, decayActiveEffects } from '../../src/core-engine/ActiveEffect'; +import { recordMainStreetEvent } from './MainStreetTranscript'; import { applyIncome, type IncomeResult } from './MainStreetAdjacency'; import { purchaseBusiness, + purchaseBusinessToHand, purchaseUpgrade, purchaseEvent, refillAllMarkets, refillIncidentQueue, + cycleMarketCards, type PurchaseResult, } from './MainStreetMarket'; import { evaluateChallenges } from './MainStreetChallenges'; @@ -60,6 +65,12 @@ export interface PlayEventAction { type: 'play-event'; } +/** Buy a business card and add it to the player's hand (Multi-Use Card Economy). */ +export interface BuyBusinessToHandAction { + type: 'buy-business-to-hand'; + cardId: string; +} + /** End the current market/action phase. */ export interface EndTurnAction { type: 'end-turn'; @@ -70,6 +81,7 @@ export type PlayerAction = | BuyBusinessAction | BuyUpgradeAction | BuyEventAction + | BuyBusinessToHandAction | PlayEventAction | EndTurnAction; @@ -85,6 +97,8 @@ export interface TurnResult { gameResult: 'playing' | 'win' | 'loss'; /** Current final score. */ finalScore: number; + /** Challenge IDs that were newly completed during this turn's evaluation. */ + newlyCompletedChallenges: string[]; } // ── Score Calculation ─────────────────────────────────────── @@ -166,6 +180,8 @@ export function executeAction( switch (action.type) { case 'buy-business': return purchaseBusiness(state, action.cardId, action.slotIndex); + case 'buy-business-to-hand': + return purchaseBusinessToHand(state, action.cardId); case 'buy-upgrade': return purchaseUpgrade(state, action.cardId, action.targetSlot); case 'buy-event': @@ -212,7 +228,91 @@ function classifyEffect(coinChange: number, repChange: number): 'gain' | 'loss' * (CG-0MMLR38NJ1N11DOS). Negative deltas (penalties) pass through * unchanged. */ +/** + * Computes the effective duration for a DurationEventCard by scanning + * the street grid for Clinic and Medical Center cards. + * + * Rules (from Flu event AC): + * - Medical Center (upg-medical-center) reduces duration by 3 + * - Clinic (biz-clinic) reduces duration by 2 + * - Only the stronger reduction applies (Medical Center > Clinic) + * - Minimum duration floor is 1 + * + * @param baseDuration Base duration before reductions + * @param state Current game state (street grid is scanned) + * @returns Effective duration after reductions (min 1). + */ +function computeDurationWithClinicReduction(baseDuration: number, state: MainStreetState): number { + let hasMedicalCenter = false; + let hasClinic = false; + + for (const slot of state.streetGrid) { + if (slot === null) continue; + if (slot.id.startsWith('upg-medical-center')) { + hasMedicalCenter = true; + } else if (slot.id.startsWith('biz-clinic')) { + hasClinic = true; + } + } + + let reduction = 0; + if (hasMedicalCenter) { + reduction = 3; + } else if (hasClinic) { + reduction = 2; + } + + return Math.max(1, baseDuration - reduction); +} + +/** + * Resolves a single event card's effects on the game state. + * + * DurationEventCards branch to ActiveEffect creation instead of applying + * one-shot coin/reputation deltas. Regular EventCards apply deltas as before. + */ export function resolveEvent(state: MainStreetState, event: EventCard): void { + // ── DurationEventCard branch ──────────────────────────────── + if (isDurationEventCard(event)) { + const dEvent = event as DurationEventCard; + + // Compute effective duration (check clinic/medical center for flu) + let effectiveDuration = dEvent.duration; + if (dEvent.id === 'evt-flu-outbreak') { + effectiveDuration = computeDurationWithClinicReduction(dEvent.duration, state); + } + + // Create the ActiveEffect + const effect = createActiveEffect( + dEvent.effectType, + dEvent.multiplier, + effectiveDuration, + dEvent.id, + `${dEvent.name}: ${dEvent.effect}`, + ); + state.activeEffects.push(effect); + + // Log the onset + const logText = effectiveDuration > 0 + ? `${dEvent.name}: Income reduced to ${Math.round(dEvent.multiplier * 100)}% for ${effectiveDuration} turns` + : `${dEvent.name}: Resolved with no effect (fully neutralized)`; + addLog(state, logText, 'loss'); + + // Record transcript event + recordMainStreetEvent({ + type: 'active-effect', + turn: state.turn, + effectType: dEvent.effectType, + sourceEventId: dEvent.id, + duration: effectiveDuration, + description: logText, + }); + + syncResourceBankToLedger(state); + return; + } + + // ── Regular EventCard resolution ──────────────────────────── const rep = state.resourceBank.reputation; const cfg = state.config; @@ -460,6 +560,15 @@ export function processEndOfTurn(state: MainStreetState): TurnResult { throw new Error(`Cannot end turn during ${state.phase}. Must be in MarketPhase.`); } + // Cycle unpurchased market cards to discard piles before advancing phases. + // During the tutorial (before T7 completes), market cycling is skipped to + // preserve scenario-placed cards (e.g. Local Festival for T7). The + // `skipMarketCycleOnEndTurn` flag is set by the turn controller when the + // tutorial is active and the current step requires the scenario cards. + if (!state.skipMarketCycleOnEndTurn) { + cycleMarketCards(state); + } + // Phase: InvestmentResolution // Held Investment events are NO LONGER auto-resolved. The player must // actively play them by clicking during the MarketPhase. Unplayed events @@ -473,6 +582,7 @@ export function processEndOfTurn(state: MainStreetState): TurnResult { incident: null, gameResult: state.gameResult, finalScore: state.finalScore, + newlyCompletedChallenges: [], }; } @@ -480,6 +590,9 @@ export function processEndOfTurn(state: MainStreetState): TurnResult { state.phase = 'IncomePhase'; const income = applyIncome(state); + // Apply staff card ongoing costs (Multi-Use Card Economy) + applyStaffOngoingCosts(state); + // Phase: IncidentPhase state.phase = 'IncidentPhase'; const incident = resolveIncident(state); @@ -491,14 +604,27 @@ export function processEndOfTurn(state: MainStreetState): TurnResult { incident, gameResult: state.gameResult, finalScore: state.finalScore, + newlyCompletedChallenges: [], }; } // Phase: EndCheck state.phase = 'EndCheck'; + // Decay active effects (decrement turnsRemaining, remove expired) + const decayResult = decayActiveEffects(state.activeEffects); + state.activeEffects = decayResult.active; + for (const expired of decayResult.expired) { + addLog(state, `${expired.description} has expired.`, 'neutral'); + recordMainStreetEvent({ + type: 'info', + turn: state.turn, + message: `${expired.description} has expired.`, + }); + } + // Evaluate challenges before checking end conditions (so score includes any new bonus points) - evaluateChallenges(state.activeChallenges, state); + const newlyCompletedChallenges = evaluateChallenges(state.activeChallenges, state); checkEndConditions(state); @@ -513,6 +639,7 @@ export function processEndOfTurn(state: MainStreetState): TurnResult { incident, gameResult: state.gameResult, finalScore: state.finalScore, + newlyCompletedChallenges, }; } @@ -544,3 +671,221 @@ export function executeFullTurn( // Process end of turn return processEndOfTurn(state); } + +// ── Card Placement & Sell Operations (Multi-Use Card Economy) ─ + +/** + * Places a card from the player's hand onto an empty tableau slot. + * Costs 80% of the card's purchase price. + * + * @param state Current game state (mutated in-place). + * @param handIndex Index of the card in state.hand to place. + * @param slotIndex Target street grid slot (0-based, must be empty). + * @throws Error if the hand index is invalid, slot is occupied, or coins insufficient. + */ +export function placeFromHand( + state: MainStreetState, + handIndex: number, + slotIndex: number, +): void { + const hand = state.hand ?? []; + + // Validate hand index + if (handIndex < 0 || handIndex >= hand.length) { + throw new Error(`Invalid hand index: ${handIndex}. Hand has ${hand.length} cards.`); + } + + const card = hand[handIndex]; + + // Validate slot index + if (slotIndex < 0 || slotIndex >= 10) { + throw new Error(`Invalid slot index: ${slotIndex}. Must be 0-9.`); + } + + // Check slot is empty + if (state.streetGrid[slotIndex] !== null) { + throw new Error(`Slot ${slotIndex} is already occupied.`); + } + + // Calculate placement cost (80% of purchase price) + const placementCost = Math.floor(card.cost * PLACE_COST_RATIO); + + // Check coins + if (state.resourceBank.coins < placementCost) { + throw new Error(`Not enough coins. Need ${placementCost} to place, have ${state.resourceBank.coins}.`); + } + + // Deduct cost + state.resourceBank.coins -= placementCost; + + // Remove from hand and place on tableau + hand.splice(handIndex, 1); + state.streetGrid[slotIndex] = card; + + addLog(state, `Placed ${card.name} from hand in slot ${slotIndex} (-$${placementCost})`, 'loss'); +} + +/** + * Sells a card from the player's hand for 75% of purchase value. + * The card goes to the discard pile. + * + * @param state Current game state (mutated in-place). + * @param handIndex Index of the card in state.hand to sell. + * @throws Error if the hand index is invalid. + */ +export function sellFromHand( + state: MainStreetState, + handIndex: number, +): void { + const hand = state.hand ?? []; + + // Validate hand index + if (handIndex < 0 || handIndex >= hand.length) { + throw new Error(`Invalid hand index: ${handIndex}. Hand has ${hand.length} cards.`); + } + + const card = hand[handIndex]; + + // Calculate sell value (75% of purchase price) + const sellValue = Math.floor(card.cost * SELL_VALUE_RATIO); + + // Remove from hand + hand.splice(handIndex, 1); + + // Credit coins + state.resourceBank.coins += sellValue; + + // Add to discard pile + state.discardPile.push(card as any); + + addLog(state, `Sold ${card.name} from hand for +${sellValue} coins`, 'gain'); +} + +/** + * Sells a card from the tableau for 75% of purchase value. + * The card goes to the discard pile and the slot becomes empty. + * + * @param state Current game state (mutated in-place). + * @param slotIndex Street grid slot index of the card to sell. + * @throws Error if the slot is empty or index is invalid. + */ +export function sellFromTableau( + state: MainStreetState, + slotIndex: number, +): void { + // Validate slot index + if (slotIndex < 0 || slotIndex >= 10) { + throw new Error(`Invalid slot index: ${slotIndex}. Must be 0-9.`); + } + + const card = state.streetGrid[slotIndex]; + + // Check slot is occupied + if (card === null) { + throw new Error(`Slot ${slotIndex} is empty. Nothing to sell.`); + } + + // Calculate sell value (75% of purchase price) + const sellValue = Math.floor(card.cost * SELL_VALUE_RATIO); + + // Remove from tableau + state.streetGrid[slotIndex] = null; + + // Credit coins + state.resourceBank.coins += sellValue; + + // Add to discard pile + state.discardPile.push(card as any); + + addLog(state, `Sold ${card.name} from slot ${slotIndex} for +${sellValue} coins`, 'gain'); +} + +// ── Staff Card Operations (Multi-Use Card Economy) ─────────── + +/** + * Applies staff card ongoing costs for the current turn. + * Deducts each active staff card's ongoingCost from coins. + * If coins are insufficient, deducts what's available (down to 0). + * + * @param state Current game state (mutated in-place). + */ +export function applyStaffOngoingCosts(state: MainStreetState): void { + const staffCards = state.staffCards ?? []; + if (staffCards.length === 0) return; + + let totalCost = 0; + for (const card of staffCards) { + totalCost += card.ongoingCost; + } + + if (totalCost > 0) { + const actualDeduction = Math.min(totalCost, state.resourceBank.coins); + state.resourceBank.coins -= actualDeduction; + if (actualDeduction > 0) { + addLog(state, `Staff costs: -${actualDeduction} coins (${staffCards.length} staff)`, 'loss'); + } + if (actualDeduction < totalCost) { + addLog(state, `Insufficient coins for staff costs: owed ${totalCost}, paid ${actualDeduction}`, 'loss'); + } + } +} + +/** + * Lays off (removes) a staff card, decreasing maxHandSize and randomly + * removing hand cards equal to the staff card's handSlotsAdded. + * + * Uses the game's seeded RNG for deterministic random card selection. + * If hand has fewer cards than slots to remove, all hand cards are removed. + * + * @param state Current game state (mutated in-place). + * @param cardId ID of the staff card to lay off (must be in staffCards). + * @throws Error if the staff card is not found. + */ +export function layoffStaffCard( + state: MainStreetState, + cardId: string, +): void { + const staffIndex = state.staffCards.findIndex(c => c.id === cardId); + if (staffIndex === -1) { + throw new Error(`Staff card ${cardId} not found in active staff.`); + } + + const card = state.staffCards[staffIndex]; + const slotsToRemove = card.handSlotsAdded; + + // Remove the staff card + state.staffCards.splice(staffIndex, 1); + + // Decrease maxHandSize (minimum 2) + state.maxHandSize = Math.max(2, state.maxHandSize - slotsToRemove); + + // Randomly remove hand cards equal to slots added (uses seeded RNG) + const hand = state.hand ?? []; + const cardsToRemove = Math.min(slotsToRemove, hand.length); + + if (cardsToRemove > 0) { + // Use Fisher-Yates shuffle on indices for deterministic random selection + const indices = hand.map((_, i) => i); + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(state.rng() * (i + 1)); + const tmp = indices[i]; + indices[i] = indices[j]; + indices[j] = tmp; + } + + // Remove the first `cardsToRemove` randomly-selected cards + const toRemove = indices.slice(0, cardsToRemove).sort((a, b) => b - a); + const removedCards: string[] = []; + for (const idx of toRemove) { + removedCards.push(hand[idx].name ?? hand[idx].id); + hand.splice(idx, 1); + } + + addLog(state, `Laid off ${card.name}: removed ${cardsToRemove} hand card(s) (${removedCards.join(', ')})`, 'loss'); + } else { + addLog(state, `Laid off ${card.name}: no hand cards to remove`, 'neutral'); + } + + // Return the staff card to the market + state.staffCardMarket.push({ ...card }); +} diff --git a/example-games/main-street/MainStreetHint.ts b/example-games/main-street/MainStreetHint.ts index 48557e96..392e3f59 100644 --- a/example-games/main-street/MainStreetHint.ts +++ b/example-games/main-street/MainStreetHint.ts @@ -139,6 +139,8 @@ export function buildRationale( return `Play ${cardName} now for immediate benefit`; } + case 'buy-business-to-hand': + return 'Buy business card to hand for later placement'; case 'end-turn': return 'No good buys available -- end your turn'; } diff --git a/example-games/main-street/MainStreetMarket.ts b/example-games/main-street/MainStreetMarket.ts index df9cbe07..b02aa867 100644 --- a/example-games/main-street/MainStreetMarket.ts +++ b/example-games/main-street/MainStreetMarket.ts @@ -23,6 +23,7 @@ import { MARKET_INVESTMENT_SLOTS, MARKET_INVESTMENT_UPGRADE_COUNT, MARKET_INVESTMENT_EVENT_COUNT, + REFRESH_DEVELOPMENT_COST, REFRESH_INVESTMENTS_COST, } from './MainStreetCards'; import { shuffleArray } from '../../src/card-system'; @@ -47,6 +48,22 @@ function reshuffleIfNeeded(state: MainStreetState, deck: T[], discard: T[], n } } +/** + * Force-reshuffles the discard pile into the deck regardless of whether the + * deck is empty. Used when the deck still holds cards but none of the required + * trigger type (e.g. only Incident cards remain when we need an Investment), + * and reshuffleIfNeeded (which requires deck.length === 0) was insufficient. + */ +function forceReshuffleFromDiscards(state: MainStreetState, deck: T[], discard: T[], name: string): void { + if (discard.length > 0) { + shuffleArray(discard, state.rng); + while (discard.length > 0) { + deck.push(discard.pop()!); + } + addLog(state, `Reshuffled ${name} discard into deck`, 'neutral'); + } +} + // ── Shared Market Engine Integration ────────────────────── /** @@ -269,6 +286,9 @@ export function refillInvestmentsMarket(state: MainStreetState): void { if (idx === -1) { // Maybe the event deck was empty and discards can be reshuffled now reshuffleIfNeeded(state, decks.event, state.discards.event, 'event'); + // If the deck still has cards but none are Investment-trigger, + // force a reshuffle from discards (e.g. only Incidents remain). + forceReshuffleFromDiscards(state, decks.event, state.discards.event, 'event'); idx = decks.event.findIndex(e => e.trigger === 'Investment'); if (idx === -1) break; } @@ -359,6 +379,55 @@ export function refreshInvestments(state: MainStreetState): RefreshResult { return { replaced: removed, cost: REFRESH_INVESTMENTS_COST }; } +/** + * Checks whether the player can pay to refresh the development row. + */ +export function canRefreshDevelopment(state: MainStreetState): LegalityResult { + if (state.phase !== 'MarketPhase') { + return { legal: false, reason: 'Refresh development is only allowed during MarketPhase.' }; + } + if (state.resourceBank.coins < REFRESH_DEVELOPMENT_COST) { + return { legal: false, reason: `Not enough coins. Need ${REFRESH_DEVELOPMENT_COST}, have ${state.resourceBank.coins}.` }; + } + return { legal: true }; +} + +/** + * Refreshes the development row by charging the player, discarding the + * currently-visible development cards to their respective discard piles, + * and drawing replacements using the same rules as refillDevelopmentMarket. + */ +export function refreshDevelopment(state: MainStreetState): RefreshResult { + const legality = canRefreshDevelopment(state); + if (!legality.legal) throw new Error(legality.reason); + + // Deduct cost + state.resourceBank.coins -= REFRESH_DEVELOPMENT_COST; + + // Move visible development cards to discard piles + const removed: AnyCard[] = state.market.development.slice(); + for (const c of removed) { + if (c.family === 'business') { + state.discards.business.push(c as BusinessCard); + } else if (c.family === 'community-space') { + state.discards.communitySpace.push(c as CommunitySpaceCard); + } + } + + // Clear the visible development row and draw replacements + state.market.development.length = 0; + refillDevelopmentMarket(state); + + // Build a detailed replacement summary for the activity log + const replacedStrings = removed.map(c => { + const name = (c as any).name ?? c.id; + return `${c.id}${name ? ` (${name})` : ''}`; + }); + addLog(state, `Refreshed development (-$${REFRESH_DEVELOPMENT_COST}): replaced ${replacedStrings.join(', ')}`, 'loss'); + + return { replaced: removed, cost: REFRESH_DEVELOPMENT_COST }; +} + /** * Refills all market rows to their maximum slot counts. */ @@ -367,6 +436,51 @@ export function refillAllMarkets(state: MainStreetState): void { refillInvestmentsMarket(state); } +// ── Market Cycling (Multi-Use Card Economy) ────────────────── + +/** + * Cycles all unpurchased market cards to their respective discard piles + * and refills the market from the decks. + * + * Called at the end of each MarketPhase (before IncomePhase) to ensure + * fresh cards are available each turn. Player-owned cards (hand, tableau) + * are not affected. + * + * Uses the existing seeded RNG for any reshuffles that occur during refill. + * + * @param state Current game state (mutated in-place). + */ +export function cycleMarketCards(state: MainStreetState): void { + // ── Cycle development row cards to discards ────────────── + const developmentCards = state.market.development.splice(0); + for (const card of developmentCards) { + if (card.family === 'business') { + state.discards.business.push(card as BusinessCard); + } else if (card.family === 'community-space') { + state.discards.communitySpace.push(card as CommunitySpaceCard); + } + } + + // ── Cycle investments row cards to discards ────────────── + const investmentCards = state.market.investments.splice(0); + for (const card of investmentCards) { + if (card.family === 'upgrade') { + state.discards.upgrade.push(card as UpgradeCard); + } else if (card.family === 'event') { + state.discards.event.push(card as EventCard); + } + } + + // Log the cycle + const totalCycled = developmentCards.length + investmentCards.length; + if (totalCycled > 0) { + addLog(state, `Market cycled: ${totalCycled} unpurchased cards moved to discard`, 'neutral'); + } + + // ── Refill all market rows from decks ──────────────────── + refillAllMarkets(state); +} + /** * Tops up the incident queue to INCIDENT_QUEUE_SIZE by drawing * Incident-trigger cards from the event deck. If the deck has no @@ -381,6 +495,9 @@ export function refillIncidentQueue(state: MainStreetState): void { if (idx === -1) { // Attempt a reshuffle in case the deck was empty earlier reshuffleIfNeeded(state, state.decks.event, state.discards.event, 'event'); + // If the deck still has cards but none are Incident-trigger, + // force a reshuffle from discards (e.g. only Investments remain). + forceReshuffleFromDiscards(state, state.decks.event, state.discards.event, 'event'); idx = state.decks.event.findIndex(e => e.trigger === 'Incident'); if (idx === -1) break; } @@ -430,6 +547,72 @@ export function purchaseBusiness( return { card, cost: card.cost, refilled }; } +// ── Purchase-to-Hand (Multi-Use Card Economy) ──────────────── + +/** + * Checks whether the player can add a card to their hand. + * + * The hand is full when its length >= maxHandSize. + * + * @param state Current game state. + * @returns LegalityResult indicating whether the action is permitted. + */ +export function canAddToHand(state: MainStreetState): LegalityResult { + const hand = state.hand ?? []; + const maxSize = state.maxHandSize ?? 2; + if (hand.length >= maxSize) { + return { legal: false, reason: `Hand is full (${hand.length}/${maxSize}). Place on tableau or sell a card first.` }; + } + return { legal: true }; +} + +/** + * Purchases a Business card from the market and adds it to the player's hand + * instead of placing it on the tableau. + * + * @param state Current game state (mutated in-place). + * @param cardId ID of the Business card in the development market. + * @returns PurchaseResult on success. + * @throws Error if the action is illegal or hand is full. + */ +export function purchaseBusinessToHand( + state: MainStreetState, + cardId: string, +): PurchaseResult { + // Validate the card exists and player can afford it + const card = state.market.development.find(c => c.id === cardId); + if (!card) { + throw new Error(`Card ${cardId} not found in the development market.`); + } + if (state.resourceBank.coins < card.cost) { + throw new Error(`Not enough coins. Need ${card.cost}, have ${state.resourceBank.coins}.`); + } + + // Check hand capacity + const handCheck = canAddToHand(state); + if (!handCheck.legal) { + throw new Error(handCheck.reason); + } + + const marketIndex = state.market.development.findIndex(c => c.id === cardId); + + // Deduct cost + state.resourceBank.coins -= card.cost; + + // Remove from market + state.market.development.splice(marketIndex, 1); + + // Add to hand + state.hand.push({ ...card } as BusinessCard); + + // Note: market is not refilled immediately. + const refilled = false; + + addLog(state, `Added ${card.name} to hand (-$${card.cost})`, 'loss'); + + return { card, cost: card.cost, refilled }; +} + /** * Purchases an Upgrade card from the market and applies it to the first * matching eligible business on the street. @@ -486,6 +669,7 @@ export function purchaseUpgrade( business.level += 1; business.incomeBonus += card.incomeBonus; business.synergyRangeBonus += card.synergyRangeBonus; + business.reputationBonus += card.reputationBonus ?? 0; if (!business.appliedUpgrades) { business.appliedUpgrades = []; } @@ -604,9 +788,8 @@ export function findTargetBusinessSlot( * the business occupying `slotIndex` — i.e. cards whose `targetBusiness` * matches and whose `requiredLevel` equals the business's current level. * - * This is the set of upgrade *branches* the player can choose from for - * that business. When the set has more than one entry the UI should - * present an upgrade-choice modal so the player can pick a branch. + * The upgrade the player clicks is applied directly; this helper is + * used for validation and display purposes. * * @param state Current game state. * @param slotIndex Street grid slot index of the target business. @@ -626,3 +809,44 @@ export function getUpgradeBranchesForBusiness( (card.requiredLevel ?? 0) === business.level, ); } + +// ── Staff Card Operations (Multi-Use Card Economy) ─────────── + +/** + * Purchases a staff card from the staff card market. + * Deducts coins, adds the staff card to active staffCards[], + * and increases maxHandSize by the card's handSlotsAdded. + * + * @param state Current game state (mutated in-place). + * @param cardId ID of the staff card in the staff card market. + * @throws Error if the card is not found or player cannot afford it. + */ +export function purchaseStaffCard( + state: MainStreetState, + cardId: string, +): void { + const marketIndex = state.staffCardMarket.findIndex(c => c.id === cardId); + if (marketIndex === -1) { + throw new Error(`Staff card ${cardId} not found in the market.`); + } + + const card = state.staffCardMarket[marketIndex]; + + if (state.resourceBank.coins < card.cost) { + throw new Error(`Not enough coins. Need ${card.cost}, have ${state.resourceBank.coins}.`); + } + + // Deduct cost + state.resourceBank.coins -= card.cost; + + // Remove from market + state.staffCardMarket.splice(marketIndex, 1); + + // Add to active staff cards + state.staffCards.push({ ...card }); + + // Increase max hand size + state.maxHandSize += card.handSlotsAdded; + + addLog(state, `Hired ${card.name} (+${card.handSlotsAdded} hand slots, -$${card.cost}, ongoing $${card.ongoingCost}/turn)`, 'loss'); +} diff --git a/example-games/main-street/MainStreetState.ts b/example-games/main-street/MainStreetState.ts index 7c38a992..7879ce70 100644 --- a/example-games/main-street/MainStreetState.ts +++ b/example-games/main-street/MainStreetState.ts @@ -9,17 +9,19 @@ */ import { shuffleArray } from '../../src/card-system'; -import { createSeededRng } from '../../src/core-engine'; +import { type ActiveEffect, createSeededRng } from '../../src/core-engine'; import { createEconomyLedger, type EconomyLedger } from '../../src/rule-engine/EconomyLedger'; import { type BusinessCard, type CommunitySpaceCard, type EventCard, type UpgradeCard, + type StaffCard, createBusinessDeck, createCommunitySpaceDeck, createEventDeck, createUpgradeDeck, + createStaffDeck, GRID_SIZE, MARKET_BUSINESS_SLOTS, MARKET_INVESTMENT_UPGRADE_COUNT, @@ -212,6 +214,24 @@ export interface MainStreetState { rng: () => number; /** Chronological log of game activities for the UI activity log panel. */ activityLog: LogEntry[]; + /** Active duration-based modifiers (e.g. Flu outbreak income reduction). */ + activeEffects: ActiveEffect[]; + /** Cards held in the player's hand (not placed on tableau). */ + hand: BusinessCard[]; + /** Maximum number of cards the player can hold in hand (default 2, expanded by staff cards). */ + maxHandSize: number; + /** Discard pile for cycled and sold cards (unified discard pool). */ + discardPile: BusinessCard[]; + /** Active staff cards providing hand capacity bonuses. */ + staffCards: StaffCard[]; + /** Staff cards available for purchase in the market. */ + staffCardMarket: StaffCard[]; + /** + * If true, `processEndOfTurn()` will skip `cycleMarketCards()`. + * Used during the tutorial to preserve scenario-placed market cards + * until the T7 purchase step completes. + */ + skipMarketCycleOnEndTurn: boolean; } export interface MainStreetSerializedState { @@ -248,6 +268,19 @@ export interface MainStreetSerializedState { numericSeed: number; rngCalls: number; activityLog: LogEntry[]; + activeEffects: ActiveEffect[]; + /** Serialized hand cards. */ + hand: BusinessCard[]; + /** Maximum hand size at the time of save. */ + maxHandSize: number; + /** Serialized discard pile. */ + discardPile: BusinessCard[]; + /** Serialized active staff cards. */ + staffCards: StaffCard[]; + /** Serialized staff card market. */ + staffCardMarket: StaffCard[]; + /** Whether market cycling should be skipped on next end-of-turn. */ + skipMarketCycleOnEndTurn: boolean; } /** Record of a single milestone (tier unlock) achievement. */ @@ -389,11 +422,13 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS // are selected deterministically per-game-seed rather than by template order. const eventDeck = createEventDeck(3, options.unlockedCardIds, rng, config.positiveIncidentMultiplier); const upgradeDeck = createUpgradeDeck(2, options.unlockedCardIds); + const staffDeck = createStaffDeck(1); shuffleArray(businessDeck, rng); shuffleArray(communitySpaceDeck, rng); shuffleArray(eventDeck, rng); shuffleArray(upgradeDeck, rng); + shuffleArray(staffDeck, rng); // Populate initial market // Development row: fill from business deck (community space cards are @@ -467,6 +502,13 @@ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainS rngCalls, rng, activityLog: [], + activeEffects: [], + hand: [], + maxHandSize: 2, + discardPile: [], + staffCards: [], + staffCardMarket: staffDeck, + skipMarketCycleOnEndTurn: false, }; // Select challenges for this run using seeded RNG and config count @@ -506,6 +548,13 @@ export function serializeMainStreetState(state: MainStreetState): MainStreetSeri numericSeed: state.numericSeed, rngCalls: state.rngCalls, activityLog: structuredClone(state.activityLog), + activeEffects: structuredClone(state.activeEffects), + hand: structuredClone(state.hand), + maxHandSize: state.maxHandSize, + discardPile: structuredClone(state.discardPile), + staffCards: structuredClone(state.staffCards), + staffCardMarket: structuredClone(state.staffCardMarket), + skipMarketCycleOnEndTurn: state.skipMarketCycleOnEndTurn, }; } @@ -516,6 +565,7 @@ export function serializeMainStreetState(state: MainStreetState): MainStreetSeri * - `market.business` → `market.development` rename * - Park cards with `family: 'business'` → `family: 'community-space'` * - Missing `communitySpace` deck/discard in old saves + * - Missing `activeEffects` field (defaults to []) */ function migrateSerializedState(saved: Record): void { // ── Market: rename business → development ──────────────── @@ -576,6 +626,33 @@ function migrateSerializedState(saved: Record): void { if (discards && !('communitySpace' in discards)) { discards.communitySpace = []; } + + // ── ActiveEffects: add missing activeEffects field ──────── + if (!('activeEffects' in saved)) { + (saved as Record).activeEffects = []; + } + + // ── Hand management fields (Multi-Use Card Economy) ────── + if (!('hand' in saved)) { + (saved as Record).hand = []; + } + if (!('maxHandSize' in saved)) { + (saved as Record).maxHandSize = 2; + } + if (!('discardPile' in saved)) { + (saved as Record).discardPile = []; + } + if (!('staffCards' in saved)) { + (saved as Record).staffCards = []; + } + if (!('staffCardMarket' in saved)) { + (saved as Record).staffCardMarket = []; + } + + // ── skipMarketCycleOnEndTurn: add missing flag (defaults to false) ─ + if (!('skipMarketCycleOnEndTurn' in saved)) { + (saved as Record).skipMarketCycleOnEndTurn = false; + } } /** @@ -632,6 +709,13 @@ export function deserializeMainStreetState(saved: MainStreetSerializedState): Ma rngCalls: saved.rngCalls, rng, activityLog: structuredClone(saved.activityLog), + activeEffects: structuredClone(saved.activeEffects), + hand: structuredClone(saved.hand), + maxHandSize: saved.maxHandSize, + discardPile: structuredClone(saved.discardPile), + staffCards: structuredClone(saved.staffCards), + staffCardMarket: structuredClone(saved.staffCardMarket), + skipMarketCycleOnEndTurn: saved.skipMarketCycleOnEndTurn ?? false, }; return state; diff --git a/example-games/main-street/MainStreetTiers.ts b/example-games/main-street/MainStreetTiers.ts index aa7b995a..8e029c95 100644 --- a/example-games/main-street/MainStreetTiers.ts +++ b/example-games/main-street/MainStreetTiers.ts @@ -56,7 +56,7 @@ const TIER_1_CARD_IDS: string[] = [ // Upgrade (3) 'upg-patisserie', 'upg-bistro', - 'upg-library', + 'upg-readers-cafe', // Early expanded sample (~10% of expanded set => 5 cards) 'biz-pawnshop', @@ -106,13 +106,17 @@ 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) ───────────────────────────── @@ -122,6 +126,25 @@ const TIER_5_NEW_CARD_IDS: string[] = [ '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', diff --git a/example-games/main-street/MainStreetTranscript.ts b/example-games/main-street/MainStreetTranscript.ts index 131e6d5a..317dcc68 100644 --- a/example-games/main-street/MainStreetTranscript.ts +++ b/example-games/main-street/MainStreetTranscript.ts @@ -11,7 +11,8 @@ export type MainStreetTranscriptEvent = | { type: 'redo'; turn: number; reappliedAction: { description?: string; [k: string]: any } } | { type: 'turn-end'; turn: number } | { type: 'game-end'; turn: number; finalScore: number; result?: any } - | { type: 'info'; turn: number; message: string }; + | { type: 'info'; turn: number; message: string } + | { type: 'active-effect'; turn: number; effectType: string; sourceEventId: string; duration: number; description: string }; export interface MainStreetTranscript { version: number; diff --git a/example-games/main-street/TutorialFlow.ts b/example-games/main-street/TutorialFlow.ts index 38cc8892..476871f1 100644 --- a/example-games/main-street/TutorialFlow.ts +++ b/example-games/main-street/TutorialFlow.ts @@ -13,14 +13,13 @@ * A pure controller manages tutorial progression. This module has NO Phaser * dependency so it can be unit tested in Node. * - * ## Coin Budget Analysis (Tutorial seed, Easy difficulty) + * ## Coin Budget Analysis (TutorialScenario, Easy difficulty) * - * With the fixed tutorial seed and Easy difficulty (12 coins, 5 reputation): + * With the TutorialScenario system and Easy difficulty (12 coins, 5 reputation): * - * - Market business cards: Cinema ($10), **Laundromat ($6)**, Hardware Store ($10), Clinic ($10) - * - Investments: Upgrade to Garden ($3), Upgrade to Bistro ($4), Grand Opening Sale ($2) - * - Incidents in queue: varies by RNG, but per-turn income from the placed business - * ensures sufficient coins remain throughout the 13-step flow. + * - Market development row: Bakery ($6), **Laundromat ($6)**, Park ($4), Hardware Store ($10) + * - Investments: Upgrade to Patisserie ($4), Upgrade to Garden ($3), Local Festival ($3) + * - Incidents in queue: Community Award (+2 rep), Rainy Day (-1 coin per Food) * * ### Budget Walkthrough * @@ -32,9 +31,9 @@ * | T4 | Place business (free) | 0 | 0 | 6 | * | T5 | Confirm (no cost) | 0 | 0 | 6 | * | T6 | End Turn + income (~1 coin)| 1 | 0 | 7 | - * | T7 | Buy Grand Opening Sale ($2)| 0 | 2 | 5 | - * | T8 | Confirm (no cost) | 0 | 0 | 5 | - * | T9 | Confirm (no cost) | 0 | 0 | 5 | + * | T7 | Buy Local Festival ($3) | 0 | 3 | 4 | + * | T8 | Confirm (no cost) | 0 | 0 | 4 | + * | T9 | Confirm (no cost) | 0 | 0 | 4 | * | T10 | Confirm (no cost) | 0 | 0 | ~6 | * | T11 | Confirm (no cost) | 0 | 0 | ~6 | * | T12 | Confirm (no cost) | 0 | 0 | ~6 | @@ -42,12 +41,15 @@ * * **Conclusion:** Even with worst-case incidents, the budget is sufficient * for all tutorial actions. The cheapest viable business card (Laundromat, - * $6) leaves enough coins for the Grand Opening Sale ($2) after one turn's + * $6) leaves enough coins for the Local Festival ($3) after one turn's * income. * * @module */ +import { t } from '../../src/core-engine/I18n'; +import { tutorialKey } from './i18n/tutorial-en'; + // ── Step Types ────────────────────────────────────────────── /** @@ -104,10 +106,16 @@ export type TutorialGateType = 'confirm' | 'action'; export interface UnifiedTutorialStepDef { /** Step identifier (T1, T2, ..., T13). */ id: string; - /** Short title shown in the overlay. */ - title: string; - /** Body copy explaining the concept. */ - body: string; + /** + * i18n key for the short title shown in the overlay. + * Resolve via `t(titleKey)` or `resolveTutorialStepText(step).title`. + */ + titleKey: string; + /** + * i18n key for the body copy explaining the concept. + * Resolve via `t(bodyKey)` or `resolveTutorialStepText(step).body`. + */ + bodyKey: string; /** Screen zone to highlight (null zones: centerModal, completionModal). */ highlightZone: TutorialHighlightZone; /** Whether this step requires a gameplay action to advance. */ @@ -144,135 +152,105 @@ export interface UnifiedTutorialStepDef { export const UNIFIED_TUTORIAL_STEPS: readonly UnifiedTutorialStepDef[] = [ { id: 'T1', - title: 'Welcome to Main Street', - body: - 'Build the best Main Street in 20 turns. I\'ll guide your first few actions.\n\n' + - 'This is "Scenario: Tutorial" — Easy difficulty, 25 turns, and a lower score target.', + titleKey: tutorialKey('T1', 'title'), + bodyKey: tutorialKey('T1', 'body'), highlightZone: 'centerModal', gate: 'confirm', }, { id: 'T2', - title: 'Resource HUD', - body: - 'Track Coins, Reputation, and Score here. Running out of reputation or coins can end your run.', + titleKey: tutorialKey('T2', 'title'), + bodyKey: tutorialKey('T2', 'body'), highlightZone: 'hud', gate: 'confirm', }, { id: 'T3', - title: 'Development Row', - body: - 'Click any card from the Development row to buy it.\n' + - 'Cards go on your street to earn income.\n\n' + - 'Buy the **Laundromat** card (cost $6) — it is the cheapest card and will earn you income each turn.\n\n' + - 'The bottom row shows Investment cards with one-time effects.', + titleKey: tutorialKey('T3', 'title'), + bodyKey: tutorialKey('T3', 'body'), highlightZone: 'marketBusinessRow', gate: 'action', requiredAction: 'select-business', - // With the fixed tutorial seed 'tutorial-seed', the Laundromat (biz-laundromat-0) is - // always at market index 1 and costs $6 (most affordable, leaves 6 coins for later steps). + // The TutorialScenario system (TutorialScenario.ts) guarantees the Laundromat + // (biz-laundromat-0) is present in the development row. It costs $6 (most + // affordable, leaves 6 coins for later steps). requiredCardId: 'biz-laundromat-0', }, { id: 'T4', - title: 'Place a Business', - body: - 'Place this business in a highlighted slot. Adjacent matching types create synergy bonuses.', + titleKey: tutorialKey('T4', 'title'), + bodyKey: tutorialKey('T4', 'body'), highlightZone: 'streetGrid', gate: 'action', requiredAction: 'place-business', }, { id: 'T5', - title: 'Upcoming Incidents', - body: - 'Blue cards show incidents that will hit at the end of each turn — plan around them!\n' + - 'Negative incidents (Tax Audit, Vandalism) cost coins or reputation.\n' + - 'Positive ones help you. Queue scrolls left: the leftmost card fires next.', + titleKey: tutorialKey('T5', 'title'), + bodyKey: tutorialKey('T5', 'body'), highlightZone: 'incidentQueue', gate: 'confirm', }, { id: 'T6', - title: 'End Turn', - body: - 'End Turn resolves income and incidents, then starts a new market day.', + titleKey: tutorialKey('T6', 'title'), + bodyKey: tutorialKey('T6', 'body'), highlightZone: 'endTurnButton', gate: 'action', requiredAction: 'end-turn', }, { id: 'T7', - title: 'Held Event Card', - body: - 'Buy the **Grand Opening Sale** event card from the investments row.\n' + - 'You can hold one event card and play it when timing is best.', + titleKey: tutorialKey('T7', 'title'), + bodyKey: tutorialKey('T7', 'body'), highlightZone: 'investmentsRow', gate: 'action', requiredAction: 'buy-event', - // With the fixed tutorial seed 'tutorial-seed', Grand Opening Sale (evt-grand-opening-15) - // is always at investments index 2 and costs $2 (affordable after T3+T6 income). - requiredCardId: 'evt-grand-opening-15', + // The TutorialScenario system puts Local Festival (evt-festival, $3) + // in the investments row. This is affordable after the T3 Laundromat purchase + // ($6) and T6 income (~1 coin). No specific card is required — the player can + // buy any Investment event card. }, { id: 'T8', - title: 'Upgrade Concept', - body: - 'Upgrades improve an existing business. Strong upgrades compound over remaining turns.', + titleKey: tutorialKey('T8', 'title'), + bodyKey: tutorialKey('T8', 'body'), highlightZone: 'investmentsRow', gate: 'confirm', }, { id: 'T9', - title: 'Your Hand', - body: - 'You can hold one Investment event at a time.\n' + - 'When you buy an event it appears here.\n' + - 'Click the card in your hand to play it for its one-time effect.', + titleKey: tutorialKey('T9', 'title'), + bodyKey: tutorialKey('T9', 'body'), highlightZone: 'centerModal', gate: 'confirm', }, { id: 'T10', - title: 'Action Controls', - body: - 'Use the buttons along the bottom to:\n' + - '• End Turn — collect income and advance the day\n' + - '• Undo / Redo — step back a market action\n' + - '• Hint — get a suggested move\n' + - '• Refresh — swap the investment row (costs coins)\n\n' + - 'You can also press the keyboard shortcut for End Turn (configurable in Settings).', + titleKey: tutorialKey('T10', 'title'), + bodyKey: tutorialKey('T10', 'body'), highlightZone: 'endTurnButton', gate: 'confirm', }, { id: 'T11', - title: 'Challenges', - body: - 'Each run gives you challenges to complete for bonus points (visible in the Challenge Tracker).\n\n' + - 'Completing challenges unlocks new cards for future games —' + - ' the more challenges you complete across runs, the more businesses,' + - ' upgrades, and events you will have access to!', + titleKey: tutorialKey('T11', 'title'), + bodyKey: tutorialKey('T11', 'body'), highlightZone: 'challengePanel', gate: 'confirm', }, { id: 'T12', - title: 'Scoring', - body: - 'Your score is shown at the top of the screen.\n\n' + - 'Final Score = Coins + Reputation × multiplier + Challenges × bonus\n\n' + - 'Reach the target score within the turn limit to win the game — good luck!', + titleKey: tutorialKey('T12', 'title'), + bodyKey: tutorialKey('T12', 'body'), highlightZone: 'hud', gate: 'confirm', }, { id: 'T13', - title: 'Tutorial Complete', - body: - 'Great job! You\'re ready for a full run. Tutorial can be replayed from menu/settings.', + titleKey: tutorialKey('T13', 'title'), + bodyKey: tutorialKey('T13', 'body'), highlightZone: 'completionModal', gate: 'confirm', }, @@ -380,3 +358,25 @@ export function shouldAllowAction( if (!state.isActive) return true; return isRequiredAction(state, actionType); } + +// ── i18n Resolution ───────────────────────────────────────── + +/** + * Resolve a tutorial step's title and body through the i18n system. + * + * Looks up `step.titleKey` and `step.bodyKey` via `t()`, falling back to + * the key itself if no locale bundle has been registered. + * + * Utility glue for the overlay manager (`MainStreetTutorialHints`). + */ +export function resolveTutorialStepText( + step: UnifiedTutorialStepDef, +): { title: string; body: string } { + return { + title: t(step.titleKey), + body: t(step.bodyKey), + }; +} + +/** Re-export `tutorialKey` for convenience. */ +export { tutorialKey } from './i18n/tutorial-en'; diff --git a/example-games/main-street/TutorialScenario.ts b/example-games/main-street/TutorialScenario.ts new file mode 100644 index 00000000..2b9aa8f4 --- /dev/null +++ b/example-games/main-street/TutorialScenario.ts @@ -0,0 +1,351 @@ +/** + * Main Street: Tutorial Scenario System + * + * Defines a scenario mechanism for pre-building the exact game state for the + * Main Street tutorial, without relying on seed-based shuffling or RNG-driven + * card selection. + * + * The system is designed with future scenario/sandbox modes in mind: the + * `TutorialScenario` interface can be reused by non-tutorial scenario + * definitions (e.g., sandbox mode, pre-built challenges) without requiring + * rework. Those use cases are explicitly out of scope for this module. + * + * ## Architecture + * + * - `TutorialScenario` — A data-only interface describing the desired initial + * game state (market cards, resources, incident queue, difficulty). + * - `STANDARD_TUTORIAL_SCENARIO` — The concrete scenario used by the + * Main Street tutorial. All card IDs reference Tier-1 cards only. + * - `createTutorialScenario()` — Builds a fully initialised `MainStreetState` + * from the scenario definition, using the standard deck builders and then + * explicitly extracting and placing the scenario's cards into the market + * and incident queue. Cards are identified by their **base template ID** + * (without copy/serial suffix) so the scenario is robust across deck + * construction ordering. + * + * ## Coin Budget (Easy / 12 coins) + * + * | Step | Action | Coins In | Coins Out | Balance | + * |------|----------------------------|----------|-----------|---------| + * | T1 | Start (Easy, 12 coins) | 12 | 0 | 12 | + * | T2 | Confirm (no cost) | 0 | 0 | 12 | + * | T3 | Buy Laundromat ($6) | 0 | 6 | 6 | + * | T4 | Place business (free) | 0 | 0 | 6 | + * | T5 | Confirm (no cost) | 0 | 0 | 6 | + * | T6 | End Turn + income (~1 coin)| 1 | 0 | 7 | + * | T7 | Buy event ($3) | 0 | 3 | 4 | + * | T8+ | Confirm steps (no cost) | 0 | 0 | ≥5 | + * + * @module + */ + +import type { MainStreetState, ResourceBank } from './MainStreetState'; +import { + type BusinessCard, + type EventCard, + type UpgradeCard, + type CommunitySpaceCard, + MARKET_BUSINESS_SLOTS, + MARKET_INVESTMENT_UPGRADE_COUNT, + MARKET_INVESTMENT_EVENT_COUNT, + INCIDENT_QUEUE_SIZE, + GRID_SIZE, + createBusinessDeck, + createCommunitySpaceDeck, + createEventDeck, + createUpgradeDeck, +} from './MainStreetCards'; +import { getPreset, type DifficultyName } from './MainStreetDifficulty'; +import { deriveUnlockedCardIds } from './MainStreetTiers'; +import { createSeededRng } from '../../src/core-engine'; +import { createEconomyLedger } from '../../src/rule-engine/EconomyLedger'; +import { CHALLENGE_TEMPLATES, selectChallenges } from './MainStreetChallenges'; + +// ── Scenario Interface ─────────────────────────────────────── + +/** + * Describes a pre-built game state for a Main Street scenario. + * + * The scenario explicitly defines which cards appear in the market, + * incident queue, and the player's starting resources — bypassing all + * seed-based shuffling. + * + * Card IDs are **base template IDs** (without copy/serial suffix). + * For example, `'biz-laundromat'` matches any instance of the Laundromat + * card regardless of its copy number (`biz-laundromat-0`, `biz-laundromat-1`, + * etc.). This makes the scenario robust across deck construction order. + * + * This interface is designed to be reusable for non-tutorial scenarios + * (e.g., sandbox mode, pre-built challenges) by simply changing the + * card IDs and resource values. + */ +export interface TutorialScenario { + /** Difficulty preset. Tutorial always uses Easy. */ + difficulty: DifficultyName; + /** Starting resources. */ + resourceBank: ResourceBank; + /** Base template IDs for the development row (exactly MARKET_BUSINESS_SLOTS). */ + market: { + development: string[]; + investments: string[]; + }; + /** Base template IDs for the incident queue (exactly INCIDENT_QUEUE_SIZE). */ + incidentQueue: string[]; + /** + * Seed string for deterministic RNG (used for challenge selection and + * any remaining RNG-dependent game mechanics). Does NOT affect market + * or incident queue composition — those are driven by card IDs above. + */ + seed: string; +} + +// ── Standard Tutorial Scenario ─────────────────────────────── + +/** + * The concrete tutorial scenario used by the Main Street tutorial. + * + * All card IDs reference Tier-1 pool cards. The market is set up so that: + * + * **Development Row (4 slots):** + * - `biz-bakery` (Bakery, $6, Food) + * - `biz-laundromat` (Laundromat, $6, Service) — T3 purchase target + * - `cs-park` (Park, $4, Culture) + * - `biz-hardware` (Hardware Store, $10, Commerce) + * + * **Investments Row (3 slots: 2 upgrades + 1 investment event):** + * - `upg-patisserie` (Upgrade to Patisserie, $4, targets Bakery) + * - `upg-garden` (Upgrade to Garden, $3, targets Park) + * - `evt-festival` (Local Festival, $3) — T7 purchase target + * + * **Incident Queue (2 cards):** + * - `evt-award` (Community Award, +2 reputation) + * - `evt-rainy` (Rainy Day, -1 coin per Food business) + * + * **Coin Budget:** 12 starting (Easy), $6 Laundromat, $3 Local Festival, + * remaining ≥3 coins after both purchases. RNG-independent. + */ +export const STANDARD_TUTORIAL_SCENARIO: TutorialScenario = { + difficulty: 'Easy', + resourceBank: { coins: 12, reputation: 5 }, + market: { + development: [ + 'biz-bakery', + 'biz-laundromat', + 'cs-park', + 'biz-hardware', + ], + investments: [ + 'upg-patisserie', + 'upg-garden', + 'evt-festival', + ], + }, + incidentQueue: [ + 'evt-award', + 'evt-rainy', + ], + seed: 'tutorial-scenario', +}; + +// ── State Builder ──────────────────────────────────────────── + +/** + * Finds and removes a card from an array by matching its ID against a + * base template ID (without copy/serial suffix). A card matches if its + * `.id` field starts with the given `templateId`. + * + * E.g., `findCardByTemplate(deck, 'biz-laundromat')` matches + * `biz-laundromat-0`, `biz-laundromat-1`, etc. + * + * Mutates the array in place. Throws if no card with the given template + * ID is found. + */ +function findCardByTemplate( + deck: T[], + templateId: string, +): T { + const idx = deck.findIndex(c => c.id.startsWith(templateId)); + if (idx === -1) { + throw new Error( + `TutorialScenario: no card matching template "${templateId}" found in deck. ` + + 'Has it been removed from the Tier-1 card pool?', + ); + } + return deck.splice(idx, 1)[0]; +} + +/** + * Builds a fully initialised `MainStreetState` from a `TutorialScenario` + * definition. + * + * Process: + * 1. Build all four decks using the standard deck builders filtered to + * Tier-1 cards. + * 2. Find cards matching each scenario template ID in the appropriate + * decks and extract them. + * 3. Place extracted cards into the market (development / investments) + * and incident queue. + * 4. Build and return the complete `MainStreetState` with remaining deck + * contents and a deterministic RNG for challenge selection. + * + * @param scenario The scenario definition (defaults to STANDARD_TUTORIAL_SCENARIO). + * @returns A fully initialised MainStreetState ready for day 1. + */ +export function createTutorialScenario( + scenario: TutorialScenario = STANDARD_TUTORIAL_SCENARIO, +): MainStreetState { + // ── Resolve config ──────────────────────────────────────── + const config = getPreset(scenario.difficulty); + const tier1Ids = deriveUnlockedCardIds(['tier-1']); + + // Validate scenario card counts + if (scenario.market.development.length !== MARKET_BUSINESS_SLOTS) { + throw new Error( + `TutorialScenario: expected ${MARKET_BUSINESS_SLOTS} development row cards, ` + + `got ${scenario.market.development.length}`, + ); + } + if (scenario.market.investments.length !== MARKET_INVESTMENT_UPGRADE_COUNT + MARKET_INVESTMENT_EVENT_COUNT) { + throw new Error( + `TutorialScenario: expected ${MARKET_INVESTMENT_UPGRADE_COUNT + MARKET_INVESTMENT_EVENT_COUNT} investments row cards, ` + + `got ${scenario.market.investments.length}`, + ); + } + if (scenario.incidentQueue.length !== INCIDENT_QUEUE_SIZE) { + throw new Error( + `TutorialScenario: expected ${INCIDENT_QUEUE_SIZE} incident queue cards, ` + + `got ${scenario.incidentQueue.length}`, + ); + } + + // ── Build decks ─────────────────────────────────────────── + const businessDeck: BusinessCard[] = createBusinessDeck(3, tier1Ids); + const communitySpaceDeck: CommunitySpaceCard[] = createCommunitySpaceDeck(3, tier1Ids); + const eventDeck: EventCard[] = createEventDeck(3, tier1Ids, () => 0); + const upgradeDeck: UpgradeCard[] = createUpgradeDeck(2, tier1Ids); + + // ── Extract market cards from decks by base template ID ──── + + // Development row: try business deck, then community space deck + const developmentRow: (BusinessCard | CommunitySpaceCard)[] = []; + for (const templateId of scenario.market.development) { + try { + developmentRow.push(findCardByTemplate(businessDeck, templateId)); + } catch { + // Not in business deck — try community space deck + developmentRow.push(findCardByTemplate(communitySpaceDeck, templateId)); + } + } + + // Investments row: try upgrade deck first, then event deck + const investmentsRow: (UpgradeCard | EventCard)[] = []; + for (const templateId of scenario.market.investments) { + try { + investmentsRow.push(findCardByTemplate(upgradeDeck, templateId)); + } catch { + investmentsRow.push(findCardByTemplate(eventDeck, templateId)); + } + } + + // Incident queue: from event deck + const incidentQueue: EventCard[] = []; + for (const templateId of scenario.incidentQueue) { + incidentQueue.push(findCardByTemplate(eventDeck, templateId)); + } + + // ── Setup deterministic RNG ─────────────────────────────── + // Use a simple seeded RNG for challenge selection and any + // RNG-dependent game mechanics. The market composition is NOT + // affected by this RNG — it's explicitly defined by the scenario. + const numericSeed = hashString(scenario.seed); + const baseRng = createSeededRng(numericSeed); + let rngCalls = 0; + let state!: MainStreetState; + const rng = (): number => { + rngCalls += 1; + state.rngCalls = rngCalls; + return baseRng(); + }; + + // ── Build state ─────────────────────────────────────────── + const initCoins = scenario.resourceBank.coins; + const initRep = scenario.resourceBank.reputation; + + state = { + config, + turn: 1, + phase: 'DayStart', + streetGrid: new Array(GRID_SIZE).fill(null), + market: { + development: developmentRow, + investments: investmentsRow, + }, + resourceBank: { + coins: initCoins, + reputation: initRep, + }, + ledger: createEconomyLedger({ + coins: initCoins, + reputation: initRep, + score: 0, + }), + decks: { + business: businessDeck, + communitySpace: communitySpaceDeck, + event: eventDeck, + upgrade: upgradeDeck, + }, + discards: { + business: [], + communitySpace: [], + event: [], + upgrade: [], + }, + challengesCompleted: [], + activeChallenges: [], + heldEvent: null, + incidentQueue, + gameResult: 'playing', + endReason: null, + finalScore: 0, + seed: scenario.seed, + numericSeed, + rngCalls, + rng, + activityLog: [], + activeEffects: [], + hand: [], + maxHandSize: 2, + discardPile: [], + staffCards: [], + staffCardMarket: [], + skipMarketCycleOnEndTurn: false, + }; + + // Select challenges for this run using seeded RNG + const selectedChallenges = selectChallenges( + CHALLENGE_TEMPLATES, + config.challengesPerRun, + rng, + ); + state.activeChallenges = selectedChallenges.map(ch => ({ + challenge: ch, + completed: false, + })); + + return state; +} + +// ── Helpers ────────────────────────────────────────────────── + +/** + * Simple string hash (djb2) used for converting the scenario seed string + * into a numeric seed. + */ +function hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash; +} diff --git a/example-games/main-street/TutorialState.ts b/example-games/main-street/TutorialState.ts index c227ddea..5e3ae35c 100644 --- a/example-games/main-street/TutorialState.ts +++ b/example-games/main-street/TutorialState.ts @@ -13,15 +13,13 @@ // ── Tutorial Constants ──────────────────────────────────────── /** - * Fixed seed used when the tutorial is active. + * @deprecated Since the TutorialScenario system (CG-0MQSR0H7Z0049BAD). + * The tutorial now uses `createTutorialScenario()` from `TutorialScenario.ts` + * instead of seed-based shuffling. This seed constant is retained only for + * backward compatibility with any external references and will be removed + * in a future cleanup pass. * - * This seed ensures the tutorial always presents the same cards in the same - * order, making the tutorial fully deterministic and playable end-to-end - * without running out of money or encountering impossible actions. - * - * The seed is NOT persisted to any storage — it is purely for tutorial - * gameplay and is only used when the tutorial controller is active. - * Normal gameplay uses a random seed. + * See `TutorialScenario.ts` for the new scenario-based approach. */ export const TUTORIAL_SEED = 'tutorial-seed'; @@ -187,8 +185,8 @@ export function shouldShowTutorialOffer( ): boolean { if (opts.forceShowOffer) return true; if (opts.replayMode || opts.disableTutorial) return false; - if (state.status === 'completed') return false; - // 'not_seen' and 'skipped' both show the offer + if (state.status === 'completed' || state.status === 'skipped') return false; + // Only 'not_seen' shows the offer return true; } diff --git a/example-games/main-street/i18n/tutorial-en.ts b/example-games/main-street/i18n/tutorial-en.ts new file mode 100644 index 00000000..27694b30 --- /dev/null +++ b/example-games/main-street/i18n/tutorial-en.ts @@ -0,0 +1,185 @@ +/** + * Main Street Tutorial — English locale bundle. + * + * Contains all user-facing string values for: + * - T1–T13 tutorial step titles and bodies + * - Tutorial offer modal (title, body, skip/start buttons) + * - Tutorial overlay buttons (dismiss, next, exit, start full game) + * + * The i18n keys follow these conventions: + * - Step text: `tutorial..title` and `tutorial..body` + * - Modal: `tutorial.modal.` + * - Overlay: `tutorial.overlay.` + * + * To add a new language variant: + * 1. Create `tutorial-.ts` with the translated bundle. + * 2. Import and call `registerLocale('', bundle)` at startup. + * + * @module + */ + +/** + * The i18n key prefix for tutorial step strings. + * Each step's title is at `${KEY_PREFIX}..title` + * Each step's body is at `${KEY_PREFIX}..body` + * + * Offer modal keys: + * ${KEY_PREFIX}.modal.title + * ${KEY_PREFIX}.modal.body + * ${KEY_PREFIX}.modal.skipBtn + * ${KEY_PREFIX}.modal.startBtn + * + * Overlay button keys: + * ${KEY_PREFIX}.overlay.dismiss + * ${KEY_PREFIX}.overlay.next + * ${KEY_PREFIX}.overlay.exit + * ${KEY_PREFIX}.overlay.startFullGame + */ +export const TUTORIAL_I18N_KEY_PREFIX = 'tutorial'; + +/** + * Build the i18n key for a tutorial step's title. + * @example `tutorialKey('T3', 'title')` → `'tutorial.T3.title'` + */ +export function tutorialKey(stepId: string, field: 'title' | 'body'): string { + return `${TUTORIAL_I18N_KEY_PREFIX}.${stepId}.${field}`; +} + +/** + * Helper to build keys for tutorial modal and overlay UI strings. + * @example modalKey('title') → 'tutorial.modal.title' + * @example overlayKey('dismiss') → 'tutorial.overlay.dismiss' + */ +export function modalKey(field: string): string { + return `${TUTORIAL_I18N_KEY_PREFIX}.modal.${field}`; +} + +/** + * Helper to build keys for tutorial overlay button labels. + * @example overlayKey('dismiss') → 'tutorial.overlay.dismiss' + */ +export function overlayKey(field: string): string { + return `${TUTORIAL_I18N_KEY_PREFIX}.overlay.${field}`; +} + +/** + * English locale bundle for all 13 tutorial step strings. + * + * Maps i18n keys (e.g. `tutorial.T1.title`) to English string values. + */ +export const TUTORIAL_EN_BUNDLE: Record = { + // ── Offer Modal ──────────────────────────────────────────── + [modalKey('title')]: + 'Welcome to Main Street!', + [modalKey('body')]: + 'Would you like a tour to learn the basics of Main Street?', + [modalKey('skipBtn')]: + 'Skip', + [modalKey('startBtn')]: + 'Start Tutorial', + + // ── Overlay Buttons ──────────────────────────────────────── + [overlayKey('dismiss')]: + 'Dismiss', + [overlayKey('next')]: + 'Next >', + [overlayKey('exit')]: + 'Exit Tutorial', + [overlayKey('startFullGame')]: + 'Start Full Game', + + + // ── T1: Welcome ───────────────────────────────────────────── + [tutorialKey('T1', 'title')]: + 'Welcome to Main Street', + [tutorialKey('T1', 'body')]: + 'Build the best Main Street! You have 25 turns to reach the score target. I\'ll guide your first few actions.', + + // ── T2: Resource HUD ──────────────────────────────────────── + [tutorialKey('T2', 'title')]: + 'Resource HUD', + [tutorialKey('T2', 'body')]: + 'Watch your Coins, Reputation, and Score here. Running out of coins or reputation can end your game.', + + // ── T3: Development Row ───────────────────────────────────── + [tutorialKey('T3', 'title')]: + 'Development Row', + [tutorialKey('T3', 'body')]: + 'Buy the **Laundromat** card from the Development row for $6. It is the cheapest card and earns money each turn. Place cards on your street to earn income.', + + // ── T4: Place a Business ──────────────────────────────────── + [tutorialKey('T4', 'title')]: + 'Place a Business', + [tutorialKey('T4', 'body')]: + 'Place this card in a highlighted slot. Matching cards next to each other give bonus income.', + + // ── T5: Upcoming Incidents ────────────────────────────────── + [tutorialKey('T5', 'title')]: + 'Upcoming Incidents', + [tutorialKey('T5', 'body')]: + 'Blue cards show events that happen at the end of each turn. Plan around them!\n' + + 'Bad events cost coins or reputation. Good events help you. The leftmost card happens next.', + + // ── T6: End Turn ──────────────────────────────────────────── + [tutorialKey('T6', 'title')]: + 'End Turn', + [tutorialKey('T6', 'body')]: + 'End Turn collects your income and starts the next day. Events happen after income.', + + // ── T7: Held Event Card ───────────────────────────────────── + [tutorialKey('T7', 'title')]: + 'Held Event Card', + [tutorialKey('T7', 'body')]: + 'Buy the **Local Festival** card from the investments row.\n' + + 'You can hold one event card and play it when the time is right.', + + // ── T8: Upgrade Concept ───────────────────────────────────── + [tutorialKey('T8', 'title')]: + 'Upgrade Concept', + [tutorialKey('T8', 'body')]: + 'Upgrades make a business better. Strong upgrades earn more money over time.', + + // ── T9: Your Hand ─────────────────────────────────────────── + [tutorialKey('T9', 'title')]: + 'Your Hand', + [tutorialKey('T9', 'body')]: + 'You can hold one event card at a time.\n' + + 'When you buy an event, it appears here.\n' + + 'Click the card in your hand to play it.', + + // ── T10: Action Controls ──────────────────────────────────── + [tutorialKey('T10', 'title')]: + 'Action Controls', + [tutorialKey('T10', 'body')]: + 'Use the buttons at the bottom:\n' + + '• End Turn — collect income and advance\n' + + '• Undo / Redo — go back or forward\n' + + '• Hint — get a suggested move\n' + + '• Refresh — swap the investment row (costs coins)', + + // ── T11: Challenges ───────────────────────────────────────── + [tutorialKey('T11', 'title')]: + 'Challenges', + [tutorialKey('T11', 'body')]: + 'Each game gives you challenges for bonus points. See them in the Challenge Tracker.\n\n' + + 'Completing challenges unlocks new cards for future games!', + + // ── T12: Scoring ──────────────────────────────────────────── + [tutorialKey('T12', 'title')]: + 'Scoring', + [tutorialKey('T12', 'body')]: + 'Your score appears at the top of the screen.\n\n' + + 'Final Score = Coins + Reputation + Challenge bonuses\n\n' + + 'Reach the target score before running out of turns to win!', + + // ── T13: Tutorial Complete ────────────────────────────────── + [tutorialKey('T13', 'title')]: + 'Tutorial Complete', + [tutorialKey('T13', 'body')]: + 'Great job! You are ready to play a full game. Find the tutorial again in the settings menu.', +} as const; + +// Re-export helpers +// tutorialKey is already exported above; +// modalKey and overlayKey are new exports above. + diff --git a/example-games/main-street/layouts/main-street-tutorial.layout.json b/example-games/main-street/layouts/main-street-tutorial.layout.json index 977ba15a..76d2fd89 100644 --- a/example-games/main-street/layouts/main-street-tutorial.layout.json +++ b/example-games/main-street/layouts/main-street-tutorial.layout.json @@ -17,9 +17,9 @@ "zones": { "hud": { "rect": { - "x": 0, + "x": 0.25, "y": 0.05, - "w": 1, + "w": 0.5, "h": 0.038889 }, "anchors": { @@ -30,66 +30,66 @@ "rect": { "x": 0.015625, "y": 0.111111, - "w": 0.575, - "h": 0.144444 + "w": 0.71875, + "h": 0.302778 }, "anchors": { - "topCenter": { "x": 0.5, "y": 0.125 } + "topCenter": { "x": 0.375, "y": 0.125 } } }, "streetGrid": { "rect": { "x": 0, - "y": 0.601389, - "w": 1, + "y": 0.444444, + "w": 0.73, "h": 0.255556 }, "anchors": { - "topCenter": { "x": 0.5, "y": 0.586111 } + "topCenter": { "x": 0.5, "y": 0.444444 } } }, "endTurnButton": { "rect": { - "x": 0.85625, - "y": 0.894444, - "w": 0.125, - "h": 0.058333 + "x": 0.871875, + "y": 0.905556, + "w": 0.109375, + "h": 0.044444 }, "anchors": { - "center": { "x": 0.926563, "y": 0.954167 } + "center": { "x": 0.926563, "y": 0.927778 } } }, "incidentQueue": { "rect": { - "x": 0.015625, - "y": 0.436111, - "w": 0.329688, - "h": 0.133333 + "x": 0.75, + "y": 0.566667, + "w": 0.234375, + "h": 0.294444 }, "anchors": { - "topLeft": { "x": 0.085938, "y": 0.444444 } + "topLeft": { "x": 0.75, "y": 0.566667 } } }, "investmentsRow": { "rect": { "x": 0.015625, "y": 0.269444, - "w": 0.575, + "w": 0.71875, "h": 0.130556 }, "anchors": { - "topCenter": { "x": 0.5, "y": 0.25 } + "topCenter": { "x": 0.375, "y": 0.277778 } } }, "helpButton": { "rect": { - "x": 0.90625, - "y": 0.894444, - "w": 0.078125, - "h": 0.058333 + "x": 0.78125, + "y": 0.905556, + "w": 0.08125, + "h": 0.047222 }, "anchors": { - "center": { "x": 0.890625, "y": 0.954167 } + "center": { "x": 0.821875, "y": 0.927778 } } } } diff --git a/example-games/main-street/layouts/main-street.layout.json b/example-games/main-street/layouts/main-street.layout.json index 0315b3d9..7abcd1ff 100644 --- a/example-games/main-street/layouts/main-street.layout.json +++ b/example-games/main-street/layouts/main-street.layout.json @@ -24,22 +24,22 @@ }, "incidentQueue": { "rect": { - "x": 0.085938, - "y": 0.444444 + "x": 0.75, + "y": 0.567 }, "anchors": { - "topLeft": { "x": 0.085938, "y": 0.444444 }, - "bottomLeft": { "x": 0.085938, "y": 0.597222 } + "topLeft": { "x": 0.75, "y": 0.567 }, + "bottomLeft": { "x": 0.75, "y": 0.861111 } } }, "street": { "rect": { "x": 0.265625, - "y": 0.586111 + "y": 0.444444 }, "anchors": { - "topCenter": { "x": 0.5, "y": 0.586111 }, - "bottomCenter": { "x": 0.5, "y": 0.858333 } + "topCenter": { "x": 0.3203125, "y": 0.444444 }, + "bottomCenter": { "x": 0.3203125, "y": 0.716667 } } }, "hand": { @@ -62,22 +62,24 @@ }, "activityLog": { "rect": { - "x": 0.640625, + "x": 0.75, "y": 0.111111 }, "anchors": { - "topLeft": { "x": 0.640625, "y": 0.111111 }, - "bottomRight": { "x": 0.976563, "y": 0.583333 } + "topLeft": { "x": 0.75, "y": 0.111111 }, + "bottomRight": { "x": 0.984375, "y": 0.414 } } }, "challengePanel": { "rect": { - "x": 0.359375, - "y": 0.444444 + "x": 0.75, + "y": 0.414, + "w": 0.234375, + "h": 0.153 }, "anchors": { - "topLeft": { "x": 0.359375, "y": 0.444444 }, - "bottomRight": { "x": 0.632813, "y": 0.597222 } + "topLeft": { "x": 0.75, "y": 0.414 }, + "bottomRight": { "x": 0.984375, "y": 0.567 } } }, "endTurnButton": { diff --git a/example-games/main-street/scenes/MainStreetAnimator.ts b/example-games/main-street/scenes/MainStreetAnimator.ts index 1917cd7a..2962d64f 100644 --- a/example-games/main-street/scenes/MainStreetAnimator.ts +++ b/example-games/main-street/scenes/MainStreetAnimator.ts @@ -44,6 +44,10 @@ export class MainStreetAnimator { try { if (delta > 0) { try { s.gameEvents?.emit('income-gained', { amount: delta }); } catch (_) {} + } else if (delta < 0) { + try { s.soundManager?.play(SFX_KEYS.INCOME_NEGATIVE); } catch (_) {} + } else { + try { s.soundManager?.play(SFX_KEYS.INCOME_NEUTRAL); } catch (_) {} } } catch (_) {} } @@ -70,6 +74,104 @@ export class MainStreetAnimator { s.previousReputation = reputation; } + /** + * Plays a celebration VFX (particle burst + pop text) and sound for a + * newly completed challenge. + * + * Centers the effect on the challenge tracker panel. Respects the + * reduced-motion accessibility setting: when enabled, only a brief pop + * text is shown (no particles). Falls back to pop text if the Phaser + * particle system is unavailable. + * + * @param challengeTitle The title of the completed challenge (for pop text). + * @returns A promise that resolves when the celebration animation finishes. + */ + public animateCelebration(challengeTitle: string): Promise { + const s = this.scene; + const reducedMotion = s.settingsPanel?.reducedMotion; + + // Center of the challenge tracker panel + const cx = s.layout.challengeX + s.layout.challengeW / 2; + const cy = s.layout.challengeY + 30; + + // Play the celebration sound + try { + s.soundManager?.play(SFX_KEYS.CELEBRATE); + } catch (_) { /* ignore */ } + + if (reducedMotion) { + // Reduced-motion: pop text only (no particles) + return popTextOrIcon({ + scene: s, + label: `\uD83C\uDF89 ${challengeTitle}`, + x: cx, + y: cy, + duration: 200, + reducedMotion: true, + scale: 1.5, + }); + } + + // Try particle burst + try { + const particleKey = 'celebrate-particle'; + if (!s.textures.exists(particleKey)) { + const g = s.add.graphics(); + g.fillStyle(0xffdd44, 1); + g.fillCircle(4, 4, 4); + g.generateTexture(particleKey, 8, 8); + g.destroy(); + } + + const texture = s.textures.get(particleKey); + if (texture && s.add.particles) { + const emitter = s.add.particles(cx, cy, particleKey, { + speed: { min: 60, max: 200 }, + angle: { min: 0, max: 360 }, + scale: { start: 0.8, end: 0 }, + lifespan: 1000, + quantity: 25, + emitting: false, + tint: [0xffdd44, 0x44ff44, 0x44aaff, 0xff6644, 0xdd88ff], + }); + + emitter.explode(25); + + // Show pop text alongside particles + void popTextOrIcon({ + scene: s, + label: `\uD83C\uDF89 ${challengeTitle}`, + x: cx, + y: cy - 30, + duration: 1500, + scale: 1.3, + riseY: 40, + style: { fontSize: '16px', fontStyle: 'bold', color: '#ffdd44' }, + }); + + // Clean up after particles finish + return new Promise((resolve) => { + s.time.delayedCall(1500, () => { + try { emitter.destroy(); } catch (_) { /* ignore */ } + resolve(); + }); + }); + } + } catch (_) { /* ignore */ } + + // Fallback: pop text if particle system unavailable or errored + return popTextOrIcon({ + scene: s, + label: `\uD83C\uDF89 ${challengeTitle}`, + x: cx, + y: cy, + duration: 600, + scale: 2, + riseY: 40, + style: { fontSize: '18px', fontStyle: 'bold', color: '#ffdd44' }, + }); + } + public getMarketCardCenter(row: 'development' | 'investments', slotIndex: number): { x: number; y: number } | null { const s = this.scene; if (slotIndex < 0) return null; diff --git a/example-games/main-street/scenes/MainStreetConstants.ts b/example-games/main-street/scenes/MainStreetConstants.ts index a3dd9a89..60d76e47 100644 --- a/example-games/main-street/scenes/MainStreetConstants.ts +++ b/example-games/main-street/scenes/MainStreetConstants.ts @@ -24,7 +24,7 @@ export const BASE_QUEUE_CARD_GAP = 10; // Make street slots match market placeholder size (market slots: 140x80) export const BASE_SLOT_W = 140; export const BASE_SLOT_H = 80; -export const BASE_SLOT_GAP = 10; +export const BASE_SLOT_GAP = 20; export const STREET_COLS = 5; export const STREET_ROWS = 2; export const STREET_ROW_GAP = 12; @@ -43,6 +43,9 @@ export const SFX_KEYS = { PLACE: 'sfx-place', DISCARD: 'sfx-discard', COIN_POP: 'sfx-coin-pop', + INCOME_POSITIVE: 'sfx-income-positive', + INCOME_NEGATIVE: 'sfx-income-negative', + INCOME_NEUTRAL: 'sfx-income-neutral', CLICK: COMMON_SFX_KEYS.UI_CLICK, BG_LOOP: 'sfx-bg-loop', BUSINESS_START: 'sfx-business-start', @@ -50,6 +53,7 @@ export const SFX_KEYS = { UPGRADE_START: 'sfx-upgrade-start', UPGRADE_END: 'sfx-upgrade-end', EVENT_CHEER: 'sfx-event-cheer', + CELEBRATE: 'sfx-challenge-complete', } as const; // Activity Log panel layout @@ -88,6 +92,7 @@ export interface SceneLayout { queueCardH: number; queueCardGap: number; queueLabelW: number; + eventsHeight: number; streetTop: number; slotW: number; slotH: number; diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts index bac93228..5aa17669 100644 --- a/example-games/main-street/scenes/MainStreetHudTooltips.ts +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -40,6 +40,11 @@ export const HUD_TOOLTIP_I18N_KEYS = { repEffectLabel: 'hud.tooltip.rep.effect', scoreTitle: 'hud.tooltip.score.title', scoreEstimateLabel: 'hud.tooltip.score.estimate', + scoreBreakdownCoins: 'hud.tooltip.score.breakdownCoins', + scoreBreakdownReputation: 'hud.tooltip.score.breakdownReputation', + scoreBreakdownChallenges: 'hud.tooltip.score.breakdownChallenges', + scoreRemainingToWin: 'hud.tooltip.score.remainingToWin', + scoreThresholdMet: 'hud.tooltip.score.thresholdMet', scoreNextTierLabel: 'hud.tooltip.score.nextTier', scoreAllTiersUnlocked: 'hud.tooltip.score.allTiersUnlocked', } as const; @@ -75,6 +80,11 @@ export const HUD_TOOLTIP_STRINGS = { repEffectLabel: 'Higher reputation multiplies coin income (capped)', scoreTitle: 'Score Estimate', scoreEstimateLabel: 'Estimated score', + scoreBreakdownCoins: 'Coins', + scoreBreakdownReputation: 'Reputation ×', + scoreBreakdownChallenges: 'Challenges', + scoreRemainingToWin: 'more needed to win', + scoreThresholdMet: 'Win threshold met!', scoreNextTierLabel: 'Next tier', scoreAllTiersUnlocked: 'All tiers unlocked', } as const; @@ -165,7 +175,9 @@ export function buildReputationTooltip(state: MainStreetState): string { * Builds the tooltip content string for the Score HUD element. * * Shows: - * - Current final-score estimate + * - Current final-score estimate in "x / y" format (where y is the win threshold) + * - Score breakdown by source (coins, reputation multiplier, challenge bonus) + * - How close the player is to the win threshold * - Next locked tier name and reputation threshold (or "All tiers unlocked") */ export function buildScoreTooltip( @@ -173,6 +185,14 @@ export function buildScoreTooltip( campaign: MainStreetCampaignProgress | null, ): string { const score = computeScore(state); + const threshold = state.config.winThreshold; + + // Score breakdown components + const coins = state.resourceBank.coins; + const rep = state.resourceBank.reputation; + const repContribution = rep * state.config.reputationScoreMultiplier; + const challengeContribution = state.challengesCompleted.length * state.config.challengeBonusPoints; + const remaining = threshold - score; // Determine next locked tier const unlockedTiers = campaign?.unlockedTiers ?? ['tier-1']; @@ -180,15 +200,35 @@ export function buildScoreTooltip( const lines = [ t(HUD_TOOLTIP_I18N_KEYS.scoreTitle), - `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}/${threshold}`, + '', + `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownCoins)}: ${coins}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownReputation)} ${state.config.reputationScoreMultiplier}: ${repContribution}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownChallenges)}: ${challengeContribution}`, ]; + if (remaining > 0) { + lines.push( + '', + `${remaining} ${t(HUD_TOOLTIP_I18N_KEYS.scoreRemainingToWin)}`, + ); + } else { + lines.push( + '', + t(HUD_TOOLTIP_I18N_KEYS.scoreThresholdMet), + ); + } + if (nextTier) { lines.push( + '', `${t(HUD_TOOLTIP_I18N_KEYS.scoreNextTierLabel)}: ${nextTier.name} (requires Rep ≥ ${nextTier.reputationThreshold})`, ); } else { - lines.push(t(HUD_TOOLTIP_I18N_KEYS.scoreAllTiersUnlocked)); + lines.push( + '', + t(HUD_TOOLTIP_I18N_KEYS.scoreAllTiersUnlocked), + ); } return lines.join('\n'); diff --git a/example-games/main-street/scenes/MainStreetInputManager.ts b/example-games/main-street/scenes/MainStreetInputManager.ts index 5e655373..32b8d5a0 100644 --- a/example-games/main-street/scenes/MainStreetInputManager.ts +++ b/example-games/main-street/scenes/MainStreetInputManager.ts @@ -111,7 +111,7 @@ export class MainStreetInputManager { const s = this.scene; if (!s.logMaskGraphics) return; s.logMaskGraphics.clear(); - s.logMaskGraphics.fillStyle(0xffffff); + s.logMaskGraphics.fillStyle(0xffffff, 0); s.logMaskGraphics.fillRect( s.layout.logX, s.layout.logY + LOG_TITLE_H, @@ -144,12 +144,27 @@ export class MainStreetInputManager { const BOTTOM_THRESHOLD = 4; s.logAutoScroll = s.logScrollOffset >= s.logMaxScroll - BOTTOM_THRESHOLD; - s.applyLogScroll(); + // Container re-rendering and mask update is handled by the caller (scene.handleLogWheel -> refreshLog) } public applyLogScroll(): void { const s = this.scene; + // Apply the scroll offset by shifting the content container upward. s.logContentContainer.setY(LOG_TITLE_H + 2 - s.logScrollOffset); + + // Per-entry visibility safety net (see refreshLog for details) + const visibleH = Math.max(1, s.layout.logH - LOG_TITLE_H - 4); + const visibleStart = s.logScrollOffset; + const visibleEnd = s.logScrollOffset + visibleH; + for (const child of s.logContentContainer.list) { + const localY = (child as any).y; + if (localY >= visibleStart && localY < visibleEnd) { + child.setVisible(true); + } else { + child.setVisible(false); + } + } + s.updateLogMask(); } } diff --git a/example-games/main-street/scenes/MainStreetLayoutAdapter.ts b/example-games/main-street/scenes/MainStreetLayoutAdapter.ts index 253e6fc6..ef171ba3 100644 --- a/example-games/main-street/scenes/MainStreetLayoutAdapter.ts +++ b/example-games/main-street/scenes/MainStreetLayoutAdapter.ts @@ -53,13 +53,15 @@ export function computeMainStreetLayoutWithSll(): SceneLayout { const marketTopLeft = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'market', 'topLeft', viewport, 1); const queueTopLeft = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'incidentQueue', 'topLeft', viewport, 1); + const queueBottomLeft = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'incidentQueue', 'bottomLeft', viewport, 1); const streetTopCenter = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'street', 'topCenter', viewport, 1); - // Compute a horizontally centered streetX. The 2×5 grid row width is: - // STREET_COLS × BASE_SLOT_W + (STREET_COLS - 1) × BASE_SLOT_GAP = 5 × 140 + 4 × 10 = 740px - // Centered on a 1280px screen: (1280 - 740) / 2 = 270px + // Compute streetX using the SLL anchor as the center, so it aligns with the + // left-area column (x≈20 to x≈800). With 5×140px slots and 4×20px gaps: + // rowWidth = 5*140 + 4*20 = 780px + // Centered at streetTopCenter.x = 0.3203125 (410px): streetX = 410 - 390 = 20 const rowWidth = STREET_COLS * BASE_SLOT_W + (STREET_COLS - 1) * BASE_SLOT_GAP; - const streetX = Math.round((gameW - rowWidth) / 2); + const streetX = Math.round(streetTopCenter.x - rowWidth / 2); const handTopLeft = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'hand', 'topLeft', viewport, 1); const challengeTopLeft = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'challengePanel', 'topLeft', viewport, 1); const challengeBottomRight = anchorPoint(MAIN_STREET_SLL_LAYOUT, 'challengePanel', 'bottomRight', viewport, 1); @@ -77,6 +79,7 @@ export function computeMainStreetLayoutWithSll(): SceneLayout { const logW = Math.round(logBottomRight.x - logTopLeft.x); const logH = Math.round(logBottomRight.y - logTopLeft.y); const challengeW = Math.round(challengeBottomRight.x - challengeTopLeft.x); + const eventsHeight = Math.round(queueBottomLeft.y - queueTopLeft.y); return { gameW, @@ -94,6 +97,7 @@ export function computeMainStreetLayoutWithSll(): SceneLayout { queueCardH: BASE_QUEUE_CARD_H, queueCardGap: BASE_QUEUE_CARD_GAP, queueLabelW: BASE_MARKET_LABEL_W, + eventsHeight, // Shift streetTop down by half the action button height (34 / 2 ≈ 17px) for vertical spacing streetTop: Math.round(streetTopCenter.y) + 17, slotW: BASE_SLOT_W, diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 45bee98d..8a07473e 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -1,6 +1,7 @@ import { setupMainStreetGame, deserializeMainStreetState } from '../MainStreetState'; import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun, saveCampaignProgress, createMainStreetCheckpointManager } from '../MainStreetSaveLoad'; import { DIFFICULTY_NAMES } from '../MainStreetDifficulty'; +import { createTutorialScenario } from '../TutorialScenario'; import { SaveLoadStore, createDefaultResumeOverlay, markSceneValid, markSceneInvalid, createTfPlayer, UndoRedoManager } from '../../../src/core-engine'; import { createSingleSelectionManager, TooltipManager } from '../../../src/ui'; import type { HelpSection } from '../../../src/ui'; @@ -22,7 +23,6 @@ import { loadTutorialState, saveTutorialState, updateTutorialStatus, - TUTORIAL_SEED, type TutorialVisibilityOptions, } from '../TutorialState'; import { @@ -74,6 +74,10 @@ export class MainStreetLifecycleManager { s.load.audio(`${ns}:${SFX_KEYS.UPGRADE_START}`, `${audioDir}/click.wav`); s.load.audio(`${ns}:${SFX_KEYS.UPGRADE_END}`, `${audioDir}/place.wav`); s.load.audio(`${ns}:${SFX_KEYS.EVENT_CHEER}`, `${audioDir}/coin-pop.wav`); + s.load.audio(`${ns}:${SFX_KEYS.INCOME_POSITIVE}`, `${audioDir}/coin-pop.wav`); + s.load.audio(`${ns}:${SFX_KEYS.INCOME_NEGATIVE}`, `${audioDir}/discard.wav`); + s.load.audio(`${ns}:${SFX_KEYS.INCOME_NEUTRAL}`, `${audioDir}/click.wav`); + s.load.audio(`${ns}:${SFX_KEYS.CELEBRATE}`, `${audioDir}/coin-pop.wav`); } catch (e) { // Some test environments may lack an audio loader; ignore preload failures } @@ -98,7 +102,6 @@ export class MainStreetLifecycleManager { } } catch (e) { // If svg loader is unavailable in the current environment, ignore - // eslint-disable-next-line no-console console.debug('[MS] preload: svg load failed', e); } } @@ -130,7 +133,6 @@ export class MainStreetLifecycleManager { } catch (e) { // Non-fatal: if texture generation fails let the scene continue // and fall back to colored rectangles. - // eslint-disable-next-line no-console console.debug('[MS] placeholder generation failed', e); } @@ -166,12 +168,13 @@ export class MainStreetLifecycleManager { s.logScrollOffset = 0; s.logMaxScroll = 0; s.logTotalContentH = 0; - s.logAutoScroll = true; + s.logAutoScroll = false; s.logPrevEntryCount = 0; s.detectReplayMode(); s.initEventSystem(); s.initHUDContainer(); + s.initMenuButton(); // Sound (re-use existing audio assets) // Register Main Street SFX and map common events to logical sound keys. @@ -182,8 +185,8 @@ export class MainStreetLifecycleManager { 'card-drawn': SFX_KEYS.DEAL, 'card:placed': SFX_KEYS.PLACE, 'card-discarded': SFX_KEYS.DISCARD, - // income-gained is an example domain event emitted when coins are earned - 'income-gained': SFX_KEYS.COIN_POP, + // income-gained is emitted when coins are earned; mapped to dedicated positive sound + 'income-gained': SFX_KEYS.INCOME_POSITIVE, } as const; const tfModule = getMainStreetTfModule(); @@ -402,7 +405,30 @@ export class MainStreetLifecycleManager { // has been removed. The tutorial no longer has an open-help action step. // The HelpPanel toggle no longer needs tutorial intercept. // Provide the ordered difficulty names so the Settings panel can render a selector - s.initSettingsPanel(DIFFICULTY_NAMES); + s.initSettingsPanel(DIFFICULTY_NAMES, 'Medium'); + // Listen for difficulty changes and restart the game with the new difficulty + if (typeof window !== 'undefined') { + const difficultyChangeHandler = (ev: Event) => { + const detail = (ev as CustomEvent).detail; + const newDifficulty = detail?.difficulty as string | undefined; + if (!newDifficulty || !DIFFICULTY_NAMES.includes(newDifficulty as any)) return; + if (newDifficulty === s.selectedDifficulty) return; + s.selectedDifficulty = newDifficulty as any; + // Clear any checkpoint from the previous difficulty + try { s.checkpointManager?.clear().catch(() => {}); } catch { /* ignore */ } + // Create a fresh game with the new difficulty + s.state = setupMainStreetGame({ + difficulty: s.selectedDifficulty, + unlockedCardIds: s.campaign?.unlockedCardIds, + }); + s.startDayPhase(); + s.refreshAll(); + }; + window.addEventListener('tce:difficulty-changed', difficultyChangeHandler); + s.events.once(Phaser.Scenes.Events.SHUTDOWN, () => { + window.removeEventListener('tce:difficulty-changed', difficultyChangeHandler); + }); + } s.initUndoRedoButtons( () => s.performUndo(), () => s.performRedo(), @@ -421,25 +447,20 @@ export class MainStreetLifecycleManager { { onStartTutorial: () => { try { - // ── Deterministic Tutorial Setup ───────────────── - // When the tutorial starts, force Easy difficulty and use a - // fixed seed so the same cards appear in the same order. - // This ensures the player has enough coins for all actions - // (12 starting coins, 5 starting reputation) and that the - // required cards are always available. + // ── Tutorial Scenario Setup ────────────────────── + // When the tutorial starts, create the game state using the + // explicit TutorialScenario system instead of seed-based + // shuffling. This guarantees exactly which cards appear in + // the market and incident queue, independent of deck + // composition. The tutorial always uses Easy difficulty + // (12 starting coins, 5 starting reputation) for sufficient + // coin budget throughout all 13 steps. // - // NOTE: We intentionally do NOT filter by campaign-unlocked - // card IDs here. The tutorial must use the FULL card pool so - // that the fixed seed 'tutorial-seed' produces a deterministic - // market every time regardless of the player's campaign - // progress. Filtering by unlockedCardIds would change the deck - // composition and therefore the market lineup, breaking the - // hardcoded requiredCardId references in the tutorial steps. + // The scenario system uses the STANDARD_TUTORIAL_SCENARIO + // definition which references only Tier-1 cards, ensuring + // all requiredCardId values in TutorialFlow.ts resolve. s.selectedDifficulty = 'Easy'; - s.state = setupMainStreetGame({ - difficulty: 'Easy', - seed: TUTORIAL_SEED, - }); + s.state = createTutorialScenario(); // Re-initialize the transcript recorder with the new seed try { const { MainStreetTranscriptRecorder, setMainStreetRecorder } = require('../MainStreetTranscript'); @@ -545,7 +566,9 @@ export class MainStreetLifecycleManager { s.prewarmVisibleCardTextures(); s.challengeContainer.setPosition(s.layout.challengeX, s.layout.challengeY); s.logContainer.setPosition(s.layout.logX, s.layout.logY); - s.instructionText.setPosition(s.layout.gameW - 24, s.layout.instructionY); + // Centre instruction text in the main content area (between left margin and right column) + const instructionCX = Math.round(s.layout.logX / 2); + s.instructionText.setPosition(instructionCX, s.layout.instructionY); s.refreshAll(); } @@ -728,7 +751,7 @@ export class MainStreetLifecycleManager { try { const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); - } catch (e) { /* eslint-disable-next-line no-console */ console.error('[MainStreet] tutorial offer fallback failed', e); } + } catch (e) { console.error('[MainStreet] tutorial offer fallback failed', e); } return null; }); } else { diff --git a/example-games/main-street/scenes/MainStreetOverlayContent.ts b/example-games/main-street/scenes/MainStreetOverlayContent.ts index e0f7b20b..5c5873a5 100644 --- a/example-games/main-street/scenes/MainStreetOverlayContent.ts +++ b/example-games/main-street/scenes/MainStreetOverlayContent.ts @@ -1,7 +1,7 @@ import { DIFFICULTY_NAMES } from '../MainStreetDifficulty'; import { CARD_TEMPLATE_NAMES } from '../MainStreetCards'; import type { TurnResult } from '../MainStreetEngine'; -import { FONT_FAMILY, createOverlayBackground, createOverlayButton, createOverlayMenuButton, dismissOverlay } from '../../../src/ui'; +import { FONT_FAMILY, createOverlayBackground, createOverlayButton, dismissOverlay } from '../../../src/ui'; import { TIER_DEFINITIONS, ORDERED_TIER_DEFINITIONS, highestUnlockedTier } from '../MainStreetTiers'; export class MainStreetOverlayContent { @@ -241,11 +241,5 @@ export class MainStreetOverlayContent { }); if (s.hudContainer) s.hudContainer.add(playAgainBtn); s.overlayObjects.push(playAgainBtn); - - const menuBtn = createOverlayMenuButton( - s, s.layout.gameW / 2 + 30, btnY, 101, - ); - if (s.hudContainer) s.hudContainer.add(menuBtn); - s.overlayObjects.push(menuBtn); } } diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 8ea5efa0..6ac522c2 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -3,14 +3,20 @@ */ import Phaser from 'phaser'; -import type { BusinessCard, EventCard, UpgradeCard } from '../MainStreetCards'; +import type { BusinessCard, CommunitySpaceCard, EventCard, UpgradeCard } from '../MainStreetCards'; import { GRID_SIZE, MARKET_BUSINESS_SLOTS, MARKET_INVESTMENT_SLOTS, - INCIDENT_QUEUE_SIZE, + REFRESH_DEVELOPMENT_COST, REFRESH_INVESTMENTS_COST, + isPawnShopCard, + synergyColor, } from '../MainStreetCards'; +import { + computeSynergyBonus, + computeSynergyPairs, +} from '../MainStreetAdjacency'; import { computeScore } from '../MainStreetEngine'; import { buildCoinsTooltip, @@ -22,6 +28,7 @@ import { getAffordableBusinessCards, getAffordableUpgradeCards, getEmptySlots, + canRefreshDevelopment, canRefreshInvestments, } from '../MainStreetMarket'; import { @@ -33,7 +40,6 @@ import { } from '../../../src/ui'; import { createSceneTitle, - createSceneMenuButton, createGameZone, } from '@ui/Renderer'; import { createActionButton } from '@ui/Renderer'; @@ -77,7 +83,6 @@ export class MainStreetRenderer { public createHeader(): void { const s = this.scene; - createSceneMenuButton(s); createSceneTitle(s, 'Main Street'); } @@ -194,8 +199,17 @@ export class MainStreetRenderer { s.logContainer.add(s.logContentContainer); // Geometry mask for clipping scrollable content + // IMPORTANT: Do NOT call setVisible(false) on the mask graphics! + // In Phaser 4 RC7, GeometryMask.preRenderCanvas calls + // graphics.renderCanvas() directly to draw the clip path to the + // canvas context. If the graphics is invisible, the Canvas Renderer's + // SetTransform function may still process it (it checks alpha, not + // visibility), but some internal paths skip invisible objects entirely. + // To be safe, we keep the graphics visible and use alpha=0 instead, + // so the mask shape is drawn to the context for clipping but has no + // visible appearance on screen. s.logMaskGraphics = s.add.graphics(); - s.logMaskGraphics.setVisible(false); + s.logMaskGraphics.fillStyle(0xffffff, 0); // transparent fill s.logContentMask = new Phaser.Display.Masks.GeometryMask(s, s.logMaskGraphics); s.logContentContainer.setMask(s.logContentMask); s.updateLogMask(); @@ -245,12 +259,12 @@ export class MainStreetRenderer { const { gameW, hudY } = s.layout; // Background strip - 2/3 width, centered - const strip = markHudTransient(s.add.rectangle(gameW / 2, hudY, gameW * 0.66, 28, 0x1a1408, 0.6)); + const strip = markHudTransient(s.add.rectangle(gameW / 2, hudY, gameW * 0.5, 28, 0x1a1408, 0.6)); strip.setStrokeStyle(1, BOX_STROKE, 0.5); s.hudContainer.add(strip); // Coins - centered in strip - const stripWidth = gameW * 0.66; + const stripWidth = gameW * 0.5; const stripLeft = (gameW - stripWidth) / 2; const coinText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins}`, { fontSize: '16px', fontStyle: 'bold', color: '#ffcc44', fontFamily: FONT_FAMILY, @@ -263,8 +277,8 @@ export class MainStreetRenderer { }).setOrigin(0, 0.5)); s.hudContainer.add(repText); - // Score - right side of strip - const scoreText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.85, hudY, `Score: ${score}`, { + // Score - right side of strip (shows x / y where y is the win threshold) + const scoreText = markHudTransient(s.add.text(stripLeft + stripWidth * 0.85, hudY, `Score: ${score}/${s.state.config.winThreshold}`, { fontSize: '16px', fontStyle: 'bold', color: '#ff8844', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5)); s.hudContainer.add(scoreText); @@ -389,9 +403,54 @@ export class MainStreetRenderer { this.drawEmptySlot(x, y, i); } } + + // Draw synergy lines between adjacent synergistic businesses + this.drawSynergyLines(); } - public drawBusinessSlot(x: number, y: number, _index: number, biz: BusinessCard): void { + /** + * Draws visual lines between adjacent businesses that share a synergy type. + * Each line uses the colour of the shared synergy type (from synergyColor). + * Lines are drawn over the street grid but behind tooltip zones. + */ + private drawSynergyLines(): void { + const s = this.scene; + const { streetX, streetTop, slotW, slotGap, slotH, streetCols, streetRowGap } = s.layout; + + const pairs = computeSynergyPairs(s.state.streetGrid); + + for (const pair of pairs) { + const fromCol = pair.fromIndex % streetCols; + const fromRow = Math.floor(pair.fromIndex / streetCols); + const toCol = pair.toIndex % streetCols; + const toRow = Math.floor(pair.toIndex / streetCols); + + const x1 = streetX + fromCol * (slotW + slotGap) + slotW / 2; + const y1 = streetTop + fromRow * (slotH + streetRowGap) + slotH / 2; + const x2 = streetX + toCol * (slotW + slotGap) + slotW / 2; + const y2 = streetTop + toRow * (slotH + streetRowGap) + slotH / 2; + + const color = synergyColor(pair.sharedSynergy); + + const line = s.add.graphics(); + line.lineStyle(3, color, 0.7); + line.beginPath(); + line.moveTo(x1, y1); + line.lineTo(x2, y2); + line.strokePath(); + + // Add a subtle outer glow by drawing a thicker, more transparent line underneath + line.lineStyle(6, color, 0.2); + line.beginPath(); + line.moveTo(x1, y1); + line.lineTo(x2, y2); + line.strokePath(); + + s.streetContainer.add(line); + } + } + + public drawBusinessSlot(x: number, y: number, _index: number, biz: BusinessCard | CommunitySpaceCard): void { const s = this.scene; const { slotW, slotH } = s.layout; const isHinted = s.hintedSlotIndex === _index; @@ -426,7 +485,14 @@ export class MainStreetRenderer { tooltipZone.setOrigin(0.5); tooltipZone.setInteractive({ useHandCursor: true }); tooltipZone.on('pointerover', () => { - const info = `Business: ${biz.name}\nIncome: +${biz.baseIncome + biz.incomeBonus}\nSynergy: ${biz.synergyTypes.join('/') }\nLevel: ${biz.level}`; + 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}`; s.tooltipManager?.show(info, tooltipZone.x, tooltipZone.y); }); tooltipZone.on('pointerout', () => { @@ -449,7 +515,7 @@ export class MainStreetRenderer { */ private applyUpgradeOverlays( container: Phaser.GameObjects.Container, - biz: BusinessCard, + biz: BusinessCard | CommunitySpaceCard, width: number, height: number, ): void { @@ -510,22 +576,39 @@ export class MainStreetRenderer { container.add(nameText); } - // Income text (bottom center) + // Income text (bottom-left) if (spec.incomeText) { const incomeText = this.scene.add.text( spec.incomeText.x, spec.incomeText.y, spec.incomeText.text, { - fontSize: spec.incomeText.fontSize ?? '12px', + fontSize: spec.incomeText.fontSize ?? '11px', fontStyle: spec.incomeText.fontStyle, color: spec.incomeText.color, fontFamily: FONT_FAMILY, }, ); - incomeText.setOrigin(0.5, 1); + incomeText.setOrigin(0, 1); container.add(incomeText); } + + // Reputation text (bottom-right) + if (spec.reputationText) { + const repText = this.scene.add.text( + spec.reputationText.x, + spec.reputationText.y, + spec.reputationText.text, + { + fontSize: spec.reputationText.fontSize ?? '11px', + fontStyle: spec.reputationText.fontStyle, + color: spec.reputationText.color, + fontFamily: FONT_FAMILY, + }, + ); + repText.setOrigin(1, 1); + container.add(repText); + } } public drawEmptySlot(x: number, y: number, index: number): void { @@ -568,21 +651,21 @@ export class MainStreetRenderer { s.marketSelectionByCardId.clear(); s.selectedMarketCardId = null; - const { gameW, marketTop, marketRowH, marketRowGap, marketCardW, marketCardGap, marketLabelW } = s.layout; + const { marketTop, marketRowH, marketRowGap, logX } = s.layout; - // Section background (2 rows: business + investments) - // Calculate actual right edge from the widest row (business: 4 slots) - const marketStartX = marketLabelW + 50; - const marketRight = marketStartX + (MARKET_BUSINESS_SLOTS - 1) * (marketCardW + marketCardGap) + marketCardW + 20; + // Wider section background — extends from left edge to near the activity log (logX - 20px margin) + const bgLeft = 20; + const bgRight = logX - 20; // 820 - 20 = 800 const totalH = 2 * marketRowH + marketRowGap + 20; const bgBox = s.add.graphics(); bgBox.fillStyle(BOX_FILL, 0.3); - bgBox.fillRoundedRect(20, marketTop - 10, marketRight - 20, totalH, BOX_RADIUS); + bgBox.fillRoundedRect(bgLeft, marketTop - 10, bgRight - bgLeft, totalH, BOX_RADIUS); bgBox.lineStyle(1, BOX_STROKE, 0.4); - bgBox.strokeRoundedRect(20, marketTop - 10, marketRight - 20, totalH, BOX_RADIUS); + bgBox.strokeRoundedRect(bgLeft, marketTop - 10, bgRight - bgLeft, totalH, BOX_RADIUS); s.marketContainer.add(bgBox); - const sectionLabel = s.add.text(gameW / 2, marketTop - 4, 'Market', { + // Section label centered over the wider box + const sectionLabel = s.add.text((bgLeft + bgRight) / 2, marketTop - 4, 'Market', { fontSize: '13px', fontStyle: 'bold', color: '#887766', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 1); s.marketContainer.add(sectionLabel); @@ -618,12 +701,12 @@ export class MainStreetRenderer { y: number, rowLabel: string, rowKey: string, - cards: readonly (BusinessCard | EventCard | UpgradeCard)[], + cards: readonly (BusinessCard | CommunitySpaceCard | EventCard | UpgradeCard)[], maxSlots: number, - onClick: (card: BusinessCard | EventCard | UpgradeCard) => void, + onClick: (card: BusinessCard | CommunitySpaceCard | EventCard | UpgradeCard) => void, ): void { const s = this.scene; - const { marketCardW, marketCardH, marketCardGap, marketLabelW } = s.layout; + const { marketCardW, marketCardH, marketCardGap, logX } = s.layout; // Row label - also use for positioning deck count const label = s.add.text(40, y, rowLabel, { @@ -631,7 +714,12 @@ export class MainStreetRenderer { }).setOrigin(0, 0.5); s.marketContainer.add(label); - const startX = marketLabelW + 50; + // Centre cards in the wider market box (20 to logX-20) + const boxLeft = 20; + const boxRight = logX - 20; + const boxCenter = (boxLeft + boxRight) / 2; + const totalCardsW = maxSlots * marketCardW + (maxSlots - 1) * marketCardGap; + const startX = Math.round(boxCenter - totalCardsW / 2); for (let i = 0; i < maxSlots; i++) { const cx = startX + i * (marketCardW + marketCardGap); @@ -651,14 +739,80 @@ export class MainStreetRenderer { } } - // Deck count - immediately below the label + // Deck info and refresh button - immediately below the label const deckY = y + 16; - if (rowLabel === 'Business') { - const deckCount = s.state.decks.business.length; - const deckText = s.add.text(40, deckY, `Deck: ${deckCount}`, { - fontSize: '12px', color: '#776655', fontFamily: FONT_FAMILY, + if (rowKey === 'development') { + // Development row: show deck count + Discover button + const bizCount = s.state.decks.business.length; + const csCount = s.state.decks.communitySpace.length; + const deckText = s.add.text(40, deckY, `Biz: ${bizCount} CS: ${csCount}`, { + fontSize: '11px', color: '#776655', fontFamily: FONT_FAMILY, }).setOrigin(0, 0); s.marketContainer.add(deckText); + + // Discover button for Development row + try { + const refreshDevResult = canRefreshDevelopment(s.state); + const canRefresh = refreshDevResult.legal; + const btnW = Math.max(s.layout.smallButtonW, 96); + const labelCenter = 40 + s.layout.marketLabelW / 2; + const btnX = Math.round(labelCenter - btnW / 2); + const btnY = deckY + 22; + + const labelText = `Discover (${REFRESH_DEVELOPMENT_COST})`; + + const btn = createActionButton(s, btnX, btnY, btnW, labelText, canRefresh ? () => { s.onRefreshDevelopmentClick(); } : () => {}, { + disabled: !canRefresh, + ...(canRefresh ? {} : { fillColor: 0x333333, fillAlpha: 0.6 }), + }); + // Dim visual when not allowed, but keep interactive so tooltip can show + try { + const bg = (btn.list && btn.list[0]) as Phaser.GameObjects.Rectangle | undefined; + if (bg) { + if (!canRefresh && typeof bg.setFillStyle === 'function') { + bg.setFillStyle(0x333333, 0.6); + } + + // Tooltip for the Discover button + const reasonSuffix = !canRefresh && refreshDevResult.reason ? `\n\n${refreshDevResult.reason}` : ''; + const info = `Pay $${REFRESH_DEVELOPMENT_COST} to discover new development opportunities and replace the visible development row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; + try { + bg.on('pointerover', (pointer: any) => { + if (s.tooltipManager) { + s.tooltipManager.show(info, (pointer && pointer.worldX) || btn.x, (pointer && pointer.worldY) || btn.y); + return; + } + try { + if ((s as any)._tempDiscoverTooltip) { + (s as any)._tempDiscoverTooltip.destroy(); + (s as any)._tempDiscoverTooltip = null; + } + const tt = s.add.text(btn.x, btn.y - s.layout.actionButtonH / 2 - 6, info, { + fontSize: '12px', color: '#ffffff', fontFamily: FONT_FAMILY, backgroundColor: 'rgba(0,0,0,0.85)', padding: { x: 6, y: 4 }, wordWrap: { width: 280 }, align: 'center' + }).setOrigin(0.5, 1).setDepth(1000); + (s as any)._tempDiscoverTooltip = tt; + } catch (e) { /* ignore fallback errors */ } + }); + bg.on('pointerout', () => { + if (s.tooltipManager) { + s.tooltipManager.hide(); + return; + } + try { + if ((s as any)._tempDiscoverTooltip) { + (s as any)._tempDiscoverTooltip.destroy(); + (s as any)._tempDiscoverTooltip = null; + } + } catch (_) { /* ignore */ } + }); + } catch (_) { /* ignore */ } + } + } catch (_) { /* ignore tooltip attach errors in tests */ } + + s.marketContainer.add(btn); + } catch (_) { + // ignore UI errors in tests + } } else { // Investments row: show both upgrade and event deck counts - below label const upgCount = s.state.decks.upgrade.length; @@ -672,7 +826,8 @@ export class MainStreetRenderer { // Refresh Investments button (centered under Investments label / deck count) try { - const canRefresh = canRefreshInvestments(s.state).legal; + const refreshInvResult = canRefreshInvestments(s.state); + const canRefresh = refreshInvResult.legal; // Make button wider so label fits, and move it lower to avoid overlapping deck text const btnW = Math.max(s.layout.smallButtonW, 96); // center under the label area: label left (40) + half label width @@ -680,7 +835,7 @@ export class MainStreetRenderer { const btnX = Math.round(labelCenter - btnW / 2); const btnY = deckY + 22; // further below deck text to avoid overlap - const labelText = `Discover (${REFRESH_INVESTMENTS_COST})`; + const labelText = `Research (${REFRESH_INVESTMENTS_COST})`; const btn = createActionButton(s, btnX, btnY, btnW, labelText, canRefresh ? () => { s.onRefreshInvestmentsClick(); } : () => {}, { disabled: !canRefresh, @@ -694,8 +849,9 @@ export class MainStreetRenderer { bg.setFillStyle(0x333333, 0.6); } - // Tooltip for the Discover button (attach to bg so it receives pointer events) - const info = `Pay $${REFRESH_INVESTMENTS_COST} to research new investment opportunities and replace the visible investments row. Removed cards go to their discard piles. Available only during Market phase.`; + // Tooltip for the Research button (attach to bg so it receives pointer events) + const reasonSuffix = !canRefresh && refreshInvResult.reason ? `\n\n${refreshInvResult.reason}` : ''; + const info = `Pay $${REFRESH_INVESTMENTS_COST} to research new investment opportunities and replace the visible investments row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; try { bg.on('pointerover', (pointer: any) => { if (s.tooltipManager) { @@ -704,14 +860,14 @@ export class MainStreetRenderer { } // Fallback: create an in-canvas text tooltip if DOM tooltip manager isn't available try { - if ((s as any)._tempDiscoverTooltip) { - (s as any)._tempDiscoverTooltip.destroy(); - (s as any)._tempDiscoverTooltip = null; + if ((s as any)._tempResearchTooltip) { + (s as any)._tempResearchTooltip.destroy(); + (s as any)._tempResearchTooltip = null; } const tt = s.add.text(btn.x, btn.y - s.layout.actionButtonH / 2 - 6, info, { fontSize: '12px', color: '#ffffff', fontFamily: FONT_FAMILY, backgroundColor: 'rgba(0,0,0,0.85)', padding: { x: 6, y: 4 }, wordWrap: { width: 280 }, align: 'center' }).setOrigin(0.5, 1).setDepth(1000); - (s as any)._tempDiscoverTooltip = tt; + (s as any)._tempResearchTooltip = tt; } catch (e) { /* ignore fallback errors */ } }); bg.on('pointerout', () => { @@ -720,9 +876,9 @@ export class MainStreetRenderer { return; } try { - if ((s as any)._tempDiscoverTooltip) { - (s as any)._tempDiscoverTooltip.destroy(); - (s as any)._tempDiscoverTooltip = null; + if ((s as any)._tempResearchTooltip) { + (s as any)._tempResearchTooltip.destroy(); + (s as any)._tempResearchTooltip = null; } } catch (_) { /* ignore */ } }); @@ -740,8 +896,8 @@ export class MainStreetRenderer { public drawMarketCard( x: number, y: number, - card: BusinessCard | EventCard | UpgradeCard, - onClick: (card: BusinessCard | EventCard | UpgradeCard) => void, + card: BusinessCard | CommunitySpaceCard | EventCard | UpgradeCard, + onClick: (card: BusinessCard | CommunitySpaceCard | EventCard | UpgradeCard) => void, _rowKey: string, _slotIndex: number, ): Phaser.GameObjects.Container { @@ -828,7 +984,16 @@ export class MainStreetRenderer { let info = ''; if (card.family === 'business') { const b = card as any; - info = `Business: ${b.name}\nCost: ${b.cost}\nIncome: +${b.baseIncome + (b.incomeBonus || 0)}/turn\nSynergy: ${(b.synergyTypes || []).join('/')}\n${b.description ?? ''}`; + 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 ?? ''}`; + } 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 ?? ''}`; } 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}`; @@ -856,80 +1021,120 @@ export class MainStreetRenderer { const queue = s.state.incidentQueue; const deckRemaining = s.state.decks.event.length; + const activeEffects = s.state.activeEffects; + + const { logX, logW, queueTop } = s.layout; + + // Same panel width and left-edge as the activity log + const panelX = logX; + const panelW = logW; + const pad = 8; + const titleH = 22; + const contentX = panelX + pad; + + // Calculate dynamic height + const activeEffectLines = activeEffects.length; + const extraH = activeEffectLines > 0 ? 16 + activeEffectLines * 16 : 0; + const cardRenderH = 50; + const maxCards = Math.min(2, queue.length); + const cardAreaH = maxCards * (cardRenderH + 6) - 6 + 12; // cards + deck count + const panelH = titleH + pad + cardAreaH + extraH + pad; + + // Panel background — same warm-dark style as activity log + const bg = s.add.graphics(); + bg.fillStyle(0x1a1408, 0.85); + bg.fillRoundedRect(panelX, queueTop, panelW, panelH, 4); + bg.lineStyle(1, BOX_STROKE, 0.5); + bg.strokeRoundedRect(panelX, queueTop, panelW, panelH, 4); + s.incidentQueueContainer.add(bg); - const { queueLabelW, queueCardW, queueCardH, queueCardGap, queueTop } = s.layout; - - // Section background - width to just fit cards with small right margin - const queueW = queueLabelW + 50 + INCIDENT_QUEUE_SIZE * (queueCardW + queueCardGap) - queueCardGap + 20; - const queueH = queueCardH + 24; - const bgBox = s.add.graphics(); - bgBox.fillStyle(0x1a1830, 0.35); - bgBox.fillRoundedRect(110, queueTop - 10, queueW, queueH, BOX_RADIUS); - bgBox.lineStyle(1, 0x445577, 0.5); - bgBox.strokeRoundedRect(110, queueTop - 10, queueW, queueH, BOX_RADIUS); - s.incidentQueueContainer.add(bgBox); + // Title bar — same style as activity log + const titleBg = s.add.graphics(); + titleBg.fillStyle(0x332816, 0.9); + titleBg.fillRoundedRect(panelX, queueTop, panelW, titleH, { tl: 4, tr: 4, bl: 0, br: 0 }); + s.incidentQueueContainer.add(titleBg); - // Section label - const label = s.add.text(40, queueTop + queueCardH / 2 - 2, 'Upcoming', { - fontSize: '13px', fontStyle: 'bold', color: '#7788aa', fontFamily: FONT_FAMILY, - align: 'center', - }).setOrigin(0, 0.5); - s.incidentQueueContainer.add(label); + const titleText = s.add.text(panelX + panelW / 2, queueTop + titleH / 2, 'Upcoming', { + fontSize: '12px', fontStyle: 'bold', color: '#aa9977', fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + s.incidentQueueContainer.add(titleText); - const startX = queueLabelW + 50; + // Queue cards — stacked vertically, centred in the panel + let cardY = queueTop + titleH + pad; + const cardRenderW = Math.max(1, Math.round(panelW - pad * 2 - 8)); - for (let i = 0; i < INCIDENT_QUEUE_SIZE; i++) { - const cx = startX + i * (queueCardW + queueCardGap); + for (let i = 0; i < maxCards; i++) { const card = queue[i]; + const cx = panelX + (panelW - cardRenderW) / 2; if (card) { - const cardContainer = this.drawIncidentCard(cx, queueTop, card); - s.incidentQueueContainer.add(cardContainer); + const container = s.add.container(Math.round(cx + cardRenderW / 2), Math.round(cardY + cardRenderH / 2)); + mainStreetRenderCardSvg(s, container, card.id, cardRenderW, cardRenderH); + s.incidentQueueContainer.add(container); + + if (!s.replayMode) { + const hover = s.add.rectangle(0, 0, cardRenderW, cardRenderH, 0x000000, 0.001); + hover.setInteractive({ useHandCursor: true }); + hover.on('pointerover', () => { + let info: string; + const dCard = card as any; + 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; + } + s.tooltipManager?.show(info, container.x, container.y); + }); + hover.on('pointerout', () => s.tooltipManager?.hide()); + container.add(hover); + } } else { // Empty queue slot const empty = s.add.rectangle( - cx + queueCardW / 2, queueTop + queueCardH / 2, - queueCardW, queueCardH, 0x111122, 0.3, + cx + cardRenderW / 2, cardY + cardRenderH / 2, + cardRenderW, cardRenderH, 0x111122, 0.3, ); empty.setStrokeStyle(1, 0x223344); s.incidentQueueContainer.add(empty); } + + cardY += cardRenderH + 6; } - // Deck count - immediately below the label - const deckText = s.add.text(40, queueTop + 32, `Deck: ${deckRemaining}`, { - fontSize: '11px', color: '#556677', fontFamily: FONT_FAMILY, + // Deck count below cards + const deckText = s.add.text(contentX, cardY, 'Deck: ' + deckRemaining, { + fontSize: '11px', color: '#776655', fontFamily: FONT_FAMILY, }).setOrigin(0, 0); s.incidentQueueContainer.add(deckText); - } - - public drawIncidentCard( - x: number, - y: number, - card: EventCard, - ): Phaser.GameObjects.Container { - const s = this.scene; - const { queueCardW, queueCardH } = s.layout; - const container = s.add.container(Math.round(x + queueCardW / 2), Math.round(y + queueCardH / 2)); - - const renderW = Math.max(1, Math.round(queueCardW - 4)); - const renderH = Math.max(1, Math.round(queueCardH - 4)); + cardY += 18; + + // Active Effects indicator + if (activeEffectLines > 0) { + for (let i = 0; i < activeEffects.length; i++) { + const effect = activeEffects[i]; + const warnIcon = String.fromCodePoint(0x26A0); + const dash = String.fromCodePoint(0x2014); + const effectText = s.add.text(contentX, cardY, warnIcon + ' ' + effect.description + ' ' + dash + ' ' + effect.turnsRemaining + ' turn' + (effect.turnsRemaining !== 1 ? 's' : ''), { + fontSize: '10px', color: '#ff6644', fontFamily: FONT_FAMILY, + }).setOrigin(0, 0); + s.incidentQueueContainer.add(effectText); - // Render card via shared adapter - mainStreetRenderCardSvg(s, container, card.id, renderW, renderH); - - if (!s.replayMode) { - const hover = s.add.rectangle(0, 0, queueCardW, queueCardH, 0x000000, 0.001); - hover.setInteractive({ useHandCursor: true }); - hover.on('pointerover', () => { - const info = `Event: ${card.name}\nEffect: ${card.effect}\nCoins: ${card.coinDelta >= 0 ? '+' : ''}${card.coinDelta}, Rep: ${card.reputationDelta >= 0 ? '+' : ''}${card.reputationDelta}`; - s.tooltipManager?.show(info, container.x, container.y); - }); - hover.on('pointerout', () => s.tooltipManager?.hide()); - container.add(hover); + if (!s.replayMode) { + const hitArea = s.add.rectangle( + panelX + panelW / 2, cardY + 8, panelW - 20, 14, 0x000000, 0.001, + ).setInteractive({ useHandCursor: true }); + hitArea.on('pointerover', () => { + s.tooltipManager?.show( + 'Active: ' + effect.description + '\n' + Math.round(effect.multiplier * 100) + '% modifier ' + dash + ' ' + effect.turnsRemaining + ' turn' + (effect.turnsRemaining !== 1 ? 's' : '') + ' remaining', + hitArea.x, hitArea.y, + ); + }); + hitArea.on('pointerout', () => s.tooltipManager?.hide()); + s.incidentQueueContainer.add(hitArea); + } + cardY += 16; + } } - - return container; } public refreshPlayerHand(): void { @@ -937,6 +1142,7 @@ export class MainStreetRenderer { // handContainer zone kept for backward-compat (zone-metadata tests) s.handContainer.removeAll(true); + // Show held event card if present (existing behavior) const held = s.state.heldEvent; if (held) { @@ -946,6 +1152,115 @@ export class MainStreetRenderer { // Empty hand — HandView gracefully handles empty array (no sprites) this.handView.setCards([]); } + + // Render hand cards from state.hand (Multi-Use Card Economy) + this.refreshBusinessHandCards(); + } + + /** + * Renders business cards held in the player's hand. + * Shows each card as a small card below the tableau with synergy indicator. + */ + private refreshBusinessHandCards(): void { + const s = this.scene; + const hand = s.state.hand ?? []; + + // Remove previous hand card display + if (s.handBusinessContainer) { + s.handBusinessContainer.removeAll(true); + } else { + s.handBusinessContainer = s.add.container(0, 0); + } + + if (hand.length === 0) { + // Update hand size indicator + this.updateHandSizeIndicator(0); + return; + } + + const { handCardW, handCardH, handY } = s.layout; + const startX = 40; + const y = handY; + const spacing = handCardW + 8; + + for (let i = 0; i < hand.length; i++) { + const card = hand[i]; + const x = startX + i * spacing; + + 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); + + // 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); + + s.handBusinessContainer!.add(container); + } + + // Update hand size indicator + this.updateHandSizeIndicator(hand.length); + } + + /** + * Updates the hand size indicator text (e.g. "Hand: 2/5"). + */ + private updateHandSizeIndicator(current: number): void { + const s = this.scene; + const maxSize = s.state.maxHandSize ?? 2; + + if (s.handSizeText) { + s.handSizeText.destroy(); + } + + s.handSizeText = s.add.text(10, s.layout.handY - 14, + `Hand: ${current}/${maxSize}`, { + fontSize: '12px', + color: current >= maxSize ? '#ff6666' : '#c8b88a', + fontFamily: 'Arial', + }); + } + + /** + * 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'; } /** @@ -1065,58 +1380,91 @@ export class MainStreetRenderer { const entries = s.state.activityLog; const newCount = entries.length; - // Skip rebuild if nothing changed - if (newCount === s.logPrevEntryCount) return; + // Visible area inside the panel (below title bar, above bottom edge) + const visibleH = Math.max(1, s.layout.logH - LOG_TITLE_H - 4); + + // ── Re-render only if the entry count changed ──────────────── + if (newCount !== s.logPrevEntryCount) { + s.logPrevEntryCount = newCount; + + // Render ALL entries to compute the true total content height. + // Per-entry visibility (applied below) hides off-screen entries. + s.logContentContainer.removeAll(true); + + const contentW = s.layout.logW - LOG_PAD * 2; + let yOff = 0; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!entry) continue; + + const color = LOG_COLORS[entry.type] ?? LOG_COLORS.neutral; + const isTurnHeader = entry.type === 'turn-header'; + + if (isTurnHeader) { + // Subtle background bar for turn headers. + // Use setPosition(0, yOff) so that barBg.y correctly reflects + // the entry position, enabling per-entry visibility checks. + const barBg = s.add.graphics(); + barBg.fillStyle(0x443311, 0.5); + barBg.fillRect(0, 0, s.layout.logW, LOG_LINE_H); + barBg.setPosition(0, yOff); + s.logContentContainer.add(barBg); + } - const hadAutoScroll = s.logAutoScroll; - s.logPrevEntryCount = newCount; + const txt = s.add.text(LOG_PAD, yOff, entry.text, { + fontSize: `${LOG_FONT_SIZE}px`, + fontStyle: isTurnHeader ? 'bold' : 'normal', + color, + fontFamily: FONT_FAMILY, + wordWrap: { width: contentW }, + }); + s.logContentContainer.add(txt); - // Clear existing content - s.logContentContainer.removeAll(true); + // Use actual rendered height to handle word-wrapped lines + yOff += Math.max(LOG_LINE_H, txt.height + 2); + } - const contentW = s.layout.logW - LOG_PAD * 2; - let yOff = 0; + s.logTotalContentH = yOff; + } - for (const entry of entries) { - const color = LOG_COLORS[entry.type] ?? LOG_COLORS.neutral; - const isTurnHeader = entry.type === 'turn-header'; + // ── Compute scroll bounds using actual total content height ── + if (s.logTotalContentH <= visibleH) { + s.logMaxScroll = 0; + s.logScrollOffset = 0; + } else { + s.logMaxScroll = s.logTotalContentH - visibleH; - if (isTurnHeader) { - // Subtle background bar for turn headers - const barBg = s.add.graphics(); - barBg.fillStyle(0x443311, 0.5); - barBg.fillRect(0, yOff, s.layout.logW, LOG_LINE_H); - s.logContentContainer.add(barBg); + if (s.logAutoScroll) { + s.logScrollOffset = s.logMaxScroll; + } else { + s.logScrollOffset = Phaser.Math.Clamp(s.logScrollOffset, 0, s.logMaxScroll); } - const txt = s.add.text(LOG_PAD, yOff, entry.text, { - fontSize: `${LOG_FONT_SIZE}px`, - fontStyle: isTurnHeader ? 'bold' : 'normal', - color, - fontFamily: FONT_FAMILY, - wordWrap: { width: contentW }, - }); - s.logContentContainer.add(txt); - - // Use actual rendered height to handle word-wrapped lines - yOff += Math.max(LOG_LINE_H, txt.height + 2); + const atBottom = s.logScrollOffset >= s.logMaxScroll - 4; + s.logAutoScroll = atBottom; } - s.logTotalContentH = yOff; - - // Visible area inside the panel (below title bar, above bottom edge) - const visibleH = s.layout.logH - LOG_TITLE_H - 4; - s.logMaxScroll = Math.max(0, s.logTotalContentH - visibleH); - - // Keep scroll position valid for the current content height. - // On scene restart we can transition from a long previous run to a short - // new log; without clamping, stale offsets can hide all entries. - if (hadAutoScroll) { - s.logScrollOffset = s.logMaxScroll; - } else { - s.logScrollOffset = Phaser.Math.Clamp(s.logScrollOffset, 0, s.logMaxScroll); + // Apply scroll by shifting the content container upward. + s.logContentContainer.setY(LOG_TITLE_H + 2 - s.logScrollOffset); + + // ── Per-entry visibility safety net ──────────────── + // Phaser 4 RC7's GeometryMask clip is unreliable. As a safety net, + // explicitly hide any child whose local Y falls outside the visible + // window [scrollOffset, scrollOffset + visibleH). + // This ensures no content renders above the title bar or below the + // panel bottom, regardless of whether the mask clips. + const visibleStart = s.logScrollOffset; + const visibleEnd = s.logScrollOffset + visibleH; + for (const child of s.logContentContainer.list) { + const localY = (child as any).y; + if (localY >= visibleStart && localY < visibleEnd) { + child.setVisible(true); + } else { + child.setVisible(false); + } } - s.applyLogScroll(); + s.updateLogMask(); } } diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index fa161544..f18ce27e 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -1,9 +1,6 @@ import type { MainStreetState, MainStreetCampaignProgress } from '../MainStreetState'; import type { DifficultyName } from '../MainStreetDifficulty'; import type { BusinessCard } from '../MainStreetCards'; -import { - INCIDENT_QUEUE_SIZE, -} from '../MainStreetCards'; import { CardGameScene, TooltipManager, @@ -69,6 +66,10 @@ export class MainStreetScene extends CardGameScene { public marketContainer!: Phaser.GameObjects.Container; public incidentQueueContainer!: Phaser.GameObjects.Container; public handContainer!: Phaser.GameObjects.Container; + /** Container for business cards in the player's hand (Multi-Use Card Economy). */ + public handBusinessContainer!: Phaser.GameObjects.Container; + /** Text element showing hand capacity (e.g. "Hand: 2/5"). */ + public handSizeText!: Phaser.GameObjects.Text; public actionContainer!: Phaser.GameObjects.Container; // Activity Log panel @@ -79,8 +80,10 @@ export class MainStreetScene extends CardGameScene { public logScrollOffset = 0; public logMaxScroll = 0; public logTotalContentH = 0; - public logAutoScroll = true; + public logAutoScroll = false; public logPrevEntryCount = 0; + /** The index of the first entry displayed in the current log window (for windowed rendering). */ + public logRenderedStartIdx = 0; // Challenge Tracker panel public challengeContainer!: Phaser.GameObjects.Container; @@ -323,6 +326,10 @@ export class MainStreetScene extends CardGameScene { } // Refresh investments proxy (forward to turn controller) + public onRefreshDevelopmentClick(...args: any[]): any { + return (this.msTurnController as any).onRefreshDevelopmentClick.apply(this.msTurnController, args); + } + public onRefreshInvestmentsClick(...args: any[]): any { return (this.msTurnController as any).onRefreshInvestmentsClick.apply(this.msTurnController, args); } @@ -365,20 +372,6 @@ export class MainStreetScene extends CardGameScene { return (this.msTurnController as any).onUpgradeCardClick.apply(this.msTurnController, args); } - /** - * Shows a modal overlay that lets the player choose between multiple - * upgrade branches available for the business at `targetSlot`. - * - * When a branch button is clicked the modal is dismissed, the chosen - * upgrade is applied via `executeAction`, and the scene is refreshed. - * - * @param branches Eligible UpgradeCards the player may choose from. - * @param targetSlot Street grid slot of the business to be upgraded. - */ - public showUpgradeChoiceModal(...args: any[]): any { - return (this.msTurnController as any).showUpgradeChoiceModal.apply(this.msTurnController, args); - } - // ── Activity Log ───────────────────────────────────────── /** @@ -396,7 +389,10 @@ export class MainStreetScene extends CardGameScene { /** Handles mouse wheel events over the log panel area. */ public handleLogWheel = (...args: any[]): any => { - return (this.msInputManager as any).handleLogWheel.apply(this.msInputManager, args); + const ret = (this.msInputManager as any).handleLogWheel.apply(this.msInputManager, args); + // After updating scroll offset, refresh the log to render the new entry window + this.msRenderer?.refreshLog(); + return ret; }; /** Applies the current scroll offset to the log content container. */ @@ -456,9 +452,9 @@ export class MainStreetScene extends CardGameScene { h: 2 * l.marketRowH + l.marketRowGap + 20, }; const queue = { - x: 20, + x: l.logX, y: l.queueTop - 10, - w: l.queueLabelW + INCIDENT_QUEUE_SIZE * (l.queueCardW + l.queueCardGap) + 100, + w: l.logW, h: l.queueCardH + 24, }; const street = { diff --git a/example-games/main-street/scenes/MainStreetTurnController.ts b/example-games/main-street/scenes/MainStreetTurnController.ts index 8e214c59..c14426b1 100644 --- a/example-games/main-street/scenes/MainStreetTurnController.ts +++ b/example-games/main-street/scenes/MainStreetTurnController.ts @@ -2,20 +2,33 @@ import { addLog } from '../MainStreetState'; import { executeDayStart, processEndOfTurn, type TurnResult } from '../MainStreetEngine'; import { getEmptySlots, - getUpgradeBranchesForBusiness, findTargetBusinessSlot, canPurchaseBusiness, canPurchaseUpgrade, canPurchaseEvent, + canRefreshDevelopment, canRefreshInvestments, } from '../MainStreetMarket'; import type { BusinessCard, EventCard, UpgradeCard } from '../MainStreetCards'; -import { buyBusinessCommand, buyUpgradeCommand, buyEventCommand, playEventCommand, refreshInvestmentsCommand } from '../MainStreetCommands'; +import { buyBusinessCommand, buyUpgradeCommand, buyEventCommand, playEventCommand, refreshDevelopmentCommand, refreshInvestmentsCommand } from '../MainStreetCommands'; import { recordMainStreetEvent, finalizeMainStreetTranscript } from '../MainStreetTranscript'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; -import { FONT_FAMILY, createOverlayBackground, createOverlayButton, dismissOverlay } from '../../../src/ui'; import { getCurrentStep, type TutorialActionType } from '../TutorialFlow'; +/** + * Match a card ID against a requiredCardId using prefix matching. + * + * Card IDs include a copy-number suffix (e.g. `biz-laundromat-2`). The + * `requiredCardId` in tutorial steps is the template ID with a specific copy + * number (e.g. `biz-laundromat-0`). This helper strips trailing `-` + * from both IDs and compares the template prefix, so any copy of the required + * card template satisfies the requirement. + */ +function matchesRequiredCard(cardId: string, requiredCardId: string): boolean { + const stripCopy = (id: string): string => id.replace(/-\d+$/, ''); + return stripCopy(cardId) === stripCopy(requiredCardId); +} + export class MainStreetTurnController { constructor(private readonly scene: any) {} @@ -73,6 +86,22 @@ export class MainStreetTurnController { s.instructionText.setText('Processing end of turn...'); s.refreshActionButtons(); + // ── Tutorial guard: prevent market cycling before T7 ────────── + // When the tutorial is active and the current step is action 'end-turn' + // (T6), the upcoming processEndOfTurn would call cycleMarketCards(), + // which discards all scenario-placed market cards and refills from the + // random deck. This would lose the scenario's explicitly-placed + // investment event card (Local Festival) before T7 can reference it. + // Set skipMarketCycleOnEndTurn to preserve the market state until T7 + // completes. The flag is reset after processEndOfTurn. + const tutController = (s as any).tutorialController as any; + if (tutController?.isActive) { + const step = getCurrentStep(tutController); + if (step?.requiredAction === 'end-turn') { + s.state.skipMarketCycleOnEndTurn = true; + } + } + // Process end-of-turn phases (events, income, night, end check) let result: TurnResult; try { @@ -86,6 +115,9 @@ export class MainStreetTurnController { s.instructionText.setText(`Error: ${(e as Error).message}`); s.refreshAll(); return; + } finally { + // Reset the flag after processing so subsequent end-turns cycle normally + s.state.skipMarketCycleOnEndTurn = false; } // Save checkpoint after each completed turn (fire-and-forget) @@ -94,6 +126,30 @@ export class MainStreetTurnController { // Clear undo stack on end-of-turn (per acceptance criteria) try { s.undoManager.clear(); } catch (e) { /* ignore */ } + // ── Challenge Celebration VFX & Sound ──────────────────────── + // If any challenges were newly completed this turn, trigger celebration + // animations with staggered timing so they don't overlap. + if (result.newlyCompletedChallenges.length > 0) { + // Build a lookup from challenge ID to title + const challengeTitleById = new Map(); + for (const ac of s.state.activeChallenges) { + challengeTitleById.set(ac.challenge.id, ac.challenge.title); + } + + result.newlyCompletedChallenges.forEach((challengeId, index) => { + const title = challengeTitleById.get(challengeId) ?? 'Challenge Complete!'; + s.time.delayedCall(index * 600, () => { + void s.msAnimator.animateCelebration(title); + }); + }); + + // Refresh the challenge tracker after all celebrations + s.time.delayedCall( + result.newlyCompletedChallenges.length * 600 + 200, + () => s.refreshAll(), + ); + } + // Brief delay then show result / advance s.time.delayedCall(400, () => { if (result.gameResult !== 'playing') { @@ -212,16 +268,18 @@ export class MainStreetTurnController { return; } - // Tutorial: enforce specific card purchase if requiredCardId is set on the current step + // Tutorial: enforce specific card purchase if requiredCardId is set on the current step. + // Uses prefix matching (template ID without copy number suffix) so any copy of the + // required card template satisfies the requirement. const controller = (s as any).tutorialController as any; if (controller?.isActive) { const step = controller.currentStepIndex >= 0 ? getCurrentStep(controller) : null; - if (step?.requiredCardId && card.id !== step.requiredCardId) { + if (step?.requiredCardId && !matchesRequiredCard(card.id, step.requiredCardId)) { // Find the card name from the market for the error message const requiredCard = s.state.market.development.find( - (c: any) => c.id === step.requiredCardId + (c: any) => matchesRequiredCard(c.id, step.requiredCardId!) ); const requiredName = requiredCard?.name ?? 'the specified card'; const msg = `This is not the card you should buy right now. Please buy ${requiredName} first.`; @@ -354,15 +412,17 @@ export class MainStreetTurnController { return; } - // Tutorial: enforce specific event card purchase if requiredCardId is set + // Tutorial: enforce specific event card purchase if requiredCardId is set. + // Uses prefix matching (template ID without copy number suffix) so any copy of the + // required card template satisfies the requirement. const evtController = (s as any).tutorialController as any; if (evtController?.isActive) { const step = evtController.currentStepIndex >= 0 ? getCurrentStep(evtController) : null; - if (step?.requiredCardId && card.id !== step.requiredCardId) { + if (step?.requiredCardId && !matchesRequiredCard(card.id, step.requiredCardId)) { const requiredCard = s.state.market.investments.find( - (c: any) => c.id === step.requiredCardId + (c: any) => matchesRequiredCard(c.id, step.requiredCardId!) ); const requiredName = requiredCard?.name ?? 'the specified event card'; const msg = `This is not the card you should buy right now. Please buy ${requiredName} first.`; @@ -430,6 +490,35 @@ export class MainStreetTurnController { } } + public onRefreshDevelopmentClick(): void { + const s = this.scene; + if (s.uiPhase !== 'market') return; + + const legality = canRefreshDevelopment(s.state); + if (!legality.legal) { + s.instructionText.setText(`Cannot refresh: ${legality.reason ?? 'unknown'}`); + return; + } + + s.uiPhase = 'animating'; + s.instructionText.setText('Discovering new development opportunities...'); + s.refreshAll(); + + try { + const cmd = refreshDevelopmentCommand(s.state); + s.undoManager.execute(cmd); + try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'refresh-development' }, description: cmd.description }); } catch (_) {} + s.instructionText.setText('Refreshed development'); + addLog(s.state, 'Refreshed development (via UI)', 'neutral'); + } catch (e) { + console.error('[MS] RefreshDevelopment failed', e); + s.instructionText.setText(`Error: ${(e as Error).message}`); + } + + s.uiPhase = 'market'; + s.refreshAll(); + } + public onRefreshInvestmentsClick(): void { const s = this.scene; if (s.uiPhase !== 'market') return; @@ -485,14 +574,8 @@ export class MainStreetTurnController { // Determine which business slot this upgrade targets (first eligible match) const targetSlot = findTargetBusinessSlot(s.state, card); - // If there are multiple upgrade branches for that business, show a choice modal - const branches = getUpgradeBranchesForBusiness(s.state, targetSlot); - if (branches.length > 1) { - this.showUpgradeChoiceModal(branches, targetSlot, sourceIndex); - return; - } - - // Single upgrade available — apply after transfer animation + // Apply the upgrade directly — no intermediate choice modal. + // The player clicked the upgrade card; that is the upgrade to apply. s.uiPhase = 'animating'; s.instructionText.setText(`Applying upgrade "${card.name}"...`); s.hiddenTransferSourceCardIds.add(card.id); @@ -530,120 +613,4 @@ export class MainStreetTurnController { afterTransfer(); } } - - public showUpgradeChoiceModal(branches: UpgradeCard[], targetSlot: number, sourceIndex: number): void { - const s = this.scene; - const MODAL_DEPTH = 20; - const MODAL_W = 500; - const BTN_H = 60; - const HEADER_H = 60; - const FOOTER_H = 50; - const MODAL_H = HEADER_H + branches.length * BTN_H + FOOTER_H; - - const overlay = createOverlayBackground( - s, - { depth: MODAL_DEPTH, alpha: 0.8 }, - { width: MODAL_W, height: MODAL_H, color: 0x1a1208, alpha: 0.95, depth: MODAL_DEPTH }, - ); - s.overlayObjects.push(...overlay.objects); - - const cx = s.layout.gameW / 2; - const cy = s.layout.gameH / 2; - const top = cy - MODAL_H / 2; - - // Title - const title = s.add - .text(cx, top + 24, 'Choose an Upgrade Path', { - fontSize: '18px', fontStyle: 'bold', color: '#ffdd88', fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5, 0.5) - .setDepth(MODAL_DEPTH + 1); - s.overlayObjects.push(title); - - // Branch buttons - branches.forEach((branch, idx) => { - const btnY = top + HEADER_H + idx * BTN_H + BTN_H / 2; - - // Button background - const btnBg = s.add.rectangle(cx, btnY, MODAL_W - 40, BTN_H - 8, 0x2a1f14, 0.9) - .setDepth(MODAL_DEPTH + 1) - .setStrokeStyle(1, 0x665544) - .setInteractive({ useHandCursor: true }); - s.overlayObjects.push(btnBg); - - // Branch label - const costLabel = `$${branch.cost}`; - const bonusLabel = `+${branch.incomeBonus} income, +${branch.synergyRangeBonus} range`; - const btnText = s.add - .text(cx, btnY - 8, branch.name, { - fontSize: '14px', fontStyle: 'bold', color: '#ffffff', fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5, 0.5) - .setDepth(MODAL_DEPTH + 2); - s.overlayObjects.push(btnText); - - const detailText = s.add - .text(cx, btnY + 10, `${costLabel} — ${bonusLabel}`, { - fontSize: '11px', color: '#aaaaaa', fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5, 0.5) - .setDepth(MODAL_DEPTH + 2); - s.overlayObjects.push(detailText); - - const onChoose = (): void => { - // Dismiss modal first - dismissOverlay(s.overlayObjects); - s.overlayObjects = []; - - s.uiPhase = 'animating'; - s.instructionText.setText(`Applying upgrade "${branch.name}"...`); - s.hiddenTransferSourceCardIds.add(branch.id); - s.refreshAll(); - - const afterTransfer = (): void => { - try { - s.undoManager.execute(buyUpgradeCommand(s.state, branch.id, targetSlot)); - s.instructionText.setText(`Applied upgrade: "${branch.name}"`); - } catch (e) { - s.instructionText.setText(`Error: ${(e as Error).message}`); - } - - s.hiddenTransferSourceCardIds.delete(branch.id); - s.uiPhase = 'market'; - s.refreshAll(); - }; - - if (sourceIndex >= 0) { - void s.animateTransferFromMarket({ - cardId: branch.id, - family: 'upgrade', - row: 'investments', - slotIndex: sourceIndex, - destination: s.getStreetSlotCenter(targetSlot), - }).then(afterTransfer); - } else { - afterTransfer(); - } - }; - - btnBg.on('pointerdown', onChoose); - btnBg.on('pointerover', () => btnBg.setFillStyle(0x3a2f24, 0.95)); - btnBg.on('pointerout', () => btnBg.setFillStyle(0x2a1f14, 0.9)); - }); - - // Cancel button - const cancelBtn = createOverlayButton( - s, - cx, - top + MODAL_H - FOOTER_H / 2, - '[ Cancel ]', - MODAL_DEPTH + 2, - { color: '#ff8888', hoverColor: '#ffaaaa' }, - ); - s.overlayObjects.push(cancelBtn); - cancelBtn.on('pointerdown', () => { - dismissOverlay(s.overlayObjects); - s.overlayObjects = []; - }); - } } diff --git a/example-games/main-street/scenes/MainStreetTutorialHints.ts b/example-games/main-street/scenes/MainStreetTutorialHints.ts index 6ba19d44..e754517d 100644 --- a/example-games/main-street/scenes/MainStreetTutorialHints.ts +++ b/example-games/main-street/scenes/MainStreetTutorialHints.ts @@ -20,6 +20,7 @@ */ import { FONT_FAMILY } from '../../../src/ui'; +import { t, registerLocale } from '../../../src/core-engine/I18n'; import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; import { composeResolvedLayouts } from '../../../src/ui/screen-layout-compose'; import { type LayoutViewport } from '../../../src/ui/screen-layout'; @@ -31,9 +32,21 @@ import { type TutorialControllerState, type TutorialHighlightZone, } from '../TutorialFlow'; +import { TUTORIAL_EN_BUNDLE } from '../i18n/tutorial-en'; import baseLayout from '../layouts/main-street.layout.json'; + +/* + * WARNING: Keep main-street-tutorial.layout.json zone coordinates in sync with + * the base layout (main-street.layout.json) and MainStreetRenderer positions. + * The tutorial layout uses sceneWins policy, so its zone rects override base + * zones with the same name. If the base layout or rendering constants change, + * update the corresponding tutorial zone rects to match. + */ import tutorialLayout from '../layouts/main-street-tutorial.layout.json'; +// ── Register English locale bundle at module load time ─────── +registerLocale('en', TUTORIAL_EN_BUNDLE); + // ── Pre-parse layouts at module load ────────────────────────── const baseParsed = parseScreenLayoutDocument(baseLayout); @@ -270,13 +283,13 @@ export class MainStreetTutorialHints { titleEl.style.fontWeight = '700'; titleEl.style.color = '#aaffaa'; titleEl.style.marginBottom = `${padBetweenTitleAndBody}px`; - titleEl.textContent = step.title; + titleEl.textContent = t(step.titleKey); container.appendChild(titleEl); const bodyEl = document.createElement('div'); bodyEl.style.whiteSpace = 'pre-wrap'; bodyEl.style.color = '#ddccbb'; - bodyEl.textContent = step.body; + bodyEl.textContent = t(step.bodyKey); container.appendChild(bodyEl); const btnRow = document.createElement('div'); @@ -295,7 +308,7 @@ export class MainStreetTutorialHints { const leftGroup = document.createElement('div'); if (!isLast) { const exitBtn = document.createElement('button'); - exitBtn.textContent = 'Exit Tutorial'; + exitBtn.textContent = t('tutorial.overlay.exit'); exitBtn.style.background = '#2a1a1a'; exitBtn.style.color = '#cc6666'; exitBtn.style.border = 'none'; @@ -311,7 +324,7 @@ export class MainStreetTutorialHints { } else { // Last step: "Start Full Game" replaces "Exit Tutorial" const startBtn = document.createElement('button'); - startBtn.textContent = 'Start Full Game'; + startBtn.textContent = t('tutorial.overlay.startFullGame'); startBtn.style.background = '#44ff44'; startBtn.style.color = '#002200'; startBtn.style.border = 'none'; @@ -335,7 +348,7 @@ export class MainStreetTutorialHints { // the player navigates backward (e.g. market cards are consumed). const leftGroup = document.createElement('div'); const dismissBtn = document.createElement('button'); - dismissBtn.textContent = 'Dismiss'; + dismissBtn.textContent = t('tutorial.overlay.dismiss'); dismissBtn.style.background = '#2a2a1a'; dismissBtn.style.color = '#aa8866'; dismissBtn.style.border = 'none'; @@ -350,7 +363,7 @@ export class MainStreetTutorialHints { const rightGroup = document.createElement('div'); const nextBtn = document.createElement('button'); - nextBtn.textContent = isLast ? 'Start Full Game' : 'Next >'; + nextBtn.textContent = isLast ? t('tutorial.overlay.startFullGame') : t('tutorial.overlay.next'); nextBtn.style.background = isLast ? '#44ff44' : '#88ff88'; nextBtn.style.color = '#002200'; nextBtn.style.border = 'none'; @@ -409,7 +422,6 @@ export class MainStreetTutorialHints { this.objects.push(stepLabel); } catch (e) { // Fallback to in-canvas tooltip if DOM is not available or fails - // eslint-disable-next-line no-console // DOM tooltip creation failed; fall back to in-canvas rendering const tooltipH = TOOLTIP_H_BASE; @@ -418,8 +430,8 @@ export class MainStreetTutorialHints { const bg = s.add.rectangle(domX + TOOLTIP_W / 2, tooltipY + tooltipH / 2, TOOLTIP_W, tooltipH, 0x1a2a1a).setDepth(TOOLTIP_DEPTH + 1000); const border = s.add.rectangle(domX + TOOLTIP_W / 2, tooltipY + tooltipH / 2, TOOLTIP_W, tooltipH).setStrokeStyle(2, 0x44aa44).setDepth(TOOLTIP_DEPTH + 1001); - const titleTxt = s.add.text(domX + 12, tooltipY + 12, step.title, { fontSize: '16px', color: '#aaffaa', fontFamily: FONT_FAMILY }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); - const bodyTxt = s.add.text(domX + 12, tooltipY + 40, step.body, { fontSize: '13px', color: '#ddccbb', fontFamily: FONT_FAMILY, wordWrap: { width: TOOLTIP_W - 24 } as any }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); + const titleTxt = s.add.text(domX + 12, tooltipY + 12, t(step.titleKey), { fontSize: '16px', color: '#aaffaa', fontFamily: FONT_FAMILY }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); + const bodyTxt = s.add.text(domX + 12, tooltipY + 40, t(step.bodyKey), { fontSize: '13px', color: '#ddccbb', fontFamily: FONT_FAMILY, wordWrap: { width: TOOLTIP_W - 24 } as any }).setDepth(TOOLTIP_DEPTH + 1002).setOrigin(0, 0); const isLast = index === UNIFIED_TUTORIAL_STEP_COUNT - 1; const isActionStep = step.gate === 'action'; @@ -429,14 +441,14 @@ export class MainStreetTutorialHints { // No Continue button: the player performs the in-game action and // the tutorial auto-advances via onTutorialActionComplete. if (!isLast) { - const exitBtn = s.add.text(domX + 16, tooltipY + tooltipH - 30, 'Exit Tutorial', { fontSize: '13px', color: '#cc6666', fontFamily: FONT_FAMILY, padding: { left: 8, right: 8, top: 4, bottom: 4 } as any, backgroundColor: '#2a1a1a' }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); + const exitBtn = s.add.text(domX + 16, tooltipY + tooltipH - 30, t('tutorial.overlay.exit'), { fontSize: '13px', color: '#cc6666', fontFamily: FONT_FAMILY, padding: { left: 8, right: 8, top: 4, bottom: 4 } as any, backgroundColor: '#2a1a1a' }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); exitBtn.on('pointerdown', () => { try { (this.scene as any).exitTutorialFlow?.(); } catch (_) { /* ignore */ } }); this.objects.push(exitBtn); } else { // Last step: "Start Full Game" replaces "Exit Tutorial" - const startBtn = s.add.text(domX + 16, tooltipY + tooltipH - 30, 'Start Full Game', { fontSize: '13px', color: '#002200', fontFamily: FONT_FAMILY, fontStyle: 'bold', padding: { left: 12, right: 12, top: 6, bottom: 6 } as any, backgroundColor: '#44ff44' }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); + const startBtn = s.add.text(domX + 16, tooltipY + tooltipH - 30, t('tutorial.overlay.startFullGame'), { fontSize: '13px', color: '#002200', fontFamily: FONT_FAMILY, fontStyle: 'bold', padding: { left: 12, right: 12, top: 6, bottom: 6 } as any, backgroundColor: '#44ff44' }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); startBtn.on('pointerdown', () => (s as any).confirmTutorialStep?.()); this.objects.push(startBtn); } @@ -445,12 +457,12 @@ export class MainStreetTutorialHints { // ── Confirm canvas row: Dismiss | Next/Finish ──────── // No Prev button: action-gated steps cannot be retried if // the player navigates backward (e.g. market cards are consumed). - const dismissBtn = s.add.text(domX + 12, tooltipY + tooltipH - 30, 'Dismiss', { fontSize: '13px', color: '#aa8866', fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); + const dismissBtn = s.add.text(domX + 12, tooltipY + tooltipH - 30, t('tutorial.overlay.dismiss'), { fontSize: '13px', color: '#aa8866', fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setDepth(TOOLTIP_DEPTH + 1003); dismissBtn.on('pointerdown', () => { try { (this.scene as any).exitTutorialFlow?.(); } catch (_) { /* ignore */ } }); - const nextLabel = isLast ? 'Start Full Game' : 'Next >'; + const nextLabel = isLast ? t('tutorial.overlay.startFullGame') : t('tutorial.overlay.next'); const nextBtn = s.add.text(domX + TOOLTIP_W - 12, tooltipY + tooltipH - 30, nextLabel, { fontSize: '13px', color: '#002200', backgroundColor: isLast ? '#44ff44' : '#88ff88', padding: { left: 6, right: 6 } as any, fontFamily: FONT_FAMILY }).setInteractive({ useHandCursor: true }).setOrigin(1, 0).setDepth(TOOLTIP_DEPTH + 1003); nextBtn.on('pointerdown', () => this.nextStep()); @@ -487,7 +499,6 @@ export class MainStreetTutorialHints { for (const obj of this.objects) { try { obj.destroy(); } catch (e) { // Non-fatal: Phaser may throw when destroying already-destroyed objects in tests. - // eslint-disable-next-line no-console console.debug('[Tutorial] clearObjects: destroy failed', e); } } diff --git a/example-games/main-street/scenes/StatsOverlay.ts b/example-games/main-street/scenes/StatsOverlay.ts index 5d5268c3..ad8ecc6d 100644 --- a/example-games/main-street/scenes/StatsOverlay.ts +++ b/example-games/main-street/scenes/StatsOverlay.ts @@ -88,8 +88,9 @@ const STATS_BUTTON_DEPTH = 1100; /** * A circular stats button ("Σ") that toggles the StatsOverlay. * - * Placed in the HUD header area, next to the Help and Settings buttons. - * Follows the same pattern as HelpButton. + * Placed in the lower-left corner of the screen to avoid overlap + * with the Settings button (upper-right). Follows the same visual + * style as HelpButton and SettingsButton. */ export class StatsButton { private circle: Phaser.GameObjects.Graphics; @@ -110,7 +111,7 @@ export class StatsButton { this.label = scene.add.text(0, 0, '\u03A3', { // Greek capital Sigma for stats fontSize: '16px', - color: '#88ccff', + color: '#f0c040', fontFamily: FONT_FAMILY, fontStyle: 'bold', }); @@ -121,7 +122,7 @@ export class StatsButton { this.hitArea.setDepth(STATS_BUTTON_DEPTH); this.hitArea.setInteractive({ useHandCursor: true }); - this.drawCircle(x, y, radius, 0x333366, 0.9); + this.drawCircle(x, y, radius, 0x333355, 0.9); // Parent into HUD container try { @@ -139,22 +140,22 @@ export class StatsButton { this.hitArea.on('pointerover', () => { if (!this.destroyed) { - this.drawCircle(x, y, radius, 0x444488, 1); + this.drawCircle(x, y, radius, 0x4444aa, 1); this.label.setColor('#ffffff'); } }); this.hitArea.on('pointerout', () => { if (!this.destroyed) { - this.drawCircle(x, y, radius, 0x333366, 0.9); - this.label.setColor('#88ccff'); + this.drawCircle(x, y, radius, 0x333355, 0.9); + this.label.setColor('#f0c040'); } }); } private drawCircle(x: number, y: number, radius: number, color: number, alpha: number): void { this.circle.clear(); - this.circle.lineStyle(2, 0x88ccff, 1); + this.circle.lineStyle(2, 0xf0c040, 1); this.circle.strokeCircle(x, y, radius); this.circle.fillStyle(color, alpha); this.circle.fillCircle(x, y, radius); @@ -617,12 +618,12 @@ export class StatsOverlay { private createStatsButton(): void { const s = this.scene; - // Position: right side of the header area, before the help button. - // The help button is at (canvasWidth - MARGIN - 16, MARGIN + 16) - // Place stats button to its left with some spacing. - const canvasWidth = s.scale.width; - const x = canvasWidth - MARGIN - 16 - 48; // left of help button (~48px spacing) - const y = MARGIN + 16; + // Position: lower-left corner to avoid overlap with the Settings button + // (which is in the upper-right). Uses the same margin + radius formula as + // the HelpButton/SettingsButton default positioning. + const radius = 16; + const x = MARGIN + radius; + const y = s.scale.height - MARGIN - radius; this.statsButton = new StatsButton(s, this, x, y); } diff --git a/example-games/main-street/scenes/TutorialOfferModal.ts b/example-games/main-street/scenes/TutorialOfferModal.ts index df53c50a..242d9bc8 100644 --- a/example-games/main-street/scenes/TutorialOfferModal.ts +++ b/example-games/main-street/scenes/TutorialOfferModal.ts @@ -14,6 +14,7 @@ import { createOverlayButton, FONT_FAMILY, } from '../../../src/ui'; +import { t } from '../../../src/core-engine/I18n'; import type { TutorialStorageAdapter } from '../TutorialState'; import { loadTutorialState, @@ -135,7 +136,7 @@ export class TutorialOfferModal { const title = s.add.text( centerX, panelTop + 32, - 'Welcome to Main Street!', + t('tutorial.modal.title'), { fontSize: '24px', fontStyle: 'bold', @@ -151,8 +152,7 @@ export class TutorialOfferModal { const body = s.add.text( centerX, panelTop + 74, - 'Would you like a guided tutorial to learn\n' + - 'the basics of Main Street?', + t('tutorial.modal.body'), { fontSize: '15px', color: BODY_COLOR, @@ -170,41 +170,44 @@ export class TutorialOfferModal { // Two buttons centered within the modal panel. A 240px gap between // button centres keeps them well inside the 420px panel width with // comfortable padding on the outer edges. + // Convention: left = dismiss/exit action, right = proceed/continue action. const buttonGap = 240; - const startX = centerX - buttonGap / 2; - const endX = centerX + buttonGap / 2; + const leftX = centerX - buttonGap / 2; + const rightX = centerX + buttonGap / 2; - // Start Tutorial button (left) - const startBtn = createOverlayButton( + // Skip button (left — consistent with other tutorial overlays + // where the dismiss/exit action appears on the left) + const skipBtn = createOverlayButton( s, - startX, + leftX, buttonY, - '[ Start Tutorial ]', + '[ ' + t('tutorial.modal.skipBtn') + ' ]', CONTENT_DEPTH, - { fontSize: '15px', color: '#88ff88', hoverColor: '#aaffaa' }, + { fontSize: '15px', color: SKIP_COLOR, hoverColor: SKIP_HOVER_COLOR }, ); - startBtn.on('pointerdown', () => { + skipBtn.on('pointerdown', () => { this.dismiss(); - this.persistStatus('not_seen'); - this.callbacks.onStartTutorial(); + this.persistStatus('skipped'); + this.callbacks.onSkip(); }); - this.overlayObjects.push(startBtn); + this.overlayObjects.push(skipBtn); - // Skip for Now button (right) - const skipBtn = createOverlayButton( + // Start Tutorial button (right — consistent with other tutorial overlays + // where the proceed/continue action appears on the right) + const startBtn = createOverlayButton( s, - endX, + rightX, buttonY, - '[ Skip for Now ]', + '[ ' + t('tutorial.modal.startBtn') + ' ]', CONTENT_DEPTH, - { fontSize: '15px', color: SKIP_COLOR, hoverColor: SKIP_HOVER_COLOR }, + { fontSize: '15px', color: '#88ff88', hoverColor: '#aaffaa' }, ); - skipBtn.on('pointerdown', () => { + startBtn.on('pointerdown', () => { this.dismiss(); - this.persistStatus('skipped'); - this.callbacks.onSkip(); + this.persistStatus('not_seen'); + this.callbacks.onStartTutorial(); }); - this.overlayObjects.push(skipBtn); + this.overlayObjects.push(startBtn); } /** Dismisses the modal and restores interactivity. */ diff --git a/example-games/main-street/scenes/UpgradeOverlaySpec.ts b/example-games/main-street/scenes/UpgradeOverlaySpec.ts index b96ba6ee..7d2a7ed8 100644 --- a/example-games/main-street/scenes/UpgradeOverlaySpec.ts +++ b/example-games/main-street/scenes/UpgradeOverlaySpec.ts @@ -25,7 +25,7 @@ * @module UpgradeOverlaySpec */ -import type { BusinessCard } from '../MainStreetCards'; +import type { BusinessCard, CommunitySpaceCard } from '../MainStreetCards'; // --------------------------------------------------------------------------- // Types @@ -51,8 +51,10 @@ export interface OverlayBorderSpec { export interface UpgradeOverlaySpec { /** Level badge text (e.g. "Lvl 2"), null for base cards. */ levelBadge: OverlayTextSpec | null; - /** Combined income text (e.g. "+8"), null for base cards, always shown for upgraded. */ + /** Per-turn income text (e.g. "+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; /** Upgraded name text, null for base cards. */ nameText: OverlayTextSpec | null; /** Border/glow for upgraded cards, null for base cards. */ @@ -78,12 +80,13 @@ export interface UpgradeOverlaySpec { * @returns An UpgradeOverlaySpec describing all overlays to render. */ export function buildUpgradeOverlaySpec( - biz: BusinessCard, + biz: BusinessCard | CommunitySpaceCard, width: number, height: number, ): UpgradeOverlaySpec { const isUpgraded = biz.level > 0; const totalIncome = biz.baseIncome + biz.incomeBonus; + const totalReputation = (biz.reputationPerTurn ?? 0) + biz.reputationBonus; // Level badge: top-right corner, only for upgraded cards const levelBadge: OverlayTextSpec | null = isUpgraded @@ -97,19 +100,34 @@ export function buildUpgradeOverlaySpec( } : null; - // Income text: bottom center, only shown for upgraded cards to keep - // base cards looking clean (the SVG already shows base income) - const incomeText: OverlayTextSpec | null = isUpgraded + // Income text: bottom-left, shown for any card with income > 0 + const incomeText: OverlayTextSpec | null = totalIncome > 0 ? { - text: `+${totalIncome}`, - x: Math.round(width / 2), + text: `+${totalIncome}/turn`, + x: 8, y: Math.round(height - 8), - fontSize: '12px', + fontSize: '11px', color: '#44ff44', fontStyle: 'bold', } : null; + // Reputation text: bottom-right, 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)) + : '0'; + const reputationText: OverlayTextSpec | null = totalReputation > 0 + ? { + text: `+${repFormatted}/turn`, + x: Math.round(width - 8), + y: Math.round(height - 8), + fontSize: '11px', + color: '#88bbff', + fontStyle: 'bold', + } + : null; + // Name overlay: top center, only for upgraded cards to highlight the new name const nameText: OverlayTextSpec | null = isUpgraded ? { @@ -130,5 +148,5 @@ export function buildUpgradeOverlaySpec( } : null; - return { levelBadge, incomeText, nameText, upgradeBorder }; + return { levelBadge, incomeText, reputationText, nameText, upgradeBorder }; } diff --git a/example-games/main-street/sfx-tf-mapping.ts b/example-games/main-street/sfx-tf-mapping.ts index 4febbdfc..df527fe5 100644 --- a/example-games/main-street/sfx-tf-mapping.ts +++ b/example-games/main-street/sfx-tf-mapping.ts @@ -19,4 +19,8 @@ export const MAIN_STREET_TF_SFX_MAPPING: Record = { 'sfx-upgrade-start': 'construction-lite-hammer', 'sfx-upgrade-end': 'construction-lite-saw', 'sfx-event-cheer': 'crowd-cheer', + 'sfx-income-positive': 'income-positive-chime', + 'sfx-income-negative': 'income-negative-chime', + 'sfx-income-neutral': 'income-neutral-chime', + 'sfx-challenge-complete': 'success-fanfare', }; diff --git a/example-games/sushi-go/scenes/SushiGoOverlayContent.ts b/example-games/sushi-go/scenes/SushiGoOverlayContent.ts index da23a96c..00b24d27 100644 --- a/example-games/sushi-go/scenes/SushiGoOverlayContent.ts +++ b/example-games/sushi-go/scenes/SushiGoOverlayContent.ts @@ -9,9 +9,7 @@ import { OverlayManager, } from '../../../src/ui'; import { createActionButton } from '@ui/Renderer'; -import { - createSushiGoMenuButton, -} from '../../../src/ui/Renderer/adapters/SushiGoAdapter'; + import { scoreTableauBreakdown } from '../SushiGoScoring'; import type { SushiGoSession, RoundResult } from '../SushiGoGame'; import { getWinnerIndex } from '../SushiGoGame'; @@ -208,7 +206,7 @@ export class SushiGoOverlayContent { const playBtn = createActionButton( this.scene, - GAME_W / 2 - 140, + GAME_W / 2 - 20, buttonY - 16, 120, 'Play Again', @@ -219,9 +217,6 @@ export class SushiGoOverlayContent { { depth: 11 }, ); this.overlayManager.add(playBtn); - - const menuBtn = createSushiGoMenuButton(this.scene, GAME_W / 2 + 20, buttonY - 16, 120, { depth: 11 }); - this.overlayManager.add(menuBtn); } private resolveOverlayAnchors( diff --git a/example-games/sushi-go/scenes/SushiGoRenderer.ts b/example-games/sushi-go/scenes/SushiGoRenderer.ts index e2204373..75a657b3 100644 --- a/example-games/sushi-go/scenes/SushiGoRenderer.ts +++ b/example-games/sushi-go/scenes/SushiGoRenderer.ts @@ -3,7 +3,7 @@ */ import { GAME_W, GAME_H, FONT_FAMILY } from '../../../src/ui'; -import { createSceneTitle, createSceneMenuButton } from '@ui/Renderer'; +import { createSceneTitle } from '@ui/Renderer'; import type { SushiGoCard, SushiGoCardType } from '../SushiGoCards'; import { cardLabel } from '../SushiGoCards'; import type { SushiGoSession } from '../SushiGoGame'; @@ -31,7 +31,6 @@ export class SushiGoRenderer { ) {} createHeader(): void { - createSceneMenuButton(this.scene); createSceneTitle(this.scene, 'Sushi Go!'); } diff --git a/example-games/sushi-go/scenes/SushiGoScene.ts b/example-games/sushi-go/scenes/SushiGoScene.ts index f5d23e6d..dc88f4e8 100644 --- a/example-games/sushi-go/scenes/SushiGoScene.ts +++ b/example-games/sushi-go/scenes/SushiGoScene.ts @@ -32,7 +32,7 @@ import { dismissOverlay, PhaseManager, HandView, - createSceneTitle, createSceneMenuButton, + createSceneTitle, TooltipManager, audioPathWithFallback, } from '../../../src/ui'; @@ -179,9 +179,7 @@ export class SushiGoScene extends CardGameScene { this.recorder = null; this.replayStepIndex = -1; - this.detectReplayMode(); - this.initEventSystem(); - this.initHUDContainer(); + super.create(); if (this.replayMode) { this.createHeader(); @@ -260,7 +258,6 @@ export class SushiGoScene extends CardGameScene { // ── UI creation ───────────────────────────────────────── private createHeader(): void { - createSceneMenuButton(this); createSceneTitle(this, 'Sushi Go!'); } diff --git a/example-games/the-mind/scenes/MindAnimator.ts b/example-games/the-mind/scenes/MindAnimator.ts index d69e6000..9d6f3c13 100644 --- a/example-games/the-mind/scenes/MindAnimator.ts +++ b/example-games/the-mind/scenes/MindAnimator.ts @@ -28,6 +28,9 @@ 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, @@ -44,6 +47,10 @@ export class MindAnimator { cardValue: number, onComplete: () => void, ): void { + if (this.reducedMotion) { + onComplete(); + return; + } if (playerId === 0) { this.animateHumanCardToPile(cardValue, onComplete); } else { @@ -181,6 +188,10 @@ export class MindAnimator { // ── Penalty display ──────────────────────────────────── showPenaltyCards(result: PlayResult, onComplete: () => void): void { + if (this.reducedMotion) { + onComplete(); + return; + } const penaltySprites: Phaser.GameObjects.Image[] = []; const startPositions = pickPenaltyStartPositions( @@ -249,6 +260,10 @@ export class MindAnimator { bonusLifeAwarded: boolean, onComplete: () => void, ): void { + if (this.reducedMotion) { + onComplete(); + return; + } const bonusText = bonusLifeAwarded ? '\nBonus life awarded!' : ''; diff --git a/example-games/the-mind/scenes/MindRenderer.ts b/example-games/the-mind/scenes/MindRenderer.ts index 63cb6d68..fd3cff11 100644 --- a/example-games/the-mind/scenes/MindRenderer.ts +++ b/example-games/the-mind/scenes/MindRenderer.ts @@ -508,10 +508,14 @@ export class MindRenderer { disableGameInteraction(autoPlayButton?: Phaser.GameObjects.Text): void { for (const sprite of this.humanCardSprites) { - sprite.disableInteractive(); + if (sprite && sprite.scene) { + sprite.disableInteractive(); + } } if (autoPlayButton) { - autoPlayButton.disableInteractive(); + if (autoPlayButton.scene) { + autoPlayButton.disableInteractive(); + } } } diff --git a/example-games/the-mind/scenes/TheMindScene.ts b/example-games/the-mind/scenes/TheMindScene.ts index f27cd590..2d731eb7 100644 --- a/example-games/the-mind/scenes/TheMindScene.ts +++ b/example-games/the-mind/scenes/TheMindScene.ts @@ -46,7 +46,6 @@ import { OVERLAY_BOX_ALPHA, OVERLAY_BUTTON_FONT_SIZE, OVERLAY_BUTTON_Y_OFFSET, - OVERLAY_BUTTON_SPACING, AUTO_PLAY_BUTTON_X, AUTO_PLAY_BUTTON_MARGIN, AUTO_PLAY_FONT_SIZE, @@ -132,8 +131,7 @@ export class TheMindScene extends CardGameScene { this.events.on('shutdown', this.shutdown, this); this.resetSceneState(); - this.detectReplayMode(); - this.initEventSystem(); + super.create(); if (this.replayMode) { this.createReplayView(); @@ -141,8 +139,11 @@ export class TheMindScene extends CardGameScene { } this.createSoundSystem(); - this.initHUDContainer(); this.initializeGameControllers(); + // Propagate reduced motion preference to the animator + if (this.settingsPanel) { + this.mindAnimator.reducedMotion = this.settingsPanel.reducedMotion; + } this.createPrimaryView(); this.renderInitialState(); this.startLevel(); @@ -498,7 +499,7 @@ export class TheMindScene extends CardGameScene { buttons: [ { label: config.primaryButtonLabel, - x: GAME_W / 2 - OVERLAY_BUTTON_SPACING, + x: GAME_W / 2, y: GAME_H / 2 + OVERLAY_BUTTON_Y_OFFSET, config: { fontSize: OVERLAY_BUTTON_FONT_SIZE }, onClick: () => { @@ -510,22 +511,6 @@ export class TheMindScene extends CardGameScene { this.time.delayedCall(0, () => this.scene.restart()); }, }, - { - label: '[ Menu ]', - x: GAME_W / 2 + OVERLAY_BUTTON_SPACING, - 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: 'menu', - action: 'click', - }); - this.time.delayedCall(0, () => - this.scene.start('GameSelectorScene'), - ); - }, - }, ], }); diff --git a/package.json b/package.json index d79f23a1..0bd8444a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.2", + "version": "0.1.3", "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,7 +8,7 @@ "dev": "vite --host", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "test": "vitest run --project unit && vitest run --project browser", + "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", "transcripts:export": "tsx scripts/export-transcripts.ts", diff --git a/public/assets/games/main-street/svg/cards/biz-clinic.svg b/public/assets/games/main-street/svg/cards/biz-clinic.svg index 014de77d..319a4a43 100644 --- a/public/assets/games/main-street/svg/cards/biz-clinic.svg +++ b/public/assets/games/main-street/svg/cards/biz-clinic.svg @@ -8,17 +8,10 @@ - + Clinic 10 - - - Service icon - - - - - +