diff --git a/AGENTS.md b/AGENTS.md index 767d13cb..4683cd69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,6 @@ tableau-card-engine/ │ ├── cards/ # Card sprite assets (CC0/permissive) │ └── CREDITS.md # Asset attribution ├── tests/ # Vitest test files -│ └── smoke.test.ts ├── dist/ # Production build output (gitignored) ├── AGENTS.md ├── package.json @@ -91,6 +90,18 @@ Each example game is a standalone game that can independently demonstrate the ca Example games live in `example-games//` with their own `main.ts` entry point and `scenes/` directory. The root `index.html` loads a unified entry point (`main.ts`) that boots the **Game Selector** landing page, allowing players to choose between available games. The games are deployed to GitHub Pages at `https://thewizardscode.github.io/Tableau-Card-Engine/` via a GitHub Actions workflow that runs on every push to `main`. +#### Screen Layout Language (SLL) Requirement + +All new example games **must** use the **Screen Layout Language (SLL)** for their UI layouts instead of hardcoded pixel positions. SLL provides a declarative, responsive layout system that ensures consistency, adaptability, and reusability across example games. + +- **Do not** use absolute pixel coordinates for positioning UI elements within scenes. +- **Do** define UI layouts using SLL composition in your scene code. +- Reference the SLL modules for guidance: + - `src/ui/screen-layout.ts` — Core SLL layout engine and utilities + - `src/ui/screen-layout-compose.ts` — SLL composition helpers + - `src/ui/screen-layout-schema.ts` — SLL schema definitions and type contracts +- The Gym (`example-games/gym/`) contains SLL examples (see `GymOverlayUiScene.ts`) that demonstrate both direct and composed SLL usage patterns. + ## Technology Stack - **Phaser 4 RC** (currently 4.0.0-rc.7): The current release-candidate line of the Phaser HTML5 game framework used by this repository. Provides a foundation for building 2D games, including card games, with WebGL/Canvas rendering, input handling, tweens, and scene management. @@ -199,6 +210,186 @@ The best reference implementation is `showSellConfirmation` in `example-games/ma - Handling both confirm (sell) and cancel actions - Proper cleanup and state refresh +## Game Architecture Best Practices + +This section documents architectural patterns, design decisions, and usage guidance extracted from the Gym demo scenes. Each pattern identifies the Gym scene(s) that serve as canonical reference implementations and links to the relevant core-engine API(s). The help panel text in each Gym scene is the authoritative source for "what this feature does and when to use it." + +> **Reference:** See [`docs/gym/GYM_INDEX.md`](docs/gym/GYM_INDEX.md) for the complete scene-to-API mapping, and `docs/DEVELOPER.md` for subsystem-specific deep-dive guidance. + +> **Maintenance note:** This section should be reviewed when new Gym scenes are added or existing ones substantially change, to keep patterns aligned with the latest implementations. + +### 1. Seeded RNG for Deterministic Randomness + +Use `createSeededRng()` and `shuffleArray()` from `@core-engine` to produce deterministic, reproducible random sequences. The same seed always produces the same card order, which is essential for debugging, replay systems, and fairness. + +- **Gym scene:** `GymDeckRngScene` — `example-games/gym/scenes/GymDeckRngScene.ts` +- **Key APIs:** `createSeededRng()`, `shuffleArray()`, `createStandardDeck()` +- **When to use:** "In a game like Golf or Beleaguered Castle, seeded RNG ensures that a player can replay a specific deal for debugging or fair competition." (GymDeckRngScene Features help text) +- **Usage example:** By setting the seed to the same value used during a game session, a developer can reproduce the exact same deck order and inspect the deal sequence to verify correctness. + +### 2. View/Model Separation with HandView and PileView + +Separate card rendering from game logic using `HandView` and `PileView` reusable UI components. These components provide draggable hands, arc layouts, pile management, and rich card animations (deal, discard, flip, move tween). + +- **Gym scene:** `GymHandPileScene` — `example-games/gym/scenes/GymHandPileScene.ts` +- **Key APIs:** `HandView`, `PileView`, `flipCard()`, `discardCard()`, `moveGameObject()`, `shakeIllegalMove()` +- **When to use:** "In a real game like Golf or Lost Cities, HandView renders the player hand and PileView shows draw/discard piles with click-to-interact support." (GymHandPileScene Features help text) +- **Key features:** Arc layout with live sliders (arc, spacing, rotation, selection raise), vertical cascade toggle, drag-and-drop, card animations (deal from deck, flip in-place, discard to pile, illegal-move shake), reduced-motion fallbacks. Selected cards raise out of the hand (`HandView.setSelectionLift()`) perpendicular to their rotation in horizontal layout, and shift right in vertical cascade. Card sprites use per-index depth (`sprite.setDepth(index)`) so the Canvas-compatible selection highlight (depth `index + 0.01`) can never render over the card to the right / below; labels sit at `index + 0.005`. + +### 3. Command Pattern for Reversible Actions (Undo/Redo) + +Use `UndoRedoManager` and `Command` from `@core-engine` to implement reversible actions. Compound commands group multiple sub-actions into a single undo step. New actions after an undo invalidate the redo stack (standard stack semantics). + +- **Gym scene:** `GymUndoRedoScene` — `example-games/gym/scenes/GymUndoRedoScene.ts` +- **Key APIs:** `UndoRedoManager`, `CompoundCommand`, `Command` +- **When to use:** "In a real card game, undo/redo lets a player reverse a mistaken move — for example, undoing a discard and returning the card to hand, or undoing a series of actions that were grouped as a single turn." (GymUndoRedoScene Features help text) +- **Usage example:** "Compound commands group an entire turn's actions (e.g., draw + discard + score) into a single undo step, letting the player reverse the whole turn at once." + +### 4. Strategy Pattern for AI Decision-Making + +Use `AiStrategyBase` (the strategy interface), `AiPlayer` (generic player wrapper with seeded RNG), `pickRandom()`, and `pickBest()` from `@ai` to build AI players with interchangeable decision-making strategies. + +- **Gym scene:** `GymAiStrategyScene` — `example-games/gym/scenes/GymAiStrategyScene.ts` +- **Key APIs:** `AiStrategyBase`, `AiPlayer`, `pickRandom()`, `pickBest()`, `createSeededRng()` +- **When to use:** "In a real game like Lost Cities, an AI strategy implements AiStrategyBase { choosePhase1Action(state, rng): Phase1Action; choosePhase2Action(state, rng): Phase2Action; }. The AiPlayer wraps these strategies and calls them during the game loop." (GymAiStrategyScene Usage Example help text) +- **Key features:** Same strategy + same seed = same pick (deterministic); `pickBest()` breaks ties randomly using the seeded RNG; strategies can be swapped at runtime. + +### 5. Overlay Lifecycle and GeometryMask Clipping + +Use `createOverlayBackground()` and `dismissOverlay()` from `@ui` for modal overlays. Overlays use a semi-transparent background with depth-ordering conventions (backdrop 199, box 200, interactive elements 201). GeometryMask scrollable content regions are cleaned up on dismiss. + +- **Gym scenes:** `GymOverlayUiScene` — `example-games/gym/scenes/GymOverlayUiScene.ts`, `GymParameterizedOverlayScene` — `example-games/gym/scenes/GymParameterizedOverlayScene.ts` +- **Key APIs:** `createOverlayBackground()`, `dismissOverlay()`, `createParameterizedOverlay()`, `dismissParameterizedOverlay()`, GeometryMask (Phaser built-in) +- **When to use:** "In a real card game, overlays are used for confirmation dialogs ('Are you sure you want to quit?'), rule reminders, or modal messages that temporarily block interaction with the game board." (GymOverlayUiScene Features help text) +- **Lifecycle:** Create overlay via `createOverlayBackground`, parent all interactive elements into `hudContainer`, handle dismiss with proper cleanup (GeometryMask destroy, event listener removal, object array reset). The refined depth convention for modal dialogs is documented in the [UI Best Practices section](#ui-best-practices-creating-modal-dialogs) above. + +### 6. Event Sourcing via Transcript Recording + +Extend `TranscriptRecorderBase` from `@core-engine` to record game events as an auditable transcript. A transcript is an array of structured events that can be inspected for replay, debugging, or headless validation. + +- **Gym scene:** `GymTranscriptScene` — `example-games/gym/scenes/GymTranscriptScene.ts` +- **Key APIs:** `TranscriptRecorderBase`, `TranscriptStore`, `autoSaveTranscript()` +- **When to use:** Use transcript recording whenever game state history needs to be captured for replay, debugging, or headless deterministic testing. The Blackjack simulation in this scene demonstrates a realistic multi-event transcript (deal, hit, stick, bust, result) with auto-save on hand end. +- **Test linkage:** The headless deterministic test suite validates that same seed produces identical transcript sequences. + +### 7. Versioned Serialization and Persistence (Save/Load) + +Use `SaveLoadStore`, `serializeWithVersion()`, and `deserializeWithVersion()` from `@core-engine` for versioned game state persistence. The version field ensures forward compatibility — deserialization fails on version mismatch instead of silently corrupting data. + +- **Gym scene:** `GymSaveLoadScene` — `example-games/gym/scenes/GymSaveLoadScene.ts` +- **Key APIs:** `SaveLoadStore`, `serializeWithVersion()`, `deserializeWithVersion()`, `RenderTexture.saveTexture()` / `RenderTexture.snapshot()` +- **When to use:** Any game that needs to persist state between sessions. The scene demonstrates saving a hand of cards along with a RenderTexture screenshot as a visual thumbnail, then restoring both on load. +- **Key features:** Versioned serialization with mismatch detection, full-screen screenshot snapshot, load from storage with HandView integration. + +### 8. Event-Driven Audio and Visual Feedback + +Use `SoundManager`, `GameEventEmitter`, and `EventSoundMapping` from `@core-engine` for game audio. Combine with `popTextOrIcon()`, particle effects, and tint/shake animations for multi-modal feedback. + +- **Gym scene:** `GymAudioFeedbackScene` — `example-games/gym/scenes/GymAudioFeedbackScene.ts` +- **Key APIs:** `SoundManager`, `GameEventEmitter`, `EventSoundMapping`, `popTextOrIcon()`, particle emitters +- **When to use:** Wire `SoundManager` to `GameEventEmitter` for event-driven audio (e.g., card deal → play deal sound). Use `popTextOrIcon()` for lightweight score-change or undo/redo notifications. Add particle effects for celebrations. Provide mute toggle and volume slider with immediate effect. +- **Key features:** Auto-discovery of sound keys, mute toggling with immediate effect, invalid sound handled safely, volume slider, pop text/icon feedback, particle celebration with reduced-motion fallback. + +### 9. Screen Layout Language (SLL) for Declarative Positioning + +Define UI layouts declaratively using JSON layout files with **Screen Layout Language (SLL)** instead of hardcoded pixel positions. SLL provides a responsive layout system with zones, anchors, and viewport normalization. + +- **Gym scene:** `GymSllScene` — `example-games/gym/scenes/GymSllScene.ts` +- **Key APIs:** `parseScreenLayoutDocument()`, `validateScreenLayoutDocument()`, `normalizedToPixels()`, `composeResolvedLayouts()`, `getZoneRect()`, `anchorPoint()`, `VisibilityOwnershipController` +- **When to use:** Every new example game **must** use SLL for all UI layouts. Avoid absolute pixel coordinates for positioning. Reference `src/ui/screen-layout.ts`, `src/ui/screen-layout-compose.ts`, and `src/ui/screen-layout-schema.ts` for the core modules. +- **Composition:** Multiple SLL layouts can be composed via `composeResolvedLayouts()` to separate scene-specific chrome from scene-specific content. +- **All Gym scenes** use SLL (via `anchorPoint()` in their `resolve*Anchor()` helpers) as a reference pattern. + +### 10. Economy and Legality Pattern for Resource Constraints + +Use `EconomyLedger` from `@rule-engine` for resource tracking with constraint enforcement (min/max limits). Use a `LegalityResult` discriminated union (`legalAction` / `illegalAction`) for validating game actions with structured error reasons. + +- **Gym scene:** `GymRuleEngineScene` — `example-games/gym/scenes/GymRuleEngineScene.ts` +- **Key APIs:** `createEconomyLedger()`, `EconomyLedger`, `ResourceDelta`, leglity result helpers +- **When to use:** Any game with resources (currency, health, points) that need constraint validation. The scene demonstrates illegality for multiple reasons: not your turn, insufficient funds, out of bounds, wrong phase. Use `EconomyLedger` to add/subtract resources with automatic constraint enforcement. + +### 11. Tooltip System (DOM and Phaser Modes) + +Use `TooltipManager` from `@ui` for contextual information on hover. Supports two rendering modes: **DOM mode** (HTML overlay over the canvas) and **Phaser mode** (game-object containers rendered within the scene). + +- **Gym scene:** `GymTooltipScene` — `example-games/gym/scenes/GymTooltipScene.ts` +- **Key APIs:** `TooltipManager`, `setTooltips()`/`getTooltips()` from SettingsStore +- **When to use:** Add tooltips to interactive elements (cards, buttons, zones) to explain their function without cluttering the UI. Toggle between modes at runtime via the settings store. + +### 12. Grid and Pathfinding with SpatialRules + +Use `Grid`, `neighbors()`, `shortestPath()`, and `pathExists()` from `@core-engine/SpatialRules` for tile-based grid mechanics, pathfinding, and adjacency computation. + +- **Gym scene:** `GymSpatialRulesScene` — `example-games/gym/scenes/GymSpatialRulesScene.ts` +- **Key APIs:** `Grid`, `neighbors()`, `shortestPath()`, `pathExists()`, `computeAdjacencyBonus()`, `Position`, `DistanceMetric` +- **When to use:** Any game with a spatial board (grid-based card layout, token positioning, pathfinding obstacles). Supports Manhattan, Chebyshev, and Euclidean distance metrics. + +### 13. HUD Component Architecture (HelpPanel, SettingsPanel) + +Use `HelpPanel`, `SettingsPanel`, `HelpButton`, and `SettingsButton` from `@ui` for standard HUD chrome. These provide consistent open/close lifecycle, depth management, and content integration. + +- **Gym scene:** `GymHudComponentsScene` — `example-games/gym/scenes/GymHudComponentsScene.ts` +- **Key APIs:** `HelpPanel`, `SettingsPanel`, `HelpButton`, `SettingsButton` +- **When to use:** Every game scene should use these components for its help and settings UI rather than building custom panels. The base class `GymSceneBase` provides `initHelp()` which integrates the `HelpPanel` lifecycle. + +### 14. Token Pile System for Non-Card Counters + +Use `TokenPileView` from `@ui` for token/counter piles where cards are not the visual model. Supports multiple renderers (colored tokens, card-back tokens, custom shape renderers). + +- **Gym scene:** `GymTokenPileViewScene` — `example-games/gym/scenes/GymTokenPileViewScene.ts` +- **Key APIs:** `TokenPileView`, `createSimpleTokenRenderer()`, `createCardBackTokenRenderer()`, `createFeudalismTokenRenderer()` +- **When to use:** For resource counters, victory point tracks, or any non-card pile that needs add/remove operations with live count labels and click interaction. + +### 15. Market/Offer Engine for Purchase Mechanics + +Use `MarketOfferEngine` from `@card-system` for generic market/offer systems with rows, slots (occupied/empty/locked), visibility toggles, and purchase processing. + +- **Gym scene:** `GymMarketOfferEngineScene` — `example-games/gym/scenes/GymMarketOfferEngineScene.ts` +- **Key APIs:** `createMarketOfferEngine()`, `MarketOfferEngine`, `PurchaseResult` +- **When to use:** Any game with a market board — offer rows of purchasable items, refill from a deck, lock/unlock slots, and process purchases with result feedback (success vs failure with reason). + +### 16. SVG Rasterisation Pipeline + +Use `SvgHelpers` from `@core-engine` (fetchSvgText, rasteriseSvgToTexture, getOrCreateTexture) for rendering SVG assets as Phaser textures with configurable output size and caching. + +- **Gym scene:** `GymSvgHelpersScene` — `example-games/gym/scenes/GymSvgHelpersScene.ts` +- **Key APIs:** `fetchSvgText()`, `rasteriseSvgToTexture()`, `getOrCreateTexture()`, `makeTextureKey()` +- **When to use:** When card faces or game assets are delivered as SVG files that need to be rasterised to Phaser textures at a specific resolution. Textures are cached by key to avoid redundant rasterisation. + +### 17. Internationalisation (I18n) + +Use the `I18n` module from `@core-engine` for locale switching with key-based string lookup and fallback support. + +- **Gym scene:** `GymI18nScene` — `example-games/gym/scenes/GymI18nScene.ts` +- **Key APIs:** `registerLocale()`, `setLocale()`, `getLocale()`, `t()`, `resetI18n()` +- **When to use:** Any game that needs to support multiple languages. Register locale bundles at runtime, switch between locales interactively, and use `t('key')` for automatic lookup. Missing keys return a fallback or the key itself. + +### 18. Feasibility Spikes for Graphics Features + +Use isolated spike scenes for evaluating new graphics pipelines (shaders, lighting) before integrating into shared engine modules. Document findings (capabilities, limitations, fallback paths) in the scene itself. + +- **Gym scenes:** `GymGraphicsShaderSpikeScene` — `example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts`, `GymGraphicsLightingSpikeScene` — `example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts` +- **Key APIs:** Phaser sprite tinting, blend modes (ADD, MULTIPLY, SCREEN, NORMAL), LightPlugin, point lights +- **When to use:** When evaluating whether a new graphics feature (custom shaders, lighting pipeline) can be used safely in the engine. Spikes should: attempt the feature, document findings in help text, fall back gracefully when unavailable, and be peer-reviewed before shared code is refactored. + +### Scene Base Class Pattern + +All Gym demo scenes extend `GymSceneBase` (`example-games/gym/scenes/GymSceneBase.ts`), which provides shared utilities: +- Standard scene header with title, menu button, prev/next navigation +- `initHelp()` — structured help panel with Features, Controls, Usage Example, and Test Plan sections +- `initButtonBar()` — automated button layout via `GymButtonBar` +- `initReducedMotion()` — reads from SettingsStore and browser prefers-reduced-motion +- SLL layout loading helpers (`resolve*Anchor()` pattern) + +**When to extend:** Any new Gym scene should extend `GymSceneBase`. Non-Gym example game scenes should follow the same patterns (header, help panel, SLL layout, HUD components) to ensure consistency. + +--- + +**Related documentation:** +- `docs/gym/GYM_INDEX.md` — Complete scene-to-API mapping with source paths and test references +- `docs/DEVELOPER.md` — Subsystem-specific deep-dive guidance (SLL, HUD, card system, etc.) +- [UI Best Practices: Creating Modal Dialogs](#ui-best-practices-creating-modal-dialogs) — Depth-ordering convention and overlay implementation patterns + ## Worklog Rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6d2dab..488aa7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## v0.1.9 (2026-07-31) +### Features +- When buying a business card that card should go into the hand (CG-0MS0J0Y6G009WXMF) +- Main Street: Hand cards not clickable in market phase (CG-0MS7EPE0I001R6PI) +- Implement: Interactive hit area in handBusinessView.renderCard (CG-0MS7MLCD40005JF8) +- Implement: onHandBusinessCardClick handler + scene wiring (CG-0MS7MLKTA008WYF3) +- Multi-bar registry in GymSceneBase (CG-0MS8TISJM00596PH) +- Save card setup with save games (CG-0MRF13LRL004UB9J) +- Integrate CardMemoryTracker into Lost Cities AI (CG-0MRI9EPGM005NPL8) +- Fix missing card selection and illegal highlights in Gym Hand and Pile Interactions scene (CG-0MRGW6RQQ005NJHG) +- Gym Hand & Pile: selected card raises along its rotation with adjustable distance slider (CG-0MS934XSL00453HF) +- Gym Hand & Pile: selection highlight raises with the card; raise slider max 180 / default 60 (CG-0MS98MXQP007OGFU) +- Gym Hand & Pile: selection highlight renders over cards to the right of the selected card (CG-0MS937FT9006273E) +### Bug Fixes +- Score should be rounded to nearest whole number (CG-0MRF170G70080OSN) +- [test-failure] gym-handpile-cancel.browser.test.ts — failing test (CG-0MS99JZBK001ZIBM) +- Hand cards scroll off left side of screen with two cards (CG-0MS7EP4S0007F7M1) +- Gym Hand & Pile: setup/interaction buttons no longer visible (CG-0MS8T34T8004ZVEM) +- [test-failure] tests/ui/SettingsPanelTooltips.browser.test.ts — failing test (CG-0MS8V1L3H003LYIX) +- [test-failure] tests/main-street/MainStreetScene.browser.test.ts :: only materializes purchased cards at destination after transfer animation completes — failing test (CG-0MS8V1TRB007IE1B) +- [test-failure] tests/main-street/MainStreetZOrder.browser.test.ts :: gameplay containers use default depth (0) — failing test (CG-0MS8V1UEX008X8YR) +- [test-failure] tests/main-street/TutorialOverlayManager.browser.test.ts :: investmentsRow highlight (T9) — failing test (CG-0MS8V1V300080WPA) +- Card style preference is not persisted (CG-0MRO5W3CL000CNGO) +- [test-failure] GymHandPileScene Cancel Move after completed move also returns card to hand — failing test (CG-0MS8UILM40076ISR) +- [test-failure] GymHandPileScene Card can be moved again after cancelling previous move — failing test (CG-0MS8UIMCA008MI7P) +- [test-failure] MainStreetScene only materializes purchased cards at destination after transfer animation completes (desktop + narrow viewports) — failing test (CG-0MS8UIOL9002KVTH) +- [test-failure] Main Street container z-order gameplay containers use default depth (0) — failing test (CG-0MS8UIPER008BI6Q) +- [test-failure] TutorialOverlayManager investmentsRow highlight (T9) covers the investments row for upgrade concept — failing test (CG-0MS8UIQ56000AWGC) +- [test-failure] SettingsPanel tooltips toggle conditional display when hasTooltips is false still displays the Reduced Motion label — failing test (CG-0MS8UJGJW001IOL3) +- [test-failure] SettingsPanel tooltips toggle conditional display when hasTooltips is false still displays the End Turn Key label — failing test (CG-0MS8UJHAR009KBBR) +- [test-failure] SettingsPanel tooltips toggle conditional display when hasTooltips is omitted (defaults to true) displays the Tooltips label by default — failing test (CG-0MS8UJJGT008AQ53) +- Fix getCardDesign() default parameter bug causing persistence failure (CG-0MS83ES5U00853Y4) +- [test-failure] GymHandPileScene Cancel Move returns moved card to original hand position — failing test (CG-0MS8UH8G8009JUW0) +### Other +- Test: Hand business card interactivity in market phase (CG-0MS7ML1W7002DZKB) +- Update GymSceneBase button-bar contract test (CG-0MS8TIRWC006JOX5) +- Button-visibility regression browser test (5 scenes) (CG-0MS8TIS7G0050ILQ) +- Update button-bar docs for multi-bar semantics (CG-0MS8TISUZ006D443) +- Embed CSV data in serialized state (CG-0MS7NH2E50063LAP) +- CSV mismatch resolution and legacy save handling (CG-0MS7NHCTZ009U12O) +- Preserve market state on checkpoint resume (CG-0MS7NHLRT008LYMI) +- Documentation updates and final verification (CG-0MS7NHU1H0014Z81) +- Integrate CardMemoryTracker into game AI systems (CG-0MRI8T1WO003GL9W) +- Update AGENTS.md with game architecture best practices drawn from Gym scenes (CG-0MRPE1D1B003JHAF) +- Add browser-level card design persistence test (CG-0MS83F3K4004JZ0U) +- Update documentation for card design persistence fix (CG-0MS83FDWP005UENP) + ## v0.1.8 (2026-07-29) ### Features - E-1: Add per-run card ownership tracking to MonteCarloRunSummary (CG-0MRYZT4ID008PFFT) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 283ba9d5..f78753d9 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -276,7 +276,7 @@ example-games/ │ ├── GymRouterScene.ts Landing page with navigation cards │ ├── GymSceneBase.ts Shared base class for all Gym scenes │ ├── GymDeckRngScene.ts Deck lifecycle & seeded RNG demo -│ ├── GymHandPileScene.ts Hand/pile interaction demo (bottom-anchored hand arc + live radius slider) +│ ├── GymHandPileScene.ts Hand/pile interaction demo (bottom-anchored hand arc + live arc/spacing/rotation/raise sliders) │ ├── GymOverlayUiScene.ts Overlay & UI configuration demo │ ├── GymUndoRedoScene.ts Undo/redo workflow demo │ ├── GymTranscriptScene.ts Transcript recording demo @@ -364,7 +364,6 @@ public/assets/ └── CREDITS.md Asset attribution tests/ -├── smoke.test.ts Toolchain smoke test ├── fixtures/transcripts/ Fixture transcripts for replay tests (one per game) ├── ai/ AiPlayer, pickRandom, pickBest, barrel export tests ├── card-system/ Card, Deck, Pile unit tests @@ -2002,15 +2001,22 @@ bar.addButton('[ Custom ]', () => { /* ... */ }, { #### Integration with GymSceneBase -Gym scenes call `initButtonBar()` to create a `GymButtonBar` instance and store it as `this.buttonBar`: +Gym scenes call `initButtonBar()` once per button row/section. Each call creates a **new** `GymButtonBar` at the given Y position and appends it to an internal registry — previously created bars are **kept** (no destroy-and-recreate). `this.buttonBar` always points at the most recently created bar: ```typescript -protected initButtonBar(y?: number): void { - this.buttonBar = new GymButtonBar(this, { y: y ?? 60 }); -} +// Controls row 1 +this.initButtonBar(60); +this.buttonBar!.addButton('[ Draw ]', () => this.drawToHand(), { zone: 'center' }); +this.buttonBar!.addButton('[ Discard ]', () => this.discardSelected(), { zone: 'center' }); + +// Controls row 2 — a SECOND bar; row 1 is NOT destroyed +this.initButtonBar(112); +this.buttonBar!.addButton('[ Disable Drag ]', () => this.toggleDrag(), { zone: 'center' }); ``` -Scenes with buttons at multiple Y positions create multiple `GymButtonBar` instances at different Y values. +`initButtonBar(y, opts?)` returns the created bar (also exposed as `this.buttonBar`), and accepts the same `GymButtonBarConfig` overrides as the `GymButtonBar` constructor (e.g. `{ zone: 'left' }`, `{ rowSpacing: 30 }`). + +All registered bars are destroyed automatically when the scene shuts down or is destroyed, so scene restarts are leak-free. `GymSceneBase` wires this cleanup to the Phaser scene `shutdown`/`destroy` events on the first `initButtonBar()` call. The `GymButtonBar` is exported from the UI barrel (`src/ui/index.ts`) and can be used by any scene, not just Gym scenes. diff --git a/docs/gym/GYM_INDEX.md b/docs/gym/GYM_INDEX.md index 227b724b..531fcfd3 100644 --- a/docs/gym/GYM_INDEX.md +++ b/docs/gym/GYM_INDEX.md @@ -28,13 +28,13 @@ npx vitest run --project browser tests/gym/*.browser.test.ts | Scene | Key | Core APIs | Source | Tests | |---|---|---|---|---| -| Deck & Seeded RNG | `GymDeckRngScene` | `createStandardDeck`, `shuffleArray`, `createSeededRng` | [`scenes/GymDeckRngScene.ts`](../../example-games/gym/scenes/GymDeckRngScene.ts) | [`GymDeckRng.test.ts`](../../tests/gym/GymDeckRng.test.ts) | -| Hand & Pile Interactions | `GymHandPileScene` | `Pile`, `createStandardDeck`, `createSeededRng`, `HandView.setMaxRotationDegrees` | [`scenes/GymHandPileScene.ts`](../../example-games/gym/scenes/GymHandPileScene.ts) | [`GymRegistry.test.ts`](../../tests/gym/GymRegistry.test.ts), [`GymHandPileRotation.test.ts`](../../tests/gym/GymHandPileRotation.test.ts) | +| Deck & Seeded RNG | `GymDeckRngScene` | `createStandardDeck`, `shuffleArray`, `createSeededRng` | [`scenes/GymDeckRngScene.ts`](../../example-games/gym/scenes/GymDeckRngScene.ts) | [`GymDeckRngGrid.smoke.browser.test.ts`](../../tests/gym/GymDeckRngGrid.smoke.browser.test.ts), [`GymHeadlessDeterminism.test.ts`](../../tests/gym/GymHeadlessDeterminism.test.ts) | +| Hand & Pile Interactions | `GymHandPileScene` | `Pile`, `createStandardDeck`, `createSeededRng`, `HandView.setMaxRotationDegrees`, `HandView.setSelectionLift` | [`scenes/GymHandPileScene.ts`](../../example-games/gym/scenes/GymHandPileScene.ts) | [`GymRegistry.test.ts`](../../tests/gym/GymRegistry.test.ts), [`GymHandPile.test.ts`](../../tests/gym/GymHandPile.test.ts), [`handPileScene.animation.test.ts`](../../tests/gym/handPileScene.animation.test.ts) | | Overlay & UI Config | `GymOverlayUiScene` | `createOverlayBackground`, `dismissOverlay` | [`scenes/GymOverlayUiScene.ts`](../../example-games/gym/scenes/GymOverlayUiScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Undo / Redo | `GymUndoRedoScene` | `UndoRedoManager`, `CompoundCommand` | [`scenes/GymUndoRedoScene.ts`](../../example-games/gym/scenes/GymUndoRedoScene.ts) | [`GymUndoRedo.test.ts`](../../tests/gym/GymUndoRedo.test.ts) | | Transcript Recording | `GymTranscriptScene` | `TranscriptRecorderBase`, `createSeededRng` | [`scenes/GymTranscriptScene.ts`](../../example-games/gym/scenes/GymTranscriptScene.ts) | [`GymTranscript.test.ts`](../../tests/gym/GymTranscript.test.ts) | | Save / Load State | `GymSaveLoadScene` | `SaveLoadStore`, `serializeWithVersion`, `deserializeWithVersion`, `RenderTexture.saveTexture()`, `RenderTexture.snapshot()`, snapshot persistence via base64 data URL | [`scenes/GymSaveLoadScene.ts`](../../example-games/gym/scenes/GymSaveLoadScene.ts) | [`GymSaveLoad.test.ts`](../../tests/gym/GymSaveLoad.test.ts) | -| Audio & Feedback Config | `GymAudioFeedbackScene` | `SoundManager`, `GameEventEmitter`, `EventSoundMapping` | [`scenes/GymAudioFeedbackScene.ts`](../../example-games/gym/scenes/GymAudioFeedbackScene.ts) | [`GymAudioFeedback.test.ts`](../../tests/gym/GymAudioFeedback.test.ts) | +| Audio & Feedback Config | `GymAudioFeedbackScene` | `SoundManager`, `GameEventEmitter`, `EventSoundMapping` | [`scenes/GymAudioFeedbackScene.ts`](../../example-games/gym/scenes/GymAudioFeedbackScene.ts) | [`GymHeadlessDeterminism.test.ts`](../../tests/gym/GymHeadlessDeterminism.test.ts) | | Shader & Blend Spike | `GymGraphicsShaderSpikeScene` | Sprite tinting, blend modes, shader feasibility | [`scenes/GymGraphicsShaderSpikeScene.ts`](../../example-games/gym/scenes/GymGraphicsShaderSpikeScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Lighting Spike | `GymGraphicsLightingSpikeScene` | Point light, shadow evaluation, WebGL fallback | [`scenes/GymGraphicsLightingSpikeScene.ts`](../../example-games/gym/scenes/GymGraphicsLightingSpikeScene.ts) | [`GymSceneSmoke.browser.test.ts`](../../tests/gym/GymSceneSmoke.browser.test.ts) | | Screen Layout Language (SLL) | `GymSllScene` | `validateScreenLayoutDocument`, `parseScreenLayoutDocument`, `normalizedToPixels`, `getZoneRect`, `anchorPoint` | [`scenes/GymSllScene.ts`](../../example-games/gym/scenes/GymSllScene.ts) | [`GymSllLayout.test.ts`](../../tests/gym/GymSllLayout.test.ts), [`GymSllScene.browser.test.ts`](../../tests/gym/GymSllScene.browser.test.ts) | diff --git a/docs/main-street/prd-multi-use-card-economy.md b/docs/main-street/prd-multi-use-card-economy.md index f7a902a4..8d53c5ba 100644 --- a/docs/main-street/prd-multi-use-card-economy.md +++ b/docs/main-street/prd-multi-use-card-economy.md @@ -44,7 +44,7 @@ totalHandSynergy = Σ perBusinessBonus for each (handCard, tableauBusiness) syne | Action | Cost/Value | Destination | |--------|-----------|-------------| -| Place from hand to tableau | 80% of purchase cost (coins deducted) | Tableau slot | +| Place from hand to tableau | Free (no coin deduction) | 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) | diff --git a/example-games/gym/README.md b/example-games/gym/README.md index 3f9b927d..b6217b3c 100644 --- a/example-games/gym/README.md +++ b/example-games/gym/README.md @@ -19,7 +19,7 @@ Open `http://localhost:3000` and select **Gym** from the game selector. From the | Scene | Key | What it Demonstrates | |---|---|---| | Deck & Seeded RNG | `GymDeckRngScene` | Create/shuffle/draw with deterministic seeded randomness; flip and deal animations | -| Hand & Pile Interactions | `GymHandPileScene` | Move cards with deal/place/discard/move/shake animations; bottom-anchored arc hand layout with live radius and per-card rotation sliders; drop-zone highlights; flip support | +| Hand & Pile Interactions | `GymHandPileScene` | Move cards with deal/place/discard/move/shake animations; bottom-anchored arc hand layout with live radius, per-card rotation, and selection-raise sliders; drop-zone highlights; flip support | | Overlay & UI Config | `GymOverlayUiScene` | Open/close overlays; toggle feedback intensity; GeometryMask scrollable content | | Undo / Redo | `GymUndoRedoScene` | Execute, undo, and redo actions; pop text feedback on undo/redo | | Transcript Recording | `GymTranscriptScene` | Record game events, inspect transcripts; pop text feedback | diff --git a/example-games/gym/scenes/GymHandPileScene.ts b/example-games/gym/scenes/GymHandPileScene.ts index 6865f658..9b274ce3 100644 --- a/example-games/gym/scenes/GymHandPileScene.ts +++ b/example-games/gym/scenes/GymHandPileScene.ts @@ -86,6 +86,9 @@ export class GymHandPileScene extends GymSceneBase { private readonly ARC_SLIDER_WIDTH = 150; private readonly ARC_RADIUS_DEFAULT = 150; private readonly ROTATION_DEGREES_DEFAULT = 25; + // Selection-raise slider bounds — raise defaults to 60px (max 180px) + private readonly RAISE_DEFAULT = 60; + private readonly RAISE_MAX = 180; // Cascade / vertical layout state private readonly CASCADE_SPACING = 42; private readonly CASCADE_X = GAME_W / 2; @@ -97,6 +100,7 @@ export class GymHandPileScene extends GymSceneBase { private arcSlider!: Slider; private spacingSlider!: Slider; private rotationSlider!: Slider; + private raiseSlider!: Slider; // Discard animation mode private discardMode: 'shrink' | 'animate' = 'animate'; @@ -105,8 +109,9 @@ export class GymHandPileScene extends GymSceneBase { // Discard pile face-up display state private faceUpLabel!: Phaser.GameObjects.Text; - // Drag-and-drop demo state - private dragEnabled: boolean = false; + // Drag-and-drop demo state — enabled by default so the primary + // interaction (drag to discard) is available immediately. + private dragEnabled: boolean = true; private dragLabel!: Phaser.GameObjects.Text; private dragButton!: Phaser.GameObjects.Text; @@ -210,7 +215,7 @@ export class GymHandPileScene extends GymSceneBase { }, { heading: 'Controls', - body: '[ Draw ]: Deal a card from the deck to the hand with an arc animation. Demonstrates animateAddCard().\n[ Discard ]: Discard the selected card to the discard pile (animates based on mode).\n[ Recall ]: Move the top card of the discard pile back to the hand.\n[ Flip ]: Flip the selected card (two-phase scale animation).\n[ Move ]: Tween the selected card to a display area. Demonstrates moveGameObject().\n[ Cancel Move ]: Cancel an active move tween and return the card to the hand.\n[ Show Valid ]: Highlight deck and discard zones as valid drop targets using HighlightManager.\n[ Show Illegal ]: Trigger an illegal-move shake animation on the selected card.\n[ Select Next ]: Cycle forward through cards in the hand.\n[ Sort Hand ]: Sort hand by suit then rank.\n[ Shuffle Hand ]: Randomly shuffle the hand.\n[ Reset ]: Shuffle a fresh deck and deal a new starting hand.\n[ Enable Drag ] / [ Disable Drag ]: Toggle drag-and-drop mode. When enabled, drag a card from hand to the discard pile.\n[ Toggle Discard Mode ]: Switch between animate (move+flip to discard pile, default) and shrink (fade+shrink in place).\n[ Toggle Face Up ]: Toggle the discard pile between face-up and face-down display. The order of cards is preserved — only the visible face changes.\nArc slider: Adjust hand curvature live (0 = straight, 200 = maximum arc).\nSpacing slider: Adjust gap between cards in the hand.\nRotation slider: Adjust maximum rotation angle for cards at the edges of an arc layout.\n[ Toggle Layout ]: Switch between horizontal row and vertical cascade layout.' + body: '[ Draw ]: Deal a card from the deck to the hand with an arc animation. Demonstrates animateAddCard().\n[ Discard ]: Discard the selected card to the discard pile (animates based on mode).\n[ Recall ]: Move the top card of the discard pile back to the hand.\n[ Flip ]: Flip the selected card (two-phase scale animation).\n[ Move ]: Tween the selected card to a display area. Demonstrates moveGameObject().\n[ Cancel Move ]: Cancel an active move tween and return the card to the hand.\n[ Show Valid ]: Highlight deck and discard zones as valid drop targets using HighlightManager.\n[ Show Illegal ]: Trigger an illegal-move shake animation on the selected card.\n[ Select Next ]: Cycle forward through cards in the hand.\n[ Sort Hand ]: Sort hand by suit then rank.\n[ Shuffle Hand ]: Randomly shuffle the hand.\n[ Reset ]: Shuffle a fresh deck and deal a new starting hand.\n[ Disable Drag ] / [ Enable Drag ]: Toggle drag-and-drop mode (ON by default). When enabled, drag a card from hand to the discard pile. When disabled, click a card to select it, then click the discard pile to discard it.\n[ Toggle Discard Mode ]: Switch between animate (move+flip to discard pile, default) and shrink (fade+shrink in place).\n[ Toggle Face Up ]: Toggle the discard pile between face-up and face-down display. The order of cards is preserved — only the visible face changes.\nArc slider: Adjust hand curvature live (0 = straight, 200 = maximum arc).\nSpacing slider: Adjust gap between cards in the hand.\nRotation slider: Adjust maximum rotation angle for cards at the edges of an arc layout.\nRaise slider: Adjust how far the selected card lifts out of the hand (default 60px, max 180px; 0 = off). The raise follows the card rotation in arc layout (straight up at 0°); in vertical cascade the selected card shifts right by the slider amount.\n[ Toggle Layout ]: Switch between horizontal row and vertical cascade layout.' }, { heading: 'Usage Example', @@ -218,36 +223,45 @@ export class GymHandPileScene extends GymSceneBase { }, { heading: 'Test Plan', - body: '1. Press [ Draw ] → card animates from deck to hand, event log confirms\n2. Press [ Select Next ] twice → second card selected, log shows selection\n3. Press [ Discard ] → selected card animates to discard pile (animate mode by default)\n4. Press [ Recall ] → card returns from discard to hand\n5. Press [ Flip ] → selected card flips face-down then face-up\n6. Press [ Show Valid ] → green highlights appear on deck and discard zones\n7. Press [ Show Illegal ] → selected card shakes if one is selected\n8. Press [ Toggle Discard Mode ] → switches to shrink mode\n9. Press [ Discard ] → card fades+shrinks in place (shrink mode)\n10. Press [ Toggle Discard Mode ] → switches back to animate mode\n11. Press [ Enable Drag ] → drag a card from hand to discard pile, verify log shows acceptance\n12. Adjust Arc slider → hand curvature changes live\n13. Press [ Toggle Layout ] → layout switches between horizontal and vertical cascade\n14. Press [ Toggle Face Up ] → discard pile shows face-down; press again → face-up\n15. Press [ Reset ] → new hand dealt, all state cleared, face-up state resets to face-up' + body: '1. Press [ Draw ] → card animates from deck to hand, event log confirms\n2. Press [ Select Next ] twice → second card selected, log shows selection\n3. Press [ Discard ] → selected card animates to discard pile (animate mode by default)\n4. Press [ Recall ] → card returns from discard to hand\n5. Press [ Flip ] → selected card flips face-down then face-up\n6. Press [ Show Valid ] → green highlights appear on deck and discard zones\n7. Press [ Show Illegal ] → selected card shakes if one is selected\n8. Press [ Toggle Discard Mode ] → switches to shrink mode\n9. Press [ Discard ] → card fades+shrinks in place (shrink mode)\n10. Press [ Toggle Discard Mode ] → switches back to animate mode\n11. Verify drag is ON by default → drag a card from hand to discard pile, verify log shows acceptance\n12. Press [ Disable Drag ] → drag off; click a card then the discard pile to discard it\n13. Adjust Arc slider → hand curvature changes live\n14. Press [ Toggle Layout ] → layout switches between horizontal and vertical cascade\n15. Press [ Toggle Face Up ] → discard pile shows face-down; press again → face-up\n16. Adjust Raise slider → selected card lifts out of the hand (horizontal: straight up at 0° rotation); select an edge card and verify the raise follows the rotation; switch to vertical cascade and verify the selected card shifts right\n17. Press [ Reset ] → new hand dealt, all state cleared, raise resets to the 60px default, face-up state resets to face-up' } ]); const cx = GAME_W / 2; - // Controls rows 1 + 2 (wrapping) + // Controls row 1 — 12 action buttons spread across left/center/right + // zones so they all fit on a single row (no vertical wrapping). this.initButtonBar(60); - this.buttonBar!.addButton('[ Draw ]', () => this.drawToHand(), { zone: 'center' }); - this.buttonBar!.addButton('[ Discard ]', () => this.discardSelected(), { zone: 'center' }); - this.buttonBar!.addButton('[ Recall ]', () => this.recallFromDiscard(), { zone: 'center' }); - this.buttonBar!.addButton('[ Flip ]', () => this.flipSelected(), { zone: 'center' }); + this.buttonBar!.addButton('[ Draw ]', () => this.drawToHand(), { zone: 'left' }); + this.buttonBar!.addButton('[ Discard ]', () => this.discardSelected(), { zone: 'left' }); + this.buttonBar!.addButton('[ Recall ]', () => this.recallFromDiscard(), { zone: 'left' }); + this.buttonBar!.addButton('[ Flip ]', () => this.flipSelected(), { zone: 'left' }); this.buttonBar!.addButton('[ Move ]', () => this.moveSelectedCard(), { zone: 'center' }); this.buttonBar!.addButton('[ Cancel Move ]', () => this.cancelMove(), { zone: 'center' }); this.buttonBar!.addButton('[ Show Valid ]', () => this.showValidMoves(), { zone: 'center' }); this.buttonBar!.addButton('[ Show Illegal ]', () => this.showIllegalMove(), { zone: 'center' }); - this.buttonBar!.addButton('[ Select Next ]', () => this.selectNext(), { zone: 'center' }); - this.buttonBar!.addButton('[ Sort Hand ]', () => this.sortHand(), { zone: 'center' }); - this.buttonBar!.addButton('[ Shuffle Hand ]', () => this.shuffleHand(), { zone: 'center' }); - this.buttonBar!.addButton('[ Reset ]', () => this.reset(), { zone: 'center' }); - - // Controls row 3 — Drag-and-drop demo and discard mode toggle - const row3Y = 112; - this.initButtonBar(row3Y); - this.dragButton = this.buttonBar!.addButton('[ Enable Drag ]', () => this.toggleDrag(), { zone: 'center' }); - this.dragLabel = createHudText(this, cx - 250, row3Y, 'Drag: off (click card, then drag to discard)', '#777777', { fontSize: '11px' }).setOrigin(0, 0.5); + this.buttonBar!.addButton('[ Select Next ]', () => this.selectNext(), { zone: 'right' }); + this.buttonBar!.addButton('[ Sort Hand ]', () => this.sortHand(), { zone: 'right' }); + this.buttonBar!.addButton('[ Shuffle Hand ]', () => this.shuffleHand(), { zone: 'right' }); + this.buttonBar!.addButton('[ Reset ]', () => this.reset(), { zone: 'right' }); + + // Controls row 2 — mode toggles spread across zones on a single row. + const row2Y = 112; + this.initButtonBar(row2Y); + this.dragButton = this.buttonBar!.addButton('[ Disable Drag ]', () => this.toggleDrag(), { zone: 'left' }); this.buttonBar!.addButton('[ Toggle Discard Mode ]', () => this.toggleDiscardMode(), { zone: 'center' }); - this.discardModeLabel = createHudText(this, cx + 190, row3Y, 'Discard: animate', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); - this.buttonBar!.addButton('[ Toggle Face Up ]', () => this.toggleDiscardFaceUp(), { zone: 'center' }); - this.faceUpLabel = createHudText(this, cx + 470, row3Y, 'Face: up', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); + this.buttonBar!.addButton('[ Toggle Face Up ]', () => this.toggleDiscardFaceUp(), { zone: 'right' }); + this.buttonBar!.addButton('[ Toggle Layout ]', () => this.toggleLayoutDirection(), { zone: 'right' }); + + // Status/info line below the buttons — never overlaps the buttons. + const infoY = 134; + this.dragLabel = createHudText(this, 170, infoY, 'Drag: ON (drag card to the discard pile)', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); + this.discardModeLabel = createHudText(this, 560, infoY, 'Discard: animate', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); + this.faceUpLabel = createHudText(this, 740, infoY, 'Face: up', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); + this.layoutLabel = createHudText(this, 960, infoY, 'Layout: horizontal', '#88ff88', { fontSize: '12px' }).setOrigin(0, 0.5); + + // Apply the default drag state (ON) so HandView is configured before use + this.applyDragState(); createHudText(this, cx, 147, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); @@ -260,6 +274,7 @@ export class GymHandPileScene extends GymSceneBase { const arcSliderX = startX; const spacingSliderX = startX + sliderWidth + sliderHorizGap; const rotationSliderX = startX + 2 * (sliderWidth + sliderHorizGap); + const raiseSliderX = startX + 3 * (sliderWidth + sliderHorizGap); this.arcSlider = new Slider(this, arcSliderX, sliderY, { initialValue: this.ARC_RADIUS_DEFAULT, @@ -300,10 +315,20 @@ export class GymHandPileScene extends GymSceneBase { this.handView.setMaxRotationDegrees(value); }; - // Toggle button and layout label — placed alongside the sliders - this.initButtonBar(sliderY - 4); - this.buttonBar!.addButton('[ Toggle Layout ]', () => this.toggleLayoutDirection(), { zone: 'left' }); - this.layoutLabel = createHudText(this, startX + 3 * (sliderWidth + sliderHorizGap) + 175, sliderY, 'Layout: horizontal', '#88ff88', { fontSize: '12px' }); + // Selection-raise slider — lifts the selected card out of the hand + // along its rotation. Stays visible in both layouts (vertical mode + // shifts the selected card right by the same amount). + this.raiseSlider = new Slider(this, raiseSliderX, sliderY, { + initialValue: this.RAISE_DEFAULT, + minValue: 0, + maxValue: this.RAISE_MAX, + label: 'Raise', + width: sliderWidth, + textColor: '#88ff88', + }); + this.raiseSlider.onValueChange = (value: number) => { + this.handView.setSelectionLift(value); + }; // Sliders self-manage their own pointermove/pointerup listeners, // registering only when actively dragged and unregistering on pointerup. @@ -631,10 +656,14 @@ export class GymHandPileScene extends GymSceneBase { return; } - // Store original position and index so Cancel Move can return the card + // Store original position and index so Cancel Move can return the card. + // Use the hand's base (un-raised) position: the sprite's current x/y + // includes the selection-raise offset, which would return the card to + // a raised position instead of its true resting spot in the hand. this.movedCardIndex = this.selectedIdx; - this.movedCardOrigX = (sprite as any).x; - this.movedCardOrigY = (sprite as any).y; + const basePos = this.handView.getBasePosition(this.selectedIdx); + this.movedCardOrigX = basePos ? basePos.x : (sprite as any).x; + this.movedCardOrigY = basePos ? basePos.y : (sprite as any).y; this.cardMoved = true; const destX = GAME_W / 2 + 200; @@ -668,6 +697,14 @@ export class GymHandPileScene extends GymSceneBase { // If a card was moved, return it to its original hand position if (this.cardMoved && this.movedCardIndex >= 0) { + // Clear the selection first: applySelectionRaise() kills any + // in-flight selection-raise tween and returns the card to its base + // position. Clearing selection also prevents the raise offset from + // being re-applied on top of the return tween, so the card ends up + // exactly at its original hand position. + this.selectedIdx = -1; + this.handView.setSelected(null); + const sprite = this.handView.getSpriteAt(this.movedCardIndex); if (sprite) { if (this.reducedMotion) { @@ -734,9 +771,23 @@ export class GymHandPileScene extends GymSceneBase { if (target) { if (this.reducedMotion) { + // Use both setTint (WebGL) and overlay (Canvas) for renderer compatibility (target as any).setTint(0xff4444); + const tgt = target as any; + const overlayW = tgt.displayWidth ?? tgt.width ?? 96; + const overlayH = tgt.displayHeight ?? tgt.height ?? 130; + const tintOverlay = this.add.rectangle( + tgt.x, tgt.y, + overlayW, overlayH, + 0xff4444, + ) + .setAlpha(0.4) + .setOrigin(tgt.originX ?? 0.5, tgt.originY ?? 0.5) + .setRotation(tgt.rotation ?? 0) + .setDepth((tgt.depth ?? 0) + 0.1); this.time?.delayedCall(200, () => { try { (target as any).clearTint(); } catch (_) { /* ignore */ } + tintOverlay.destroy(); }); this.logEvent('Illegal move (brief tint, reduced-motion)'); } else { @@ -796,6 +847,10 @@ export class GymHandPileScene extends GymSceneBase { this.rotationSlider.setValue(this.ROTATION_DEGREES_DEFAULT); this.handView.setMaxRotationDegrees(this.ROTATION_DEGREES_DEFAULT); + // Reset selection-raise slider to default (raise off) + this.raiseSlider.setValue(this.RAISE_DEFAULT); + this.handView.setSelectionLift(this.RAISE_DEFAULT); + // Reset face-up state to face-up this.discardView.setFaceUp(true); this.faceUpLabel.setText('Face: up'); @@ -878,6 +933,16 @@ export class GymHandPileScene extends GymSceneBase { /** Toggle drag-and-drop mode on/off. */ private toggleDrag(): void { this.dragEnabled = !this.dragEnabled; + this.applyDragState(); + } + + /** + * Apply the current dragEnabled state to HandView and the demo UI. + * + * When enabled, cards are draggable to the discard pile. When disabled, + * the scene restores click-to-select + click-discard-pile behaviour. + */ + private applyDragState(): void { this.handView.setDragEnabled(this.dragEnabled); if (this.dragEnabled) { @@ -889,12 +954,12 @@ export class GymHandPileScene extends GymSceneBase { this.logEvent('Drag mode ON — cards are draggable to the discard pile'); } else { this.dragButton.setText('[ Enable Drag ]'); - this.dragLabel.setText('Drag: off (click card, then drag to discard)'); + this.dragLabel.setText('Drag: off (click card, then click discard pile)'); this.dragLabel.setColor('#777777'); this.handView.setDragValidator(null); this.handView.setSelected(null); this.clearHighlights(); - this.logEvent('Drag mode OFF — restored click-to-select behavior'); + this.logEvent('Drag mode OFF — click a card, then click the discard pile to discard'); } } @@ -1029,6 +1094,7 @@ export class GymHandPileScene extends GymSceneBase { try { this.arcSlider?.destroy(); } catch (_) { /* ignore */ } try { this.spacingSlider?.destroy(); } catch (_) { /* ignore */ } try { this.rotationSlider?.destroy(); } catch (_) { /* ignore */ } + try { this.raiseSlider?.destroy(); } catch (_) { /* ignore */ } // Destroy UI view components (HandView and PileView both have // destroy() that cleans up sprites, labels, and event listeners) diff --git a/example-games/gym/scenes/GymSceneBase.ts b/example-games/gym/scenes/GymSceneBase.ts index d5711f3a..ba4a7918 100644 --- a/example-games/gym/scenes/GymSceneBase.ts +++ b/example-games/gym/scenes/GymSceneBase.ts @@ -51,15 +51,29 @@ export abstract class GymSceneBase extends Phaser.Scene { protected headerDivider?: Phaser.GameObjects.Graphics; /** - * Optional GymButtonBar instance for automated button layout. + * The most recently created GymButtonBar instance for automated button layout. * * Created by calling `initButtonBar()` in the scene's `create()` method. * Once initialised, scene subclasses can use `this.buttonBar.addButton()` * to add buttons that are automatically arranged into left/center/right * zones with even spacing and row wrapping. + * + * Scenes may call `initButtonBar()` multiple times (one call per button + * row/section at its own Y position). This accessor always points at the + * latest bar; all bars are retained in `this.buttonBars`. */ protected buttonBar?: GymButtonBar; + /** + * Collection of all GymButtonBar instances created via `initButtonBar()`. + * + * Successive `initButtonBar()` calls ADD a new bar instead of destroying + * previous ones, so scenes can lay out several independent button rows. + * All registered bars are destroyed automatically on scene shutdown/destroy + * (see `destroyButtonBars()`) so scene restarts are leak-free. + */ + protected buttonBars: GymButtonBar[] = []; + /** Whether reduced motion is currently enabled. Scenes and helpers * should consult this property to skip or shorten animations when true. */ private _reducedMotion: boolean = false; @@ -195,23 +209,54 @@ export abstract class GymSceneBase extends Phaser.Scene { * button bar. Once initialised, use `this.buttonBar.addButton()` * for all button creation. * - * If a button bar was previously created, it is destroyed before - * creating the new one (allows re-creation). + * Multiple calls create ADDITIONAL bars at their own Y positions — + * previously created bars are NOT destroyed, so scenes can lay out + * several independent button rows. All registered bars are destroyed + * automatically when the scene shuts down or is destroyed. * * @param y Y position of the first button row. * @param opts Optional GymButtonBar configuration overrides. - * @returns The created GymButtonBar instance. + * @returns The created GymButtonBar instance (also exposed via `this.buttonBar`). */ protected initButtonBar(y: number, opts?: Partial): GymButtonBar { - // Destroy any existing bar first - if (this.buttonBar) { - try { this.buttonBar.destroy(); } catch (_) { /* ignore */ } - } - - this.buttonBar = new GymButtonBar(this, { y, ...opts }); + const bar = new GymButtonBar(this, { y, ...opts }); + this.buttonBars.push(bar); + this.buttonBar = bar; + this.registerButtonBarCleanup(); return this.buttonBar; } + /** + * Lazily register the button-bar cleanup listeners. + * + * The Phaser scene event emitter is only wired up once the scene boots, + * so the listeners are registered on the first `initButtonBar()` call + * (which always happens inside `create()`). + */ + private registerButtonBarCleanup(): void { + const key = '__buttonBarCleanup'; + if ((this as any)[key]) return; + const cleanup = () => this.destroyButtonBars(); + (this as any)[key] = cleanup; + this.events.on('shutdown', cleanup); + this.events.on('destroy', cleanup); + } + + /** + * Destroy all registered button bars (buttons and their listeners). + * + * Called automatically on scene shutdown/destroy to keep restarts + * leak-free. Resets the registry and accessor so a restarted scene + * starts fresh. + */ + private destroyButtonBars(): void { + for (const bar of this.buttonBars) { + try { bar.destroy(); } catch (_) { /* ignore */ } + } + this.buttonBars = []; + this.buttonBar = undefined; + } + // ── Scene transition hook ───────────────────────────────── /** diff --git a/example-games/lost-cities/AiStrategy.ts b/example-games/lost-cities/AiStrategy.ts index aba6d68c..059e9486 100644 --- a/example-games/lost-cities/AiStrategy.ts +++ b/example-games/lost-cities/AiStrategy.ts @@ -32,7 +32,11 @@ import { getLegalPhase2Actions, } from './LostCitiesRules'; import type { AiStrategyBase } from '../../src/ai'; -import { AiPlayer as AiPlayerBase, pickRandom } from '../../src/ai'; +import { + AiPlayer as AiPlayerBase, + pickRandom, + CardMemoryTracker, +} from '../../src/ai'; // --------------------------------------------------------------------------- // Strategy interface @@ -712,18 +716,34 @@ function greedyChoosePhase2(state: VisibleState): Phase2Action { } // --------------------------------------------------------------------------- -// AI Player class — wraps a strategy + maintains draw history +// AI Player class — wraps a strategy + maintains draw history + card memory // --------------------------------------------------------------------------- +/** + * Default memory configuration for the Lost Cities AI. + * + * The deck has 5 expedition colors × 12 cards per color = 60 cards. + * The tracker groups observations by expedition color, so the maximum + * number of copies of any single key is 12. + */ +const LOST_CITIES_MEMORY_CONFIG = { skill: 80, maxCopies: 12 }; + export class LostCitiesAiPlayer extends AiPlayerBase { private drawHistory: OpponentDrawHistory; + /** + * Probabilistic recall of cards the AI has seen discarded (both its + * own discards and the opponent's). Grouped by expedition color. + */ + readonly memoryTracker: CardMemoryTracker; + constructor( strategy: LostCitiesAiStrategy = GreedyStrategy, rng: () => number = Math.random, ) { super(strategy, rng); this.drawHistory = createOpponentDrawHistory(); + this.memoryTracker = new CardMemoryTracker(LOST_CITIES_MEMORY_CONFIG); } /** Choose a Phase 1 action. */ @@ -751,6 +771,20 @@ export class LostCitiesAiPlayer extends AiPlayerBase { this.drawHistory.set(color, current + 1); } + /** + * Record a card the AI has observed being discarded — either its own + * discard or the opponent's (both are fully visible on the table). + * + * The card is grouped by its expedition color in the memory tracker, + * so the AI can probabilistically recall how many cards of each color + * have cycled through the discard piles. + * + * @param card - The discarded card to remember. + */ + recordDiscard(card: LostCitiesCard): void { + this.memoryTracker.recordKey(card.color); + } + /** Reset draw history (call at start of each round). */ resetRoundHistory(): void { this.drawHistory = createOpponentDrawHistory(); diff --git a/example-games/lost-cities/scenes/LostCitiesTurnController.ts b/example-games/lost-cities/scenes/LostCitiesTurnController.ts index ca684e52..918ae77a 100644 --- a/example-games/lost-cities/scenes/LostCitiesTurnController.ts +++ b/example-games/lost-cities/scenes/LostCitiesTurnController.ts @@ -209,6 +209,9 @@ export class LostCitiesTurnController { if (action.kind === 'play-to-expedition') { this.callbacks.onPlaySound(SFX_KEYS.CARD_PLAY); } else { + // The human player's discard is fully visible — the AI opponent + // observes it and records it in its card memory. + this.aiPlayer.recordDiscard(action.card); this.callbacks.onPlaySound(SFX_KEYS.CARD_DISCARD); } @@ -266,6 +269,11 @@ export class LostCitiesTurnController { const phase1Result = executeAction(this.session, phase1Action); this.recorder.recordAction(this.session, phase1Result, phase1Action, phase1Phase); + // The AI observes its own discard (fully visible) and remembers it. + if (phase1Action.kind === 'discard') { + this.aiPlayer.recordDiscard(phase1Action.card); + } + // Don't refresh expeditions before animation — that would create a // destination sprite with card back before the animated card arrives. // Instead, the card hand sprite is animated to the destination via diff --git a/example-games/main-street/MainStreetCards.ts b/example-games/main-street/MainStreetCards.ts index 46c0f24d..1f7cf239 100644 --- a/example-games/main-street/MainStreetCards.ts +++ b/example-games/main-street/MainStreetCards.ts @@ -18,7 +18,18 @@ import cardDataRaw from './card-data.csv?raw'; import { parseCsv } from '@core-engine/CsvLoader'; import { computeCsvChecksum } from './CsvChecksum'; -const csvRows = parseCsv(cardDataRaw); + +/** + * The raw text content of card-data.csv, bundled at build time via Vite's `?raw` import. + * Exported so that save-game serializers can embed the CSV data in checkpoints. + */ +export const CARD_DATA_RAW: string = cardDataRaw; + +/** Mutable CSV rows container, initialized from the module-level import. + * Can be replaced at runtime by loadTemplatesFromCsv() when a saved + * checkpoint carries different card-data.csv content. + */ +let _csvRows: Record[] = parseCsv(cardDataRaw); /** * Deterministic checksum of the current card-data.csv content. @@ -27,6 +38,172 @@ const csvRows = parseCsv(cardDataRaw); */ export const CSV_CHECKSUM: string = computeCsvChecksum(cardDataRaw); +/** + * The currently active parsed CSV rows. + * Initially loaded from the bundled card-data.csv at module load time. + * When a saved checkpoint carries different CSV data (mismatched checksum), + * loadTemplatesFromCsv() replaces this with the saved CSV's rows. + * + * This is a mutable reference so consumers (SVG regeneration, card lookups) + * always use the currently active card data without needing per-call arguments. + */ +/** + * Returns the currently active parsed CSV rows. + * Initially loaded from the bundled card-data.csv at module load time. + * When a saved checkpoint carries different CSV data (mismatched checksum), + * loadTemplatesFromCsv() replaces this with the saved CSV's rows. + * + * This is a getter so consumers (SVG regeneration, card lookups) + * always use the currently active card data without needing per-call arguments. + */ +export function getCsvRows(): readonly Record[] { + return _csvRows; +} + +/** + * Reloads all module-level card template arrays from the given CSV string. + * + * This allows the deserializer to use saved checkpoint CSV data when the + * bundled card-data.csv has changed, ensuring card templates match the + * saved game state. After a new game setup, resetTemplatesToDefault() + * restores the bundled import. + * + * @param csvData Raw CSV string (same format as card-data.csv). + */ +export function loadTemplatesFromCsv(csvData: string): void { + _csvRows = parseCsv(csvData); + rebuildTemplateArrays(_csvRows); +} + +/** + * Resets all module-level card template arrays to their original + * values from the bundled card-data.csv import. + */ +export function resetTemplatesToDefault(): void { + _csvRows = parseCsv(cardDataRaw); + rebuildTemplateArrays(_csvRows); +} + +/** + * Rebuilds the module-level BUSINESS_TEMPLATES, COMMUNITY_SPACE_TEMPLATES, + * EVENT_TEMPLATES, UPGRADE_TEMPLATES arrays from the given parsed CSV rows. + * Also rebuilds derived lookup maps (CARD_TEMPLATE_NAMES, CARD_TIER_MAP). + */ +function rebuildTemplateArrays(rows: Record[]): void { + // Rebuild template arrays from parsed CSV rows + const bizTemplates = rows + .filter(r => r.family === 'business') + .map(r => ({ + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + baseIncome: Number(r.baseIncome) || 0, + synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], + upgradePath: r.upgradePath || undefined, + maxLevel: Number(r.maxLevel) || 0, + reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, + synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, + synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, + description: r.description, + })); + + const csTemplates = rows + .filter(r => r.family === 'community-space') + .map(r => ({ + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + baseIncome: Number(r.baseIncome) || 0, + synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], + upgradePath: r.upgradePath || undefined, + maxLevel: Number(r.maxLevel) || 0, + reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, + synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, + synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, + description: r.description, + })); + + const evtTemplates: EventCard[] = rows + .filter(r => r.family === 'event') + .map(r => { + const base: EventCard = { + family: 'event', + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + trigger: r.trigger as EventTrigger, + effect: r.effect, + target: r.target as EventTarget, + targetSynergy: (r.targetSynergy || undefined) as SynergyType | undefined, + coinDelta: Number(r.coinDelta) || 0, + reputationDelta: Number(r.reputationDelta) || 0, + }; + if (r.duration) { + return { + ...base, + duration: Number(r.duration), + effectType: r.effectType, + multiplier: Number(r.multiplier) || 0, + } as DurationEventCard; + } + return base; + }); + + const upgTemplates: UpgradeCard[] = rows + .filter(r => r.family === 'upgrade') + .map(r => ({ + family: 'upgrade', + id: r.id, + name: r.name, + targetBusiness: r.targetBusiness, + cost: Number(r.cost) || 0, + incomeBonus: Number(r.incomeBonus) || 0, + synergyRangeBonus: Number(r.synergyRangeBonus) || 0, + description: r.description, + requiredLevel: r.requiredLevel ? Number(r.requiredLevel) : undefined, + reputationBonus: r.reputationBonus ? Number(r.reputationBonus) : undefined, + })); + + // Assign to the mutable module-level variables + _BUSINESS_TEMPLATES.length = 0; + _BUSINESS_TEMPLATES.push(...bizTemplates); + _COMMUNITY_SPACE_TEMPLATES.length = 0; + _COMMUNITY_SPACE_TEMPLATES.push(...csTemplates); + _EVENT_TEMPLATES.length = 0; + _EVENT_TEMPLATES.push(...evtTemplates); + _UPGRADE_TEMPLATES.length = 0; + _UPGRADE_TEMPLATES.push(...upgTemplates); + + // Rebuild derived lookup maps + _CARD_TEMPLATE_NAMES.clear(); + for (const t of bizTemplates) _CARD_TEMPLATE_NAMES.set(t.id, t.name); + for (const t of csTemplates) _CARD_TEMPLATE_NAMES.set(t.id, t.name); + for (const t of evtTemplates) _CARD_TEMPLATE_NAMES.set(t.id, t.name); + for (const t of upgTemplates) _CARD_TEMPLATE_NAMES.set(t.id, t.name); + + _CARD_TIER_MAP.clear(); + for (const row of rows) { + if (row.tier && row.tier.trim() !== '') { + _CARD_TIER_MAP.set(row.id, row.tier.trim()); + } + } + + // Rebuild STAFF_CARD_TEMPLATES + _STAFF_CARD_TEMPLATES.length = 0; + const staffRows = rows.filter(r => r.family === 'staff'); + for (const r of staffRows) { + _STAFF_CARD_TEMPLATES.push({ + family: 'staff', + id: r.id, + name: r.name, + cost: Number(r.cost) || 0, + ongoingCost: Number(r.ongoingCost) || 0, + handSlotsAdded: Number(r.handSlotsAdded) || 0, + description: r.description, + }); + } +} + // ── Synergy & Phase Enums ─────────────────────────────────── /** Synergy types used by Business cards for adjacency bonuses. */ @@ -419,107 +596,78 @@ export interface CommunitySpaceCard { // ── CSV → typed template arrays ───────────────────────────── -/** All Business card templates parsed from the CSV. */ -const BUSINESS_TEMPLATES: Omit[] = - csvRows - .filter(r => r.family === 'business') - .map(r => ({ - id: r.id, - name: r.name, - cost: Number(r.cost) || 0, - baseIncome: Number(r.baseIncome) || 0, - synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], - upgradePath: r.upgradePath || undefined, - maxLevel: Number(r.maxLevel) || 0, - reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, - synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, - synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, - description: r.description, - })); +/** All Business card templates parsed from the CSV. Mutable for runtime CSV reload support. */ +let _BUSINESS_TEMPLATES: Omit[] = []; -/** All Community Space card templates parsed from the CSV. */ -const COMMUNITY_SPACE_TEMPLATES: Omit[] = - csvRows - .filter(r => r.family === 'community-space') - .map(r => ({ - id: r.id, - name: r.name, - cost: Number(r.cost) || 0, - baseIncome: Number(r.baseIncome) || 0, - synergyTypes: (r.synergyTypes || '').split('|').filter(Boolean) as unknown as SynergyType[], - upgradePath: r.upgradePath || undefined, - maxLevel: Number(r.maxLevel) || 0, - reputationPerTurn: r.reputationPerTurn ? Number(r.reputationPerTurn) : undefined, - synergyCoinBonus: r.synergyCoinBonus !== undefined && r.synergyCoinBonus !== '' ? Number(r.synergyCoinBonus) : undefined, - synergyRepBonus: r.synergyRepBonus !== undefined && r.synergyRepBonus !== '' ? Number(r.synergyRepBonus) : undefined, - description: r.description, - })); +/** All Community Space card templates parsed from the CSV. Mutable for runtime CSV reload support. */ +let _COMMUNITY_SPACE_TEMPLATES: Omit[] = []; + +/** All Event card templates parsed from the CSV. Mutable for runtime CSV reload support. */ +let _EVENT_TEMPLATES: EventCard[] = []; + +/** All Upgrade card templates parsed from the CSV. Mutable for runtime CSV reload support. */ +let _UPGRADE_TEMPLATES: UpgradeCard[] = []; + +/** All Staff card templates parsed from the CSV. Mutable for runtime CSV reload support. */ +let _STAFF_CARD_TEMPLATES: StaffCard[] = []; + +/** Mutable map from card template ID to display name. Updated by rebuildTemplateArrays(). */ +let _CARD_TEMPLATE_NAMES: Map = new Map(); + +/** Mutable map from card template ID to tier number. Updated by rebuildTemplateArrays(). */ +let _CARD_TIER_MAP: Map = new Map(); /** - * Read-only view of all parsed CSV rows. - * Used by the SVG regeneration system to generate card SVGs. + * Returns the currently active Business card template arrays. */ -export const CSV_ROWS: readonly Record[] = csvRows; +export function getBusinessTemplates(): typeof _BUSINESS_TEMPLATES { + return _BUSINESS_TEMPLATES; +} -/** All Event card templates parsed from the CSV. */ -const EVENT_TEMPLATES: EventCard[] = - csvRows - .filter(r => r.family === 'event') - .map(r => { - const base: EventCard = { - family: 'event', - id: r.id, - name: r.name, - cost: Number(r.cost) || 0, - trigger: r.trigger as EventTrigger, - effect: r.effect, - target: r.target as EventTarget, - targetSynergy: (r.targetSynergy || undefined) as SynergyType | undefined, - coinDelta: Number(r.coinDelta) || 0, - reputationDelta: Number(r.reputationDelta) || 0, - }; - // Duration events carry extra fields — cast to DurationEventCard if present - if (r.duration) { - return { - ...base, - duration: Number(r.duration), - effectType: r.effectType, - multiplier: Number(r.multiplier) || 0, - } as DurationEventCard; - } - return base; - }); +/** + * Returns the currently active Community Space card template arrays. + */ +export function getCommunitySpaceTemplates(): typeof _COMMUNITY_SPACE_TEMPLATES { + return _COMMUNITY_SPACE_TEMPLATES; +} -/** All Upgrade card templates parsed from the CSV. */ -const UPGRADE_TEMPLATES: UpgradeCard[] = - csvRows - .filter(r => r.family === 'upgrade') - .map(r => ({ - family: 'upgrade', - id: r.id, - name: r.name, - targetBusiness: r.targetBusiness, - cost: Number(r.cost) || 0, - incomeBonus: Number(r.incomeBonus) || 0, - synergyRangeBonus: Number(r.synergyRangeBonus) || 0, - description: r.description, - requiredLevel: r.requiredLevel ? Number(r.requiredLevel) : undefined, - reputationBonus: r.reputationBonus ? Number(r.reputationBonus) : undefined, - })); +/** + * Returns the currently active Event card template arrays. + */ +export function getEventTemplates(): EventCard[] { + return _EVENT_TEMPLATES; +} -/** All Staff card templates parsed from the CSV. */ -export const STAFF_CARD_TEMPLATES: StaffCard[] = - csvRows - .filter(r => r.family === 'staff') - .map(r => ({ - family: 'staff', - id: r.id, - name: r.name, - cost: Number(r.cost) || 0, - ongoingCost: Number(r.ongoingCost) || 0, - handSlotsAdded: Number(r.handSlotsAdded) || 0, - description: r.description, - })); +/** + * Returns the currently active Upgrade card template arrays. + */ +export function getUpgradeTemplates(): UpgradeCard[] { + return _UPGRADE_TEMPLATES; +} + +/** + * Returns the currently active Staff card templates. + */ +export function getStaffCardTemplates(): StaffCard[] { + return _STAFF_CARD_TEMPLATES; +} + +// Initialize all template arrays from the bundled CSV +rebuildTemplateArrays(_csvRows); + +/** + * @deprecated Use getCsvRows() instead. + * Kept for backward compatibility. This reference is set at module init time + * and will NOT update after loadTemplatesFromCsv() is called. Consumers + * should use getCsvRows() for the live value. + */ +export const CSV_ROWS: readonly Record[] = _csvRows; + +/** + * @deprecated Use getStaffCardTemplates() instead. + * Kept for backward compatibility with existing test code. + */ +export const STAFF_CARD_TEMPLATES: StaffCard[] = _STAFF_CARD_TEMPLATES; // ── Deck Building ─────────────────────────────────────────── @@ -532,7 +680,7 @@ export const STAFF_CARD_TEMPLATES: StaffCard[] = export function createStaffDeck(copies: number = 1): StaffCard[] { const deck: StaffCard[] = []; for (let c = 0; c < copies; c++) { - for (const template of STAFF_CARD_TEMPLATES) { + for (const template of _STAFF_CARD_TEMPLATES) { deck.push({ ...template, id: `${template.id}-${c}` }); } } @@ -553,8 +701,8 @@ export function createBusinessDeck( unlockedCardIds?: string[], ): BusinessCard[] { const templates = unlockedCardIds - ? BUSINESS_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) - : BUSINESS_TEMPLATES; + ? _BUSINESS_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) + : _BUSINESS_TEMPLATES; const deck: BusinessCard[] = []; for (let c = 0; c < copies; c++) { @@ -580,8 +728,8 @@ export function createCommunitySpaceDeck( unlockedCardIds?: string[], ): CommunitySpaceCard[] { const templates = unlockedCardIds - ? COMMUNITY_SPACE_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) - : COMMUNITY_SPACE_TEMPLATES; + ? _COMMUNITY_SPACE_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) + : _COMMUNITY_SPACE_TEMPLATES; const deck: CommunitySpaceCard[] = []; for (let c = 0; c < copies; c++) { @@ -613,8 +761,8 @@ export function createEventDeck( positiveIncidentMultiplier: number = 1, ): EventCard[] { const templates = unlockedCardIds - ? EVENT_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) - : EVENT_TEMPLATES; + ? _EVENT_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) + : _EVENT_TEMPLATES; // If multiplier > 1, positive Incident templates should appear more often. // Implement fractional multipliers deterministically without introducing @@ -693,8 +841,8 @@ export function createUpgradeDeck( unlockedCardIds?: string[], ): UpgradeCard[] { const templates = unlockedCardIds - ? UPGRADE_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) - : UPGRADE_TEMPLATES; + ? _UPGRADE_TEMPLATES.filter((t) => unlockedCardIds.includes(t.id)) + : _UPGRADE_TEMPLATES; const deck: UpgradeCard[] = []; for (let c = 0; c < copies; c++) { @@ -740,19 +888,16 @@ export function cardLabel(card: AnyCard): string { /** * Read-only map from card template ID (e.g. `'biz-cafe'`) to its display name - * (e.g. `'Cafe'`). Built once at module load from the CSV-derived template arrays. + * (e.g. `'Cafe'`). Updated at runtime when loadTemplatesFromCsv() is called. * * This is used by the meta-progression UI to show which cards a newly unlocked * tier adds to the player's card pool. + * + * NOTE: This is a mutable Map object that is cleared and re-populated when + * templates are reloaded. Consumers receive a reference to the same Map object, + * so they always see the current data without re-importing. */ -export const CARD_TEMPLATE_NAMES: ReadonlyMap = (() => { - const m = new Map(); - for (const t of BUSINESS_TEMPLATES) m.set(t.id, t.name); - for (const t of COMMUNITY_SPACE_TEMPLATES) m.set(t.id, t.name); - for (const t of EVENT_TEMPLATES) m.set(t.id, t.name); - for (const t of UPGRADE_TEMPLATES) m.set(t.id, t.name); - return m; -})(); +export const CARD_TEMPLATE_NAMES: ReadonlyMap = _CARD_TEMPLATE_NAMES; // --------------------------------------------------------------------------- // Card template ID → tier mapping (from CSV tier column) @@ -764,13 +909,6 @@ export const CARD_TEMPLATE_NAMES: ReadonlyMap = (() => { * * Built once at module load from the CSV `tier` column. * Cards without a tier assignment (e.g. staff cards) are omitted from this map. + * Updated at runtime when loadTemplatesFromCsv() is called. */ -export const CARD_TIER_MAP: ReadonlyMap = (() => { - const m = new Map(); - for (const row of csvRows) { - if (row.tier && row.tier.trim() !== '') { - m.set(row.id, row.tier.trim()); - } - } - return m; -})(); +export const CARD_TIER_MAP: ReadonlyMap = _CARD_TIER_MAP; diff --git a/example-games/main-street/MainStreetEngine.ts b/example-games/main-street/MainStreetEngine.ts index 98430335..bd02c3e3 100644 --- a/example-games/main-street/MainStreetEngine.ts +++ b/example-games/main-street/MainStreetEngine.ts @@ -18,7 +18,7 @@ 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 { SELL_VALUE_RATIO, isDurationEventCard, type DurationEventCard } from './MainStreetCards'; import { createActiveEffect, decayActiveEffects } from '../../src/core-engine/ActiveEffect'; import { recordMainStreetEvent } from './MainStreetTranscript'; import { applyIncome, type IncomeResult, updateNeighborsOnPlacement, updateNeighborsOnSale } from './MainStreetAdjacency'; @@ -527,18 +527,24 @@ export function checkEndConditions(state: MainStreetState): boolean { /** * Executes the DayStart phase: * - Increments turn counter (except turn 1). - * - Refills the market. + * - Refills the market (unless skipMarketRefill is true, e.g., checkpoint resume). * - Transitions to MarketPhase. + * + * @param state Current game state (mutated in-place). + * @param skipMarketRefill When true, skips refillAllMarkets. Used during + * checkpoint resume to preserve saved market state. */ -export function executeDayStart(state: MainStreetState): void { +export function executeDayStart(state: MainStreetState, skipMarketRefill: boolean = false): void { if (state.phase !== 'DayStart') { throw new Error(`Expected DayStart phase, got ${state.phase}`); } // Turn 1 is already set by setup; subsequent turns increment here if (state.turn > 1 || state.phase === 'DayStart') { - // Refill market at start of each day - refillAllMarkets(state); + // Refill market at start of each day (skip on checkpoint resume) + if (!skipMarketRefill) { + refillAllMarkets(state); + } } // Log turn header @@ -706,17 +712,6 @@ export function placeFromHand( 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; @@ -724,7 +719,7 @@ export function placeFromHand( // Incrementally update the new card's and all affected neighbors' cached values updateNeighborsOnPlacement(state, slotIndex); - addLog(state, `Placed ${card.name} from hand in slot ${slotIndex} (-€${placementCost})`, 'loss'); + addLog(state, `Placed ${card.name} from hand in slot ${slotIndex}`, 'neutral'); } /** diff --git a/example-games/main-street/MainStreetState.ts b/example-games/main-street/MainStreetState.ts index 6440b9cc..990acddb 100644 --- a/example-games/main-street/MainStreetState.ts +++ b/example-games/main-street/MainStreetState.ts @@ -23,11 +23,14 @@ import { createUpgradeDeck, createStaffDeck, CSV_CHECKSUM, + CARD_DATA_RAW, GRID_SIZE, MARKET_BUSINESS_SLOTS, MARKET_INVESTMENT_UPGRADE_COUNT, MARKET_INVESTMENT_EVENT_COUNT, INCIDENT_QUEUE_SIZE, + loadTemplatesFromCsv, + resetTemplatesToDefault, } from './MainStreetCards'; import { type ActiveChallenge, @@ -294,6 +297,14 @@ export interface MainStreetSerializedState { * Empty string indicates a legacy save before this field was added. */ csvChecksum: string; + /** + * Raw content of the card-data.csv at the time this save was created. + * Stored as a raw string so that if the game's card-data.csv changes + * between save and load, the original CSV data can be recovered and + * used to reconstruct card templates that match the saved state. + * Empty string indicates a legacy save before this field was added. + */ + csvData: string; /** * Tracks which street grid slots have been sold. Length = GRID_SIZE. * true = card in this slot has been sold (non-functional). @@ -416,6 +427,9 @@ function fillMarketSlots(deck: T[], count: number): T[] { * @returns A fully initialised MainStreetState ready for turn 1. */ export function setupMainStreetGame(options: MainStreetSetupOptions = {}): MainStreetState { + // Ensure templates use the bundled CSV data (reset any previous saved-CSV override) + resetTemplatesToDefault(); + const seed = options.seed ?? generateSeedString(); const numericSeed = seedToNumber(seed); const baseRng = createSeededRng(numericSeed); @@ -576,6 +590,7 @@ export function serializeMainStreetState(state: MainStreetState): MainStreetSeri skipMarketCycleOnEndTurn: state.skipMarketCycleOnEndTurn, soldSlots: [...state.soldSlots], csvChecksum: CSV_CHECKSUM, + csvData: CARD_DATA_RAW, }; } @@ -680,6 +695,11 @@ function migrateSerializedState(saved: Record): void { (saved as Record).csvChecksum = ''; } + // ── csvData: add missing field (defaults to '' for legacy saves) ─ + if (!('csvData' in saved)) { + (saved as Record).csvData = ''; + } + // ── soldSlots: add missing field (defaults to all false for legacy saves) ─ if (!('soldSlots' in saved)) { (saved as Record).soldSlots = new Array(GRID_SIZE).fill(false); @@ -699,6 +719,24 @@ function migrateSerializedState(saved: Record): void { export function deserializeMainStreetState(saved: MainStreetSerializedState): MainStreetState { migrateSerializedState(saved as unknown as Record); + // ── CSV mismatch detection ──────────────────────────────── + // If the saved checkpoint was created with a different card-data.csv, + // detect the mismatch and either use the embedded CSV data or reject + // legacy saves that lack it. + if (saved.csvChecksum && saved.csvChecksum !== CSV_CHECKSUM) { + if (saved.csvData && saved.csvData.length > 0) { + // Use the saved CSV data to reconstruct card templates + loadTemplatesFromCsv(saved.csvData); + } else { + // Legacy save without embedded CSV data — reject gracefully + throw new Error( + 'This saved state was created with a different version of card-data.csv ' + + 'and does not include the embedded card data required for compatibility. ' + + 'Starting a fresh game instead.', + ); + } + } + const baseRng = createSeededRng(saved.numericSeed); for (let i = 0; i < saved.rngCalls; i++) { baseRng(); diff --git a/example-games/main-street/TutorialFlow.ts b/example-games/main-street/TutorialFlow.ts index e5198c24..b69e45a3 100644 --- a/example-games/main-street/TutorialFlow.ts +++ b/example-games/main-street/TutorialFlow.ts @@ -32,8 +32,8 @@ * | T5 | Confirm (no cost) | 0 | 0 | 8 | * | T6 | End Turn + income (~1 coin)| 1 | 0 | 9 | * | T7 | Buy Local Festival ($3) | 0 | 3 | 6 | - * | T8 | Buy Bookshop ($3) + auto-place | 0 | 3 | 3 | - * | T9 | Confirm (no cost) | 0 | 0 | 3 | + * | T8 | Buy Bookshop ($3) to hand | 0 | 3 | 3 | + * | T9 | Place Bookshop from hand (free) | 0 | 0 | 3 | * | T10 | Confirm (no cost) | 0 | 0 | 3 | * | T11 | Confirm (no cost) | 0 | 0 | 3 | * | T12 | Confirm (no cost) | 0 | 0 | ~6 | @@ -149,7 +149,7 @@ export interface UnifiedTutorialStepDef { * New steps (from the original 13-step set and split Challenges/Scoring) * come from the reference system to fill gaps. * - * Gate type distribution: 10 confirm + 4 action. + * Gate type distribution: 8 confirm + 6 action (T3, T4, T6, T7, T8, T9). */ export const UNIFIED_TUTORIAL_STEPS: readonly UnifiedTutorialStepDef[] = [ { @@ -229,8 +229,9 @@ export const UNIFIED_TUTORIAL_STEPS: readonly UnifiedTutorialStepDef[] = [ id: 'T9', titleKey: tutorialKey('T9', 'title'), bodyKey: tutorialKey('T9', 'body'), - highlightZone: 'investmentsRow', - gate: 'confirm', + highlightZone: 'streetGrid', + gate: 'action', + requiredAction: 'place-business', }, { id: 'T10', diff --git a/example-games/main-street/i18n/tutorial-en.ts b/example-games/main-street/i18n/tutorial-en.ts index 5e796223..a641f441 100644 --- a/example-games/main-street/i18n/tutorial-en.ts +++ b/example-games/main-street/i18n/tutorial-en.ts @@ -146,11 +146,14 @@ export const TUTORIAL_EN_BUNDLE: Record = { 'Having the right businesses on your street makes your investment cards stronger!\n' + 'This card will be placed automatically.', - // ── T9: Upgrade Concept ───────────────────────────────────── + // ── T9: Place from Hand ────────────────────────────────── [tutorialKey('T9', 'title')]: - 'Upgrade Concept', + 'Place from Hand', [tutorialKey('T9', 'body')]: - 'Upgrades make a business better. Strong upgrades earn more money over time.', + 'The Bookshop is now in your hand.\n' + + 'Click an empty street slot to place it.\n\n' + + 'Tip: You can hold multiple cards in your hand\n' + + 'before deciding where to place them.', // ── T10: Your Hand ────────────────────────────────────────── [tutorialKey('T10', 'title')]: diff --git a/example-games/main-street/scenes/MainStreetConstants.ts b/example-games/main-street/scenes/MainStreetConstants.ts index 7f78a2aa..3b9ff05a 100644 --- a/example-games/main-street/scenes/MainStreetConstants.ts +++ b/example-games/main-street/scenes/MainStreetConstants.ts @@ -103,6 +103,7 @@ export interface SceneLayout { streetCols: number; handY: number; handX: number; + handCenterX: number; handCardW: number; handCardH: number; instructionY: number; diff --git a/example-games/main-street/scenes/MainStreetHudTooltips.ts b/example-games/main-street/scenes/MainStreetHudTooltips.ts index 3fb2318b..60e3af20 100644 --- a/example-games/main-street/scenes/MainStreetHudTooltips.ts +++ b/example-games/main-street/scenes/MainStreetHudTooltips.ts @@ -234,9 +234,9 @@ export function buildScoreTooltip( const lines = [ t(HUD_TOOLTIP_I18N_KEYS.scoreTitle), - `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${score}/${threshold}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreEstimateLabel)}: ${Math.round(score)}/${threshold}`, '', - `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownCoins)}: ${coins.toFixed(3)}`, + `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownCoins)}: ${Math.round(coins)}`, `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownReputation)} ${state.config.reputationScoreMultiplier}: ${repContribution}`, `${t(HUD_TOOLTIP_I18N_KEYS.scoreBreakdownChallenges)}: ${challengeContribution}`, ]; @@ -244,7 +244,7 @@ export function buildScoreTooltip( if (remaining > 0) { lines.push( '', - `${remaining} ${t(HUD_TOOLTIP_I18N_KEYS.scoreRemainingToWin)}`, + `${Math.round(remaining)} ${t(HUD_TOOLTIP_I18N_KEYS.scoreRemainingToWin)}`, ); } else { lines.push( diff --git a/example-games/main-street/scenes/MainStreetLayoutAdapter.ts b/example-games/main-street/scenes/MainStreetLayoutAdapter.ts index ef171ba3..8e727dd1 100644 --- a/example-games/main-street/scenes/MainStreetLayoutAdapter.ts +++ b/example-games/main-street/scenes/MainStreetLayoutAdapter.ts @@ -108,6 +108,7 @@ export function computeMainStreetLayoutWithSll(): SceneLayout { streetCols: STREET_COLS, handY: Math.round(handTopLeft.y), handX: Math.round(handTopLeft.x), + handCenterX: Math.round(streetTopCenter.x), handCardW: BASE_HAND_CARD_W, handCardH: BASE_HAND_CARD_H, instructionY: Math.round(handTopLeft.y - 20), diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index fb222cc8..d1ba23fe 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -961,9 +961,12 @@ export class MainStreetLifecycleManager { if (s.campaign) { s.campaign.tutorialSeen = true; } - // Rebuild renderer and start day phase from checkpoint state + // Rebuild renderer and start day phase from checkpoint state. + // Pass skipMarketRefill=true to preserve the saved market state + // (the saved state already has the correct market from save time; + // calling refillAllMarkets would replace it with fresh deck draws). try { s.refreshAll(); } catch (_) { /* ignore */ } - try { s.startDayPhase(); } catch (_) { /* ignore */ } + try { s.startDayPhase(true); } catch (_) { /* ignore */ } // Load game: compare saved checksum against current CSV this.checkForCsvMismatchAndRegenerate(savedChecksum).catch(() => {}); diff --git a/example-games/main-street/scenes/MainStreetOverlayContent.ts b/example-games/main-street/scenes/MainStreetOverlayContent.ts index f8667927..70186a3d 100644 --- a/example-games/main-street/scenes/MainStreetOverlayContent.ts +++ b/example-games/main-street/scenes/MainStreetOverlayContent.ts @@ -89,10 +89,10 @@ export class MainStreetOverlayContent { const challenges = s.state.challengesCompleted.length; const cfg = s.state.config; const lines = [ - `Coins: ${coins}`, + `Coins: ${Math.round(coins)}`, `Reputation: ${reputation} (x${cfg.reputationScoreMultiplier} = ${reputation * cfg.reputationScoreMultiplier})`, `Challenges: ${challenges} (x${cfg.challengeBonusPoints} = ${challenges * cfg.challengeBonusPoints})`, - `Final Score: ${result.finalScore}`, + `Final Score: ${Math.round(result.finalScore)}`, ]; const breakdownY = panelTop + 110; const breakdown = s.add.text(s.layout.gameW / 2, breakdownY, lines.join('\n'), { @@ -197,7 +197,7 @@ export class MainStreetOverlayContent { : 0; const statsLines = [ `Runs: ${s.campaign.totalRuns} | Wins: ${s.campaign.totalWins} (${winRate}%)`, - `High Score: ${s.campaign.highestScore} | Best Rep: ${s.campaign.persistentReputation}`, + `High Score: ${Math.round(s.campaign.highestScore)} | Best Rep: ${s.campaign.persistentReputation}`, ]; const statsText = s.add.text( s.layout.gameW / 2, cursorY, statsLines.join('\n'), diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 6508ca18..587348da 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -78,6 +78,8 @@ import { computeMainStreetLayoutWithSll } from './MainStreetLayoutAdapter'; export class MainStreetRenderer { /** HandView for player hand — uses renderCard for SVG event card rendering. */ handView!: HandView; + /** HandView for business cards held in hand — supports selection highlighting. */ + handBusinessView!: HandView; constructor(private readonly scene: any) {} @@ -113,11 +115,12 @@ export class MainStreetRenderer { s.handContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'handContainer'); // Create HandView for the player's hand (anticipates multi-event-card support) - const { handX, handY, handCardW, handCardH } = s.layout; + const { handX, handY, handCardW, handCardH, handCenterX } = s.layout; // HandView is created at the hand slot centre — renderCard positions cards via HandView layout this.handView = new HandView(s, { baseX: handX + handCardW / 2, baseY: handY + handCardH / 2, + centerX: handCenterX, spacing: handCardW + 10, cardWidth: handCardW, showLabels: false, @@ -150,7 +153,54 @@ export class MainStreetRenderer { return container; }, }); + // Create HandView for business cards (hand cards from purchase) + this.handBusinessView = new HandView(s, { + baseX: handX + handCardW / 2, + baseY: handY, + centerX: handCenterX, + spacing: handCardW + 8, + cardWidth: handCardW, + showLabels: false, + selectionEnabled: false, + clickEnabled: true, + renderCard: (_card, cardIndex) => { + const card = _card as any; + const container = s.add.container(0, 0); + const renderW = Math.max(1, Math.round(handCardW - 4)); + const renderH = Math.max(1, Math.round(handCardH - 4)); + + mainStreetRenderCardSvg(s, container, card.id, renderW, renderH); + + // Apply income/reputation overlays + this.applyUpgradeOverlays(container, card, renderW, renderH); + + // Add interactive hit area so cards can be clicked during market phase + // to start the placing-from-hand flow. + if (!s.replayMode) { + const hitArea = s.add.rectangle(0, 0, handCardW, handCardH, 0x000000, 0.001); + hitArea.setInteractive({ useHandCursor: true }); + hitArea.on('pointerdown', () => { + s.onHandBusinessCardClick(cardIndex); + }); + container.add(hitArea); + } + + return container; + }, + customClickFn: (cardIndex: number) => { + // Allow selecting a different card in the hand during placement + if (s.uiPhase === 'placing-from-hand') { + s.pendingHandIndex = cardIndex; + this.updateBusinessHandSelection(cardIndex); + const cardName = s.state.hand?.[cardIndex]?.name ?? 'card'; + s.instructionText.setText(`Click an empty slot to place "${cardName}"`); + } + }, + }); + s.actionContainer = createGameZone(s, 0, 0, s.layout.gameW, s.layout.gameH, 'actionContainer'); + // Action buttons must render above hand cards for visibility. + try { s.actionContainer.setDepth(100); } catch (_) { /* ignore in tests */ } // Ensure depth ordering is applied after container creation. try { s.children?.depthSort?.(); } catch (_) { /* ignore */ } @@ -264,7 +314,7 @@ export class MainStreetRenderer { // Coins - left-aligned in strip const stripWidth = gameW * 0.5; const stripLeft = (gameW - stripWidth) / 2; - const coinText = markHudTransient(s.add.text(stripLeft + 10, hudY, `Coins: ${coins.toFixed(3)}`, { + const coinText = markHudTransient(s.add.text(stripLeft + 10, hudY, `Coins: ${Math.round(coins)}`, { fontSize: '16px', fontStyle: 'bold', color: '#ffcc44', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5)); s.hudContainer.add(coinText); @@ -276,7 +326,7 @@ export class MainStreetRenderer { s.hudContainer.add(repText); // Score - right-aligned in strip (shows x / y where y is the win threshold) - const scoreText = markHudTransient(s.add.text(stripLeft + stripWidth - 10, hudY, `Score: ${score}/${s.state.config.winThreshold}`, { + const scoreText = markHudTransient(s.add.text(stripLeft + stripWidth - 10, hudY, `Score: ${Math.round(score)}/${s.state.config.winThreshold}`, { fontSize: '16px', fontStyle: 'bold', color: '#ff8844', fontFamily: FONT_FAMILY, }).setOrigin(1, 0.5)); s.hudContainer.add(scoreText); @@ -640,10 +690,39 @@ export class MainStreetRenderer { } } + /** + * Toggle the selection highlight on business hand cards. + * Adds or removes a green border from the card at `index`. + */ + public updateBusinessHandSelection(index: number | null): void { + const s = this.scene; + // Remove existing selection borders from all business hand card sprites + for (let i = 0; i < this.handBusinessView.getSprites().length; i++) { + const sprite = this.handBusinessView.getSpriteAt(i); + if (!sprite) continue; + const container = sprite as Phaser.GameObjects.Container; + const existing = container.getByName('hand-selection-border'); + if (existing) existing.destroy(); + } + + // Add selection border to the newly selected card + if (index !== null && index >= 0 && index < this.handBusinessView.getSprites().length) { + const sprite = this.handBusinessView.getSpriteAt(index); + if (!sprite) return; + const container = sprite as Phaser.GameObjects.Container; + const renderW = Math.max(1, Math.round(s.layout.handCardW - 4)); + const renderH = Math.max(1, Math.round(s.layout.handCardH - 4)); + const sel = s.add.rectangle(0, 0, renderW + 4, renderH + 4, 0x88ff88, 0); + sel.setStrokeStyle(3, 0x88ff88); + sel.setName('hand-selection-border'); + container.add(sel); + } + } + public drawEmptySlot(x: number, y: number, index: number): void { const s = this.scene; const { slotW, slotH } = s.layout; - const isSelectable = s.uiPhase === 'placing-business'; + const isSelectable = s.uiPhase === 'placing-business' || s.uiPhase === 'placing-from-hand'; const isHinted = s.hintedSlotIndex === index && !isSelectable; const fillAlpha = isSelectable ? 0.4 : isHinted ? 0.35 : 0.2; const strokeColor = isSelectable ? 0xffdd44 : isHinted ? 0x44ffff : 0x555544; @@ -664,7 +743,7 @@ export class MainStreetRenderer { s.streetContainer.add(idxText); // Click to place - if (isSelectable && s.pendingBusinessCard) { + if (isSelectable && (s.pendingBusinessCard || s.pendingHandIndex !== null)) { bg.setInteractive({ useHandCursor: true }); bg.on('pointerdown', () => s.onSlotClick(index)); bg.on('pointerover', () => bg.setStrokeStyle(3, 0x44ff44)); @@ -1204,74 +1283,14 @@ export class MainStreetRenderer { 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; + // Render business hand cards via HandView const hand = s.state.hand ?? []; + this.handBusinessView.setCards(hand); - // Remove previous hand card display - if (s.handBusinessContainer) { - s.handBusinessContainer.removeAll(true); - } else { - s.handBusinessContainer = s.add.container(0, 0); + // Restore selection highlight when in placing-from-hand phase + if (s.uiPhase === 'placing-from-hand' && s.pendingHandIndex !== null) { + this.updateBusinessHandSelection(s.pendingHandIndex); } - - 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); - - // Render card via shared SVG pipeline for unified appearance - const renderW = Math.max(1, Math.round(handCardW - 4)); - const renderH = Math.max(1, Math.round(handCardH - 4)); - mainStreetRenderCardSvg(s, container, card.id, renderW, renderH); - - // Apply income/reputation overlays (uses centered "Income: +X/turn" format) - this.applyUpgradeOverlays(container, card, renderW, renderH); - - 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', - }); } /** @@ -1359,6 +1378,30 @@ export class MainStreetRenderer { ); s.actionContainer.add(hintBtn); + } else if (s.uiPhase === 'placing-from-hand') { + const rightX = s.layout.gameW - 24; + const by = s.layout.actionY; + + const hand = s.state.hand ?? []; + const handCount = hand.length; + const hint = s.add.text(rightX, by - 4, `Card in hand (${handCount}) — click an empty slot to place`, { + fontSize: '14px', fontStyle: 'bold', color: '#ffdd44', fontFamily: FONT_FAMILY, + }).setOrigin(1, 1); + s.actionContainer.add(hint); + + // Cancel button (right-aligned) — returns to market, card stays in hand + const btnW = s.layout.actionButtonW; + const cancelBtn = createActionButton(s, rightX - btnW, by + 4, btnW, 'Cancel', () => { + s.pendingHandIndex = null; + s.clearMarketSelection(); + s.uiPhase = 'market'; + this.refreshAll(); + s.instructionText.setText( + `Turn ${s.state.turn} / ${s.state.config.maxTurns} -- Buy cards from the market or End Turn`, + ); + }); + s.actionContainer.add(cancelBtn); + } else if (s.uiPhase === 'placing-business') { const rightX = s.layout.gameW - 24; const by = s.layout.actionY; diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 71560325..042e8a9a 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -27,6 +27,7 @@ type UIPhase = | 'idle' // Waiting for DayStart | 'market' // Player can buy or end turn | 'placing-business' // Player selected a business card, picking a slot + | 'placing-from-hand' // Player bought a card to hand, click a slot to place it | 'animating' // Brief pause for feedback | 'game-over'; // Final overlay @@ -58,6 +59,9 @@ export class MainStreetScene extends CardGameScene { public pendingBusinessCard: BusinessCard | null = null; public pendingBusinessSourceIndex: number | null = null; + // Pending hand card for placing from hand (index into state.hand) + public pendingHandIndex: number | null = null; + // Computed responsive layout metrics public layout!: SceneLayout; @@ -67,10 +71,7 @@ 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 @@ -362,6 +363,9 @@ export class MainStreetScene extends CardGameScene { public selectMarketCardById(...args: any[]): any { return (this.msInputManager as any).selectMarketCardById.apply(this.msInputManager, args); } + public onHandBusinessCardClick(...args: any[]): any { + return (this.msTurnController as any).onHandBusinessCardClick.apply(this.msTurnController, args); + } public onBusinessCardClick(...args: any[]): any { return (this.msTurnController as any).onBusinessCardClick.apply(this.msTurnController, args); } diff --git a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts index 176d3d7d..1c0839a1 100644 --- a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts +++ b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts @@ -1,4 +1,4 @@ -import { CARD_TEMPLATE_NAMES, CSV_ROWS } from '../MainStreetCards'; +import { CARD_TEMPLATE_NAMES, getCsvRows } from '../MainStreetCards'; import { rasteriseSvgToTexture, makeTextureKey } from '../../../src/core-engine'; import { generateCardSvgFromCsvRow } from './MainStreetCardSvgGenerator'; @@ -80,7 +80,7 @@ export class MainStreetSvgTextureManager { try { // Generate fresh SVGs from the parsed CSV rows - for (const row of CSV_ROWS) { + for (const row of getCsvRows()) { const templateId = row.id; if (!templateId) continue; diff --git a/example-games/main-street/scenes/MainStreetTurnController.ts b/example-games/main-street/scenes/MainStreetTurnController.ts index 079d175a..9842c5c3 100644 --- a/example-games/main-street/scenes/MainStreetTurnController.ts +++ b/example-games/main-street/scenes/MainStreetTurnController.ts @@ -1,9 +1,8 @@ import { addLog } from '../MainStreetState'; -import { executeDayStart, processEndOfTurn, type TurnResult } from '../MainStreetEngine'; +import { executeDayStart, processEndOfTurn, placeFromHand, type TurnResult } from '../MainStreetEngine'; import { - getEmptySlots, findTargetBusinessSlot, - canPurchaseBusiness, + canAddToHand, canPurchaseUpgrade, canPurchaseEvent, canRefreshDevelopment, @@ -11,7 +10,7 @@ import { canSellBusiness, } from '../MainStreetMarket'; import type { BusinessCard, EventCard, UpgradeCard } from '../MainStreetCards'; -import { buyBusinessCommand, buyUpgradeCommand, buyEventCommand, playEventCommand, refreshDevelopmentCommand, refreshInvestmentsCommand } from '../MainStreetCommands'; +import { buyBusinessCommand, buyBusinessToHandCommand, buyUpgradeCommand, buyEventCommand, playEventCommand, refreshDevelopmentCommand, refreshInvestmentsCommand } from '../MainStreetCommands'; import { recordMainStreetEvent, finalizeMainStreetTranscript } from '../MainStreetTranscript'; import { TranscriptStore, autoSaveTranscript } from '../../../src/core-engine/transcript'; import { getCurrentStep, type TutorialActionType } from '../TutorialFlow'; @@ -45,10 +44,16 @@ export class MainStreetTurnController { */ public onGameEnd: (() => void) | null = null; - public startDayPhase(): void { + /** + * Starts the DayPhase for a new turn. + * + * @param skipMarketRefill When true (e.g., checkpoint resume), the market + * is not refilled and the saved market state is preserved. + */ + public startDayPhase(skipMarketRefill: boolean = false): void { const s = this.scene; - // Execute DayStart (refills market, transitions to MarketPhase) - executeDayStart(s.state); + // Execute DayStart (optionally refills market, transitions to MarketPhase) + executeDayStart(s.state, skipMarketRefill); s.uiPhase = 'market'; // Reset hint state for the new turn @@ -297,111 +302,120 @@ export class MainStreetTurnController { s.selectMarketCardById(card.id); - const emptySlots = getEmptySlots(s.state); - if (emptySlots.length === 0) { - s.instructionText.setText('No empty slots available!'); + // Check hand capacity + const handCheck = canAddToHand(s.state); + if (!handCheck.legal) { + s.instructionText.setText(`Hand full: ${handCheck.reason ?? 'Place or sell a card first.'}`); return; } - // Check if can afford - const firstSlot = emptySlots[0]; - const legality = canPurchaseBusiness(s.state, card.id, firstSlot); - if (!legality.legal) { - s.instructionText.setText(`Cannot buy: ${legality.reason ?? 'unknown'}`); - return; - } + // ── Buy to hand (all purchases now go through hand) ───── + const sourceIndex = s.state.market.development.findIndex((c: any) => c.id === card.id); + const cardName = card.name; - // ── Auto-place mode (buy + place in one step) ────────────── - // If the current tutorial step is an action gate with select-business - // AND has a requiredCardId set, the card should be bought and - // auto-placed without a separate placement step. - // This is used for T8 (buy Bookshop + auto-place). - const isAutoPlaceStep = controller?.isActive && - controller.currentStepIndex >= 0 && - getCurrentStep(controller)?.gate === 'action' && - getCurrentStep(controller)?.requiredAction === 'select-business' && - getCurrentStep(controller)?.requiredCardId !== undefined; - - if (isAutoPlaceStep) { - // Auto-place: buy the card and place it in the first empty slot - const sourceIndex = s.state.market.development.findIndex((c: any) => c.id === card.id); - const pendingCardId = card.id; - const pendingCardName = card.name; - const targetSlot = firstSlot; - - // Ensure stale hover tooltip is cleared - s.tooltipManager?.hide(); - - s.pendingBusinessCard = null; - s.pendingBusinessSourceIndex = null; - s.clearMarketSelection(); - s.uiPhase = 'animating'; - s.instructionText.setText(`Buying and placing "${pendingCardName}"...`); - s.hiddenTransferSourceCardIds.add(pendingCardId); - s.refreshAll(); + // Ensure stale hover tooltip is cleared + s.tooltipManager?.hide(); - const afterTransfer = () => { - try { - const cmd = buyBusinessCommand(s.state, pendingCardId, targetSlot); - s.undoManager.execute(cmd); - try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-business', cardId: pendingCardId, slotIndex: targetSlot }, description: cmd.description }); } catch (_) {} - try { s.gameEvents?.emit('card:placed', { cardId: pendingCardId, slotIndex: targetSlot }); } catch (_) {} - s.instructionText.setText(`Placed "${pendingCardName}" on slot ${targetSlot}`); - } catch (e) { - console.error('[MS] Auto-place BuyBusiness failed', e); - s.instructionText.setText(`Error: ${(e as Error).message}`); - } + s.clearMarketSelection(); + s.uiPhase = 'animating'; + s.instructionText.setText(`Buying "${cardName}"...`); + s.hiddenTransferSourceCardIds.add(card.id); + s.refreshAll(); - s.hiddenTransferSourceCardIds.delete(pendingCardId); - s.uiPhase = 'market'; - s.refreshAll(); + const afterTransfer = () => { + try { + const cmd = buyBusinessToHandCommand(s.state, card.id); + s.undoManager.execute(cmd); + try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-business-to-hand', cardId: card.id }, description: cmd.description }); } catch (_) {} + try { s.gameEvents?.emit('card:placed', { cardId: card.id }); } catch (_) {} + s.instructionText.setText(`"${cardName}" bought to hand!`); - // Tutorial: mark select-business step complete (auto-place step is done) - try { - (s.msLifecycleManager as any).onTutorialActionComplete?.('select-business' as TutorialActionType); - } catch (_) { /* ignore */ } - }; - - if (sourceIndex >= 0) { - void s.animateTransferFromMarket({ - cardId: pendingCardId, - family: 'business', - row: 'development', - slotIndex: sourceIndex, - destination: s.getStreetSlotCenter(targetSlot), - }).then(afterTransfer); - } else { - afterTransfer(); + // Set pending hand index for placement (last card added to hand) + const hand = s.state.hand ?? []; + s.pendingHandIndex = hand.length - 1; + s.uiPhase = 'placing-from-hand'; + s.instructionText.setText(`Click an empty slot to place "${cardName}"`); + } catch (e) { + console.error('[MS] BuyBusinessToHand failed', e); + s.instructionText.setText(`Error: ${(e as Error).message}`); + s.uiPhase = 'market'; } - return; - } - // ── Normal placement mode (select then place) ────────────── - s.pendingBusinessCard = card; - s.pendingBusinessSourceIndex = s.state.market.development.findIndex((c: any) => c.id === card.id); - s.uiPhase = 'placing-business'; - s.instructionText.setText(`Click an empty slot to place "${card.name}"`); - s.refreshStreetGrid(); - s.refreshActionButtons(); + s.hiddenTransferSourceCardIds.delete(card.id); + s.refreshAll(); + s.refreshStreetGrid(); + s.refreshActionButtons(); - // Tutorial: mark select-business step complete if active - try { - (s.msLifecycleManager as any).onTutorialActionComplete?.('select-business' as TutorialActionType); - } catch (_) { /* ignore */ } + // Tutorial: mark select-business step complete if active + try { + (s.msLifecycleManager as any).onTutorialActionComplete?.('select-business' as TutorialActionType); + } catch (_) { /* ignore */ } + }; + + if (sourceIndex >= 0) { + const handIndex = (s.state.hand ?? []).length; + const spacing = s.layout.handCardW + 8; + const destX = s.layout.handX + s.layout.handCardW / 2 + handIndex * spacing; + void s.animateTransferFromMarket({ + cardId: card.id, + family: 'business', + row: 'development', + slotIndex: sourceIndex, + destination: { x: destX, y: s.layout.handY }, + }).then(afterTransfer); + } else { + afterTransfer(); + } } public onSlotClick(slotIndex: number): void { const s = this.scene; - if (s.uiPhase !== 'placing-business') return; + if (s.uiPhase !== 'placing-from-hand' && s.uiPhase !== 'placing-business') return; + + // Tutorial gating: only allow place-business if it's the required action or tutorial is inactive + const check = (s.msLifecycleManager as any).isTutorialActionAllowed?.('place-business' as TutorialActionType); + if (check && !check.allowed) { + s.instructionText.setText(check.reason ?? 'Complete the highlighted step first.'); + return; + } - // Tutorial: if no card is pending (because it was rejected by requiredCardId check), - // show a helpful message directing the player to buy a business card first + // Ensure stale hover tooltip is cleared when a card is placed. + s.tooltipManager?.hide(); + + // ── New flow: place from hand ────────────────────────────── + if (s.pendingHandIndex !== null) { + const handIndex = s.pendingHandIndex; + s.pendingHandIndex = null; + s.uiPhase = 'animating'; + s.refreshAll(); + + try { + placeFromHand(s.state, handIndex, slotIndex); + try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'place', handIndex, slotIndex }, description: `Placed from hand to slot ${slotIndex}` }); } catch (_) {} + try { s.gameEvents?.emit('card:placed', { handIndex, slotIndex }); } catch (_) {} + s.instructionText.setText(`Placed on slot ${slotIndex}`); + } catch (e) { + console.error('[MS] placeFromHand failed', e); + s.instructionText.setText(`Error: ${(e as Error).message}`); + } + + s.uiPhase = 'market'; + s.refreshAll(); + // Tutorial: mark place-business step complete if active + try { + (s.msLifecycleManager as any).onTutorialActionComplete?.('place-business' as TutorialActionType); + } catch (_) { /* ignore */ } + return; + } + + // ── Legacy flow: direct buy to grid (pendingBusinessCard) ── + // This path is kept for backward compatibility but should not be + // triggered in normal gameplay since all purchases go through hand. if (!s.pendingBusinessCard) { - const controller = (s as any).tutorialController as any; - if (controller?.isActive) { + const tutController = (s as any).tutorialController as any; + if (tutController?.isActive) { const msg = 'You must first buy a business card. Click on a business card in the market.'; s.instructionText.setText(msg); - // Clear the error message after 2 seconds s.time.delayedCall(2000, () => { if (s.instructionText?.text === msg) { s.instructionText.setText('Complete the highlighted step.'); @@ -411,16 +425,6 @@ export class MainStreetTurnController { return; } - // Tutorial gating: only allow place-business if it's the required action or tutorial is inactive - const check = (s.msLifecycleManager as any).isTutorialActionAllowed?.('place-business' as TutorialActionType); - if (check && !check.allowed) { - s.instructionText.setText(check.reason ?? 'Complete the highlighted step first.'); - return; - } - - // Ensure stale hover tooltip is cleared when a card is played. - s.tooltipManager?.hide(); - const sourceIndex = s.pendingBusinessSourceIndex; const pendingCardId = s.pendingBusinessCard.id; const pendingCardName = s.pendingBusinessCard.name; @@ -434,13 +438,10 @@ export class MainStreetTurnController { s.refreshAll(); const afterTransfer = (): void => { - console.debug('[MS] onSlotClick: attempting BuyBusiness', { cardId: pendingCardId, slotIndex, coinsBefore: s.state.resourceBank.coins, marketBefore: s.state.market.development.map((c: any)=>c.id) }); try { const cmd = buyBusinessCommand(s.state, pendingCardId, slotIndex); s.undoManager.execute(cmd); - // Record action event try { recordMainStreetEvent({ type: 'action', turn: s.state.turn, action: { type: 'buy-business', cardId: pendingCardId, slotIndex }, description: cmd.description }); } catch (_) {} - // Emit a game event for audio / integrations try { s.gameEvents?.emit('card:placed', { cardId: pendingCardId, slotIndex }); } catch (_) {} s.instructionText.setText(`Placed "${pendingCardName}" on slot ${slotIndex}`); } catch (e) { @@ -451,7 +452,6 @@ export class MainStreetTurnController { s.hiddenTransferSourceCardIds.delete(pendingCardId); s.uiPhase = 'market'; s.refreshAll(); - // Tutorial: mark place-business step complete if active (s.msLifecycleManager as any).onTutorialActionComplete?.('place-business' as TutorialActionType); }; @@ -686,6 +686,65 @@ export class MainStreetTurnController { * * @param slotIndex Street grid slot index of the card to sell. */ + /** + * Handles clicking on a business card in the player's hand during + * the market phase. Sets pendingHandIndex and switches to + * placing-from-hand phase so the card can be placed on the grid. + * + * When already in placing-from-hand phase, clicking a different + * hand card switches the selection. + * + * @param index Index into s.state.hand for the clicked card. + */ + public onHandBusinessCardClick(index: number): void { + const s = this.scene; + const hand = s.state.hand ?? []; + if (index < 0 || index >= hand.length) return; + + // Tutorial gating: only allow if it's the required action or tutorial is inactive + const check = (s.msLifecycleManager as any).isTutorialActionAllowed?.('select-hand-card' as any); + if (check && !check.allowed) { + s.instructionText.setText(check.reason ?? 'Complete the highlighted step first.'); + return; + } + + // When already in placing-from-hand, switching selection is allowed + // (preserving existing customClickFn behavior) + if (s.uiPhase === 'placing-from-hand' && s.pendingHandIndex !== null) { + s.pendingHandIndex = index; + const cardName = hand[index]?.name ?? 'card'; + s.instructionText.setText(`Click an empty slot to place "${cardName}"`); + s.refreshAll(); + // Update the selection highlight via the renderer + if (s.msRenderer && typeof s.msRenderer.updateBusinessHandSelection === 'function') { + s.msRenderer.updateBusinessHandSelection(index); + } + return; + } + + // Only respond during market phase + if (s.uiPhase !== 'market') return; + + // Ensure stale hover tooltip is cleared + s.tooltipManager?.hide(); + + s.pendingHandIndex = index; + s.uiPhase = 'placing-from-hand'; + const cardName = hand[index]?.name ?? 'card'; + s.instructionText.setText(`Click an empty slot to place "${cardName}"`); + s.refreshAll(); + + // Update the selection highlight + if (s.msRenderer && typeof s.msRenderer.updateBusinessHandSelection === 'function') { + s.msRenderer.updateBusinessHandSelection(index); + } + + // Tutorial: mark select-hand-card step complete if active + try { + (s.msLifecycleManager as any).onTutorialActionComplete?.('select-hand-card' as any); + } catch (_) { /* ignore */ } + } + public onSellCard(slotIndex: number): void { const s = this.scene; if (s.uiPhase !== 'market') return; diff --git a/package-lock.json b/package-lock.json index 19272fb4..6705e455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tableau-card-engine", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tableau-card-engine", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "dependencies": { "phaser": "4.0.0-rc.7" diff --git a/package.json b/package.json index f1bc03a5..8bda472e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.8", + "version": "0.1.9", "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", diff --git a/src/ai/CardMemoryTracker.ts b/src/ai/CardMemoryTracker.ts index 642dbc73..198e53cb 100644 --- a/src/ai/CardMemoryTracker.ts +++ b/src/ai/CardMemoryTracker.ts @@ -2,14 +2,15 @@ * CardMemoryTracker — probabilistic recall of observed discard-pile cards. * * The AI uses this tracker to remember cards it has seen on the discard - * pile. When queried via {@link getVisibleRanks}, each rank has a + * pile. When queried via {@link getVisibleRanks}, each key has a * P(correct) = skill/100 chance of being recalled with its exact count; * otherwise a uniformly random count in [0, maxCopies] is returned. * - * Memory scope: The tracker records **all** cards passed to {@link recordCard}. - * In the Golf AI, only discard-pile cards are recorded — face-up grid cards - * are always visible and do not need memory. This means the AI perfectly - * knows its own grid at all times but may misremember historical discards. + * Memory scope: The tracker records **all** cards passed to + * {@link recordCard} or {@link recordKey}. In the Golf AI, only + * discard-pile cards are recorded — face-up grid cards are always visible + * and do not need memory. This means the AI perfectly knows its own grid + * at all times but may misremember historical discards. * * @module ai */ @@ -85,6 +86,16 @@ const DEFAULT_MAX_COPIES = 4; * const ranks = memory.getVisibleRng(rng); * // ranks['Q'] is either 1 (correct) or a random 0-maxCopies (misremembered) * ``` + * + * For custom card models that do not implement the engine {@link Card} + * interface (e.g. Lost Cities' `LostCitiesCard` with `color`/`type`), + * use {@link recordKey} with a game-specific grouping key instead: + * + * ```ts + * const memory = new CardMemoryTracker({ skill: 80, maxCopies: 12 }); + * memory.recordKey('yellow'); // Lost Cities: group by expedition color + * const counts = memory.getVisibleRanks(rng); + * ``` */ export class CardMemoryTracker { private skill: number; @@ -118,40 +129,56 @@ export class CardMemoryTracker { * Record a card that the AI has observed on the discard pile. * * Duplicate ranks increment the count. Suit is ignored — only rank - * matters for memory. + * matters for memory. This is a convenience wrapper around + * {@link recordKey} that derives the grouping key from the card's + * rank. * * @param card - The card to record (must have a `rank` property). */ recordCard(card: Card): void { - const rank = card.rank.toString(); - this.trueCounts[rank] = (this.trueCounts[rank] || 0) + 1; + this.recordKey(card.rank.toString()); + } + + /** + * Record an observed card identified by an arbitrary string key. + * + * This is the generic entry point that supports custom card models + * which do not implement the engine's {@link Card} interface (e.g. + * Lost Cities' `LostCitiesCard` with `color`/`type` fields). The key + * can be any stable string — a rank, an expedition color, or a + * combination — and counts are tracked per unique key. + * + * @param key - The grouping key for the observed card. + */ + recordKey(key: string): void { + this.trueCounts[key] = (this.trueCounts[key] || 0) + 1; } /** - * Return rank counts with probabilistic recall based on the skill rating. + * Return key counts with probabilistic recall based on the skill rating. * - * Each recorded rank has a P(correct) = skill / 100 chance of being - * recalled with its exact true count. When the AI misremembers a rank, - * a uniformly random integer between 0 and {@link maxCopies} is - * returned instead. + * Each recorded key (rank string, expedition color, etc.) has a + * P(correct) = skill / 100 chance of being recalled with its exact true + * count. When the AI misremembers a key, a uniformly random integer + * between 0 and {@link maxCopies} is returned instead. * * The caller must provide an RNG so that test code can use a * deterministic source and the game loop can use Math.random * (or a seeded game RNG when fairness/replayability matters). * * @param rng - A function returning a pseudo-random number in [0, 1). - * @returns A map from rank string to the recalled count (0–maxCopies). + * @returns A map from key string to the recalled count (0–maxCopies). */ getVisibleRanks(rng: () => number): Record { const result: Record = {}; - for (const [rank, trueCount] of Object.entries(this.trueCounts)) { + for (const [key, trueCount] of Object.entries(this.trueCounts)) { const recallCorrectly = rng() < this.skill / 100; if (recallCorrectly) { - result[rank] = trueCount; + result[key] = trueCount; } else { // Misremember: return a random count from 0 to maxCopies - result[rank] = Math.floor(rng() * (this.maxCopies + 1)); + result[key] = Math.floor(rng() * (this.maxCopies + 1)); } } diff --git a/src/ai/README.md b/src/ai/README.md index 8aa9baab..8dbd1592 100644 --- a/src/ai/README.md +++ b/src/ai/README.md @@ -27,6 +27,11 @@ players that need to "remember" previously seen cards with configurable accuracy - **Configurable `maxCopies`:** Sets the upper bound for random counts when the AI misremembers. Default is 4 (standard 52-card deck). Set to 8 for a double deck, or adjust per game requirements. +- **Generic grouping keys:** `recordKey(key: string)` records observations by + any stable string key, supporting custom card models that do not implement + the engine `Card` interface (e.g. Lost Cities' `LostCitiesCard`, grouped by + expedition color). `recordCard(card: Card)` is a convenience wrapper that + derives the key from the card's rank. - **Deterministic testing:** Accepts an external RNG function, allowing tests to use seeded PRNGs for reproducible results. - **Backward compatible:** The constructor accepts either a plain `skill` @@ -44,11 +49,16 @@ const memory = new CardMemoryTracker(); // Double deck: skill=90, maxCopies=8 const memory = new CardMemoryTracker({ skill: 90, maxCopies: 8 }); -// Record observed cards +// Record observed cards (standard Card interface, grouped by rank) memory.recordCard(createCard('Q', 'hearts', true)); // Query with a game RNG const recalled = memory.getVisibleRanks(gameRng); + +// Custom card model (Lost Cities): group by expedition color +const lcMemory = new CardMemoryTracker({ skill: 80, maxCopies: 12 }); +lcMemory.recordKey('yellow'); +const colorCounts = lcMemory.getVisibleRanks(gameRng); ``` ### `AiUtils` diff --git a/src/ui/CardTextureHelpers.ts b/src/ui/CardTextureHelpers.ts index 5ff855f5..4af61250 100644 --- a/src/ui/CardTextureHelpers.ts +++ b/src/ui/CardTextureHelpers.ts @@ -75,8 +75,11 @@ export function getCardTexture(card: Card): string { * iterating over ranks and suits. * * Loads SVGs for the player's currently selected card design (read from - * localStorage via {@link getCardDesign}) so that the initial game start - * renders the correct design. + * `globalThis.localStorage` via {@link getCardDesign}) so that the initial + * game start renders the correct design. Because {@link getCardDesign} falls + * back to `globalThis.localStorage` when called without arguments, the + * player's design preference persists across page reloads and server + * restarts. * * @param scene The Phaser scene whose loader should be used. * @param width Card sprite width in pixels (defaults to `CARD_W`). diff --git a/src/ui/HandView.ts b/src/ui/HandView.ts index d19951b2..d1876a68 100644 --- a/src/ui/HandView.ts +++ b/src/ui/HandView.ts @@ -13,7 +13,7 @@ import type { Card } from '../card-system/Card'; import { getCardTexture } from './CardTextureHelpers'; import { layoutCardPositions } from './layoutCardPositions'; -import { CARD_W } from './constants'; +import { CARD_W, CARD_H } from './constants'; import { dealCard } from './dealCard'; import { GameEventEmitter } from '../core-engine'; @@ -129,6 +129,16 @@ export interface HandViewOptions { */ maxRotationDegrees?: number; + /** + * Distance (px) the selected card is raised from its resting position. + * In horizontal layout the raise follows the card's rotation — + * perpendicular to the card face (`dx = d·sin(θ)`, `dy = −d·cos(θ)`), + * straight up at 0° rotation. In vertical cascade layout the selected + * card shifts right (`dx = +d`, `dy = 0`). + * @default 0 (no raise) + */ + selectionLift?: number; + /** * Layout direction for the hand. * - `'horizontal'`: cards laid out in a row (left to right). @@ -410,6 +420,12 @@ export class HandView { /** Maximum rotation (degrees) applied proportionally based on card offset from centre. */ private maxRotationDegrees: number = 0; + /** + * Distance (px) the selected card is raised from its resting position. + * @default 0 + */ + private selectionLift: number = 0; + /** Layout direction for the hand — horizontal row or vertical cascade. */ private layoutDirection: 'horizontal' | 'vertical'; @@ -443,6 +459,20 @@ export class HandView { private _dimTint: number = 0x888888; private static readonly DRAG_THRESHOLD: number = 5; + /** + * Per-card tint overlay rectangles (Canvas-compatible alternative to setTint). + * Same length as {@link sprites}. Each entry is either a Rectangle or null + * (no overlay). Managed by {@link _setCardTint}. + */ + private _tintOverlays: (Phaser.GameObjects.Rectangle | null)[] = []; + + /** + * Base (un-raised) layout position for each sprite, parallel to + * {@link sprites}. The selection-raise offset is applied on top of + * these positions when a card is selected. + */ + private _basePositions: { x: number; y: number }[] = []; + // Events — lightweight listener map private listeners: Map> = new Map(); @@ -461,6 +491,7 @@ export class HandView { this.clickEnabled = opts.clickEnabled ?? true; this._reducedMotion = opts.reducedMotion ?? false; this.maxRotationDegrees = opts.maxRotationDegrees ?? 25; + this.selectionLift = opts.selectionLift ?? 0; this.layoutDirection = opts.layoutDirection ?? 'horizontal'; this._centerX = opts.centerX; this._customTextureFn = opts.cardTextureFn; @@ -746,6 +777,9 @@ export class HandView { // 4. Compute new target positions. const newPositions = this.computeCardPositions(); + // Track the new base positions so a later selection raise is correct. + this._basePositions = newPositions.map((p) => ({ x: p.x, y: p.y })); + // Precompute rotation helpers (mirrors applyLayout logic). let arcCenterX = 0; let halfSpan = 1; @@ -1028,6 +1062,27 @@ export class HandView { return this.maxRotationDegrees; } + /** + * Set the distance (px) the selected card is raised from its resting + * position. In horizontal layout the raise follows the card's rotation + * (perpendicular to the card face); in vertical cascade layout the + * selected card shifts right by this amount. Pass 0 to disable. + * + * Changes apply instantly (no animation) so a live slider can tune the + * distance while a card is selected. + */ + setSelectionLift(distance: number): void { + const next = Number.isFinite(distance) ? Math.max(0, distance) : 0; + if (next === this.selectionLift) return; + this.selectionLift = next; + this.applySelectionRaise(false); + } + + /** Current selection raise distance (px). */ + getSelectionLift(): number { + return this.selectionLift; + } + /** * Register an event callback. * @@ -1081,6 +1136,23 @@ export class HandView { return this.sprites[index]; } + /** + * Return the base (un-raised) layout position for the card at the given + * index, or undefined when the index is out of range. + * + * The base position is the card's resting spot in the hand — the + * selection-raise offset (if any) is NOT included. Callers that need to + * restore a card to its exact hand position (e.g. cancelling a move) + * must use this instead of reading the sprite's current x/y, which may + * include the selection raise. + * + * @param index - The card index. + * @returns The base position, or `undefined` if out of bounds. + */ + getBasePosition(index: number): { x: number; y: number } | undefined { + return this._basePositions[index]; + } + /** * Return all card display objects. * @@ -1132,6 +1204,7 @@ export class HandView { if (this.cards.length === 0) return; const positions = this.computeCardPositions(); + this._basePositions = positions.map((p) => ({ x: p.x, y: p.y })); // Precompute rotation helpers for horizontal mode (centre and half-span) // so rotation is proportional to horizontal offset from the hand centre. @@ -1169,6 +1242,9 @@ export class HandView { this.addCardLabel(card, i, positions[i], sprite); } } + + // Apply the selection-raise offset instantly to the fresh sprites. + this.applySelectionRaise(false); } /** @@ -1193,6 +1269,11 @@ export class HandView { // Position the returned object at the computed layout position (cardObj as any).x = pos.x; (cardObj as any).y = pos.y; + // Per-index depth (see below) so the highlight cannot cover the + // card to the right / below. + if (typeof (cardObj as any).setDepth === 'function') { + (cardObj as any).setDepth(index); + } return cardObj; } @@ -1251,6 +1332,14 @@ export class HandView { (sprite as any).rotation = (rotDeg * Math.PI) / 180; } + // Assign per-index depth so the Canvas-compatible tint overlay + // (sprite.depth + 0.01) renders above this card but below the card + // to the right / below (which gets a higher index depth). Without + // this, the selection highlight bleeds over neighbouring cards. + if (typeof (sprite as any).setDepth === 'function') { + (sprite as any).setDepth(index); + } + if (this.clickEnabled || this.selectionEnabled) { sprite.setInteractive({ useHandCursor: true }); } @@ -1303,15 +1392,15 @@ export class HandView { }); } - // Hover visual feedback + // Hover visual feedback (uses Canvas-compatible tint overlay) sprite.on('pointerover', () => { - sprite.setTint(0x66ff66); + this._setCardTint(idx, 0x66ff66); }); sprite.on('pointerout', () => { const isSelected = this.layoutDirection === 'vertical' && this.selectedIndex !== null ? idx <= this.selectedIndex : idx === this.selectedIndex; - sprite.setTint(isSelected ? 0x88ff88 : 0xffffff); + this._setCardTint(idx, isSelected ? 0x88ff88 : null); }); } @@ -1322,7 +1411,7 @@ export class HandView { card: Card, index: number, pos: { x: number; y: number }, - sprite: Phaser.GameObjects.GameObject, + _sprite: Phaser.GameObjects.GameObject, ): void { const isSelected = this.layoutDirection === 'vertical' && this.selectedIndex !== null ? index <= this.selectedIndex @@ -1340,10 +1429,16 @@ export class HandView { color: isSelected ? '#88ff88' : '#aaaaaa', fontFamily: 'monospace', }).setOrigin(0.5); + // Keep the label just above its own card (depth index) but below the + // tint overlay (index + 0.01) and below the next card (index + 1), so + // the per-index card depth does not push labels behind their cards. + if (typeof (label as any).setDepth === 'function') { + (label as any).setDepth(index + 0.005); + } this.labels.push(label); // Apply selection tint (default Image sprite path only) - (sprite as any).setTint(isSelected ? 0x88ff88 : 0xffffff); + this._setCardTint(index, isSelected ? 0x88ff88 : null); } /** Compute current hand card center positions (x/y). */ @@ -1397,6 +1492,7 @@ export class HandView { if (this.sprites.length === 0 || this.cards.length === 0) return; const positions = this.computeCardPositions(); + this._basePositions = positions.map((p) => ({ x: p.x, y: p.y })); // Precompute rotation helpers for horizontal mode const firstX = positions[0].x; @@ -1431,6 +1527,12 @@ export class HandView { } } } + + // Update tint overlay positions to stay aligned with repositioned sprites + this._updateTintOverlayPositions(); + + // Re-apply the selection raise on top of the new base positions. + this.applySelectionRaise(false); } /** Clear all sprites and labels from the scene. */ @@ -1443,12 +1545,109 @@ export class HandView { } this.sprites = []; this.labels = []; + + // Destroy canvas-compatible tint overlays + for (const o of this._tintOverlays) { + if (o) { try { o.destroy(); } catch (_) { /* ignore */ } } + } + this._tintOverlays = []; + this._basePositions = []; + } + + // ── Canvas-compatible tint overlay helpers ─────────────── + + /** + * Apply or clear a Canvas-compatible tint overlay on a card sprite. + * + * In Phaser 4's Canvas renderer, `setTint()` on Image/Sprite objects does + * not render visible color changes. This method uses a colored + * semi-transparent Rectangle overlay instead, which works identically in + * both Canvas and WebGL. + * + * @param index - Card index in the hand. + * @param color - Tint color (hex, e.g. 0x88ff88 for green) or null to clear. + */ + private _setCardTint(index: number, color: number | null): void { + const sprite = this.sprites[index]; + if (!sprite || !(sprite as any).active) return; + + // Also call setTint for WebGL renderer (where it works natively) + try { + (sprite as any).setTint(color ?? 0xffffff); + } catch (_) { /* ignore */ } + + const s = sprite as any; + const existing = this._tintOverlays[index]; + if (existing && existing.active) { + if (color === null) { + existing.destroy(); + this._tintOverlays[index] = null; + return; + } + // Repaint IN PLACE — never destroy/recreate an active overlay. An + // overlay recreated mid-raise-tween would be left at its creation + // position while the card continues to rise (the raise tween only + // moves the overlay object it was created with), leaving the + // highlight detached from the raised card. Repainting keeps the + // same overlay glued to the card through the raise. + if ((existing as any).fillColor !== color) { + (existing as any).setFillStyle(color, (existing as any).fillAlpha ?? 0.35); + } + // Keep the overlay aligned with the sprite at this instant (the + // raise tween / drag updates overwrite it every frame when active). + existing.setPosition(s.x ?? 0, s.y ?? 0); + existing.setRotation(s.rotation ?? 0); + existing.setDepth((s.depth ?? 0) + 0.01); + return; + } + + if (color === null) return; + + // Create a new overlay rectangle at the sprite's current position + const overlay = this.scene.add.rectangle( + s.x ?? 0, + s.y ?? 0, + this.cardWidth, + CARD_H, + color, + ) + .setAlpha(0.35) + .setOrigin(s.originX ?? 0.5, s.originY ?? 0.5) + .setRotation(s.rotation ?? 0) + .setDepth((s.depth ?? 0) + 0.01); + + this._tintOverlays[index] = overlay; + } + + /** + * Update all tint overlay positions to match the current sprite positions. + * Called after sprite positions change (e.g. during drag). + */ + private _updateTintOverlayPositions(): void { + for (let i = 0; i < this.sprites.length; i++) { + const overlay = this._tintOverlays[i]; + if (!overlay) continue; + const sprite = this.sprites[i]; + if (!sprite || !(sprite as any).active) { + overlay.destroy(); + this._tintOverlays[i] = null; + continue; + } + const s = sprite as any; + overlay.setPosition(s.x ?? 0, s.y ?? 0); + overlay.setRotation(s.rotation ?? 0); + overlay.setDepth((s.depth ?? 0) + 0.01); + } } /** Update visual selection tint on all sprites. */ private updateSelectionTints(): void { - // Custom-rendered cards manage their own selection visuals - if (this._renderCardFn) return; + // Custom-rendered cards manage their own selection visuals but + // HandView still owns raised positioning. + if (this._renderCardFn) { + this.applySelectionRaise(true); + return; + } const isVertical = this.layoutDirection === 'vertical'; for (let i = 0; i < this.sprites.length; i++) { const sprite = this.sprites[i]; @@ -1457,7 +1656,7 @@ export class HandView { const isSelected = isVertical && this.selectedIndex !== null ? i <= this.selectedIndex : i === this.selectedIndex; - (sprite as any).setTint(isSelected ? 0x88ff88 : 0xffffff); + this._setCardTint(i, isSelected ? 0x88ff88 : null); // Update label colour if (i < this.labels.length) { @@ -1467,6 +1666,99 @@ export class HandView { } } } + // Apply the raise AFTER the tints so a newly-created highlight overlay + // is included in the raise tween and raises together with the card. + this.applySelectionRaise(true); + } + + // ── Selection raise (selected card lift) ────────────────── + + /** Whether the card at `index` is part of the current selection. */ + private _isCardSelected(index: number): boolean { + if (this.selectedIndex === null) return false; + if (this.layoutDirection === 'vertical') return index <= this.selectedIndex; + return index === this.selectedIndex; + } + + /** + * Compute the selection-raise offset for a card index. + * + * Horizontal: the card raises perpendicular to its rotated face — + * `dx = d·sin(θ)`, `dy = −d·cos(θ)` where θ is the sprite's rotation in + * radians (straight up when θ = 0). + * Vertical: the selected card shifts right — `dx = +d`, `dy = 0`. + */ + private _computeRaiseOffset(index: number): { x: number; y: number } { + const d = this.selectionLift; + if (d <= 0) return { x: 0, y: 0 }; + if (this.layoutDirection === 'vertical') { + return { x: d, y: 0 }; + } + const sprite = this.sprites[index]; + const rotation = sprite ? ((sprite as any).rotation ?? 0) : 0; + return { x: d * Math.sin(rotation), y: -d * Math.cos(rotation) }; + } + + /** + * Apply the selection-raise offset to a single card sprite (plus its + * tint overlay and label so they stay attached). The raise animates + * with a short tween unless reduced-motion is active or `animate` is + * false (used by layout updates and live slider changes). + */ + private _applyRaiseForIndex(index: number, animate: boolean): void { + const sprite = this.sprites[index]; + if (!sprite || !(sprite as any).active) return; + const base = this._basePositions[index]; + if (!base) return; + + const offset = this._isCardSelected(index) ? this._computeRaiseOffset(index) : { x: 0, y: 0 }; + const targetX = base.x + offset.x; + const targetY = base.y + offset.y; + + // No-op when the sprite is already at the target position. + if ( + Math.abs(((sprite as any).x ?? 0) - targetX) < 0.5 && + Math.abs(((sprite as any).y ?? 0) - targetY) < 0.5 + ) { + return; + } + + const targets: any[] = [sprite]; + if (this.labels[index]) targets.push(this.labels[index]); + const overlay = this._tintOverlays[index]; + if (overlay && overlay.active) targets.push(overlay); + + // Stop any in-flight raise tween so it cannot fight the new target. + this.scene.tweens.killTweensOf(targets); + + if (this._reducedMotion || !animate) { + for (const t of targets) { + t.x = targetX; + t.y = targetY; + } + } else { + this.scene.tweens.add({ + targets, + x: targetX, + y: targetY, + duration: 180, + ease: 'Quad.easeOut', + }); + } + } + + /** + * Apply the selection-raise offset to every card sprite. + * + * @param animate - When true the raise tweens on selection changes + * (unless reduced-motion); when false it applies + * instantly (layout updates, live slider changes). + */ + private applySelectionRaise(animate: boolean): void { + if (this.sprites.length === 0) return; + for (let i = 0; i < this.sprites.length; i++) { + this._applyRaiseForIndex(i, animate); + } } // ── Drag helpers ───────────────────────────────────────── @@ -1513,6 +1805,14 @@ export class HandView { if (!this._dragSourceRange) return; const { from, to } = this._dragSourceRange; + // Stop any in-flight selection-raise tween on the dragged sprites so + // it cannot fight the drag movement (the raised position is captured + // in _originalPositions by _storeOriginalPositions). + const dragSprites = this.sprites.slice(from, to + 1); + if (dragSprites.length > 0) { + this.scene.tweens.killTweensOf(dragSprites); + } + // Lift selected cards (Y offset) for (let i = from; i <= to; i++) { const sprite = this.sprites[i]; @@ -1521,12 +1821,15 @@ export class HandView { } } + // Update overlay positions after lift + this._updateTintOverlayPositions(); + // Dim unselected cards above drag handle (only meaningful in vertical mode) if (this.layoutDirection === 'vertical') { for (let i = 0; i < from; i++) { const sprite = this.sprites[i]; if (sprite && sprite.active) { - (sprite as any).setTint(this._dimTint); + this._setCardTint(i, this._dimTint); } } } @@ -1553,6 +1856,9 @@ export class HandView { (sprite as any).y = this._originalPositions[i].y + this._dragLiftOffset + dy; } } + + // Update overlay positions to stay aligned with dragged sprites + this._updateTintOverlayPositions(); } /** Animate dragged cards back to original positions (snap-back on rejection). */ @@ -1654,6 +1960,9 @@ export class HandView { this._animateDragAccept(); } else { this._animateSnapBack(); + // Restore selection visuals (tint + raise) only when the card + // returns to the hand — an accepted drop keeps its drop position. + this._resetDragVisuals(); } this.emit('dragend', { @@ -1661,8 +1970,6 @@ export class HandView { targetPileIndex, accepted, }); - - this._resetDragVisuals(); } this._dragSourceRange = null; diff --git a/src/ui/SettingsStore.ts b/src/ui/SettingsStore.ts index 79dd6d35..9fa910f2 100644 --- a/src/ui/SettingsStore.ts +++ b/src/ui/SettingsStore.ts @@ -64,8 +64,12 @@ export function getCardDesignDisplayName(designKey: string): string { * Read the selected card design from storage. * Returns the default design key when nothing is stored or the stored * key is not in the available designs registry. + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access (returns the default design). */ -export function getCardDesign(storage: StorageLike | null = null): string { +export function getCardDesign(storage?: StorageLike | null): string { const backend = resolveStorage(storage); if (!backend) return CARD_DESIGN_DEFAULT; @@ -82,8 +86,12 @@ export function getCardDesign(storage: StorageLike | null = null): string { /** * Persist the selected card design key to storage. + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access (no-op). */ -export function setCardDesign(designKey: string, storage: StorageLike | null = null): void { +export function setCardDesign(designKey: string, storage?: StorageLike | null): void { const backend = resolveStorage(storage); if (!backend) return; @@ -112,8 +120,12 @@ function resolveStorage(storage?: StorageLike | null): StorageLike | null { /** * Read selected difficulty from storage. Returns null when not set or invalid. + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access. */ -export function getSelectedDifficulty(storage: StorageLike | null = null, allowedNames?: readonly string[]): string | null { +export function getSelectedDifficulty(storage?: StorageLike | null, allowedNames?: readonly string[]): string | null { const backend = resolveStorage(storage); if (!backend) return null; @@ -127,7 +139,7 @@ export function getSelectedDifficulty(storage: StorageLike | null = null, allowe } } -export function setSelectedDifficulty(name: string, storage: StorageLike | null = null): void { +export function setSelectedDifficulty(name: string, storage?: StorageLike | null): void { const backend = resolveStorage(storage); if (!backend) return; @@ -141,8 +153,12 @@ export function setSelectedDifficulty(name: string, storage: StorageLike | null /** * Read reduced motion preference from storage. * Returns false when not set or storage is unavailable. + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access. */ -export function getReducedMotion(storage: StorageLike | null = null): boolean { +export function getReducedMotion(storage?: StorageLike | null): boolean { const backend = resolveStorage(storage); if (!backend) return false; @@ -156,7 +172,7 @@ export function getReducedMotion(storage: StorageLike | null = null): boolean { /** * Persist reduced motion preference to storage. */ -export function setReducedMotion(enabled: boolean, storage: StorageLike | null = null): void { +export function setReducedMotion(enabled: boolean, storage?: StorageLike | null): void { const backend = resolveStorage(storage); if (!backend) return; @@ -172,8 +188,12 @@ export function setReducedMotion(enabled: boolean, storage: StorageLike | null = /** * Read tooltip preference from storage. Returns true when not set or * storage is unavailable (tooltips shown by default). + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access. */ -export function getTooltips(storage: StorageLike | null = null): boolean { +export function getTooltips(storage?: StorageLike | null): boolean { const backend = resolveStorage(storage); if (!backend) return true; @@ -189,7 +209,7 @@ export function getTooltips(storage: StorageLike | null = null): boolean { /** * Persist tooltip preference to storage. */ -export function setTooltips(enabled: boolean, storage: StorageLike | null = null): void { +export function setTooltips(enabled: boolean, storage?: StorageLike | null): void { const backend = resolveStorage(storage); if (!backend) return; @@ -205,8 +225,12 @@ export function setTooltips(enabled: boolean, storage: StorageLike | null = null /** * Read the configured End Turn keybind from storage. Returns the key name * (e.g. 'Enter'). If not set, returns the default 'Enter'. + * + * @param storage Optional storage backend. When omitted (undefined), falls + * back to `globalThis.localStorage`. Pass `null` to explicitly disable + * storage access. */ -export function getEndTurnKeybind(storage: StorageLike | null = null): string { +export function getEndTurnKeybind(storage?: StorageLike | null): string { const backend = resolveStorage(storage); if (!backend) return 'Enter'; @@ -222,7 +246,7 @@ export function getEndTurnKeybind(storage: StorageLike | null = null): string { /** * Persist the End Turn keybind name to storage. */ -export function setEndTurnKeybind(keyName: string, storage: StorageLike | null = null): void { +export function setEndTurnKeybind(keyName: string, storage?: StorageLike | null): void { const backend = resolveStorage(storage); if (!backend) return; diff --git a/src/ui/shakeIllegalMove.ts b/src/ui/shakeIllegalMove.ts index 624d867b..37726811 100644 --- a/src/ui/shakeIllegalMove.ts +++ b/src/ui/shakeIllegalMove.ts @@ -115,8 +115,24 @@ export function shakeIllegalMove( const originalX = target.x; + // Apply tint. In Phaser 4 Canvas renderer setTint on Image/Sprite + // does not render visibly, so we add a Rectangle overlay as well. target.setTint(tint); + // Canvas-compatible tint overlay (also works under WebGL) + const tgt = target as any; + const overlayW = tgt.displayWidth ?? tgt.width ?? 96; + const overlayH = tgt.displayHeight ?? tgt.height ?? 130; + const tintOverlay = scene.add.rectangle( + target.x, target.y, + overlayW, overlayH, + tint, + ) + .setAlpha(0.4) + .setOrigin(tgt.originX ?? 0.5, tgt.originY ?? 0.5) + .setRotation(tgt.rotation ?? 0) + .setDepth((tgt.depth ?? 0) + 0.1); + return scene.tweens.add({ targets: target, x: originalX - shakeDistance, @@ -126,6 +142,7 @@ export function shakeIllegalMove( ease, onComplete: () => { target.clearTint(); + tintOverlay.destroy(); target.setX(originalX); onComplete?.(); }, diff --git a/tests/ai/CardMemoryTracker.test.ts b/tests/ai/CardMemoryTracker.test.ts index 7e98d6cd..c6ec9e3b 100644 --- a/tests/ai/CardMemoryTracker.test.ts +++ b/tests/ai/CardMemoryTracker.test.ts @@ -86,6 +86,16 @@ describe('CardMemoryTracker', () => { expect(ranks['Q']).toBe(1); }); + it('delegates to recordKey with the card rank as the key', () => { + const tracker = new CardMemoryTracker(100); + const card = createCard('7', 'diamonds', true); + tracker.recordCard(card); + + const ranks = tracker.getVisibleRanks(createTestRng()); + // The rank string '7' is the grouping key + expect(ranks['7']).toBe(1); + }); + it('records multiple cards of the same rank', () => { const tracker = new CardMemoryTracker(100); tracker.recordCard(createCard('Q', 'hearts', true)); @@ -129,6 +139,66 @@ describe('CardMemoryTracker', () => { }); }); + describe('recordKey', () => { + it('records a card by arbitrary string key', () => { + const tracker = new CardMemoryTracker(100); + tracker.recordKey('yellow'); + + const counts = tracker.getVisibleRanks(createTestRng()); + expect(counts['yellow']).toBe(1); + }); + + it('increments counts for duplicate keys', () => { + const tracker = new CardMemoryTracker(100); + tracker.recordKey('yellow'); + tracker.recordKey('yellow'); + tracker.recordKey('red'); + + const counts = tracker.getVisibleRanks(createTestRng()); + expect(counts['yellow']).toBe(2); + expect(counts['red']).toBe(1); + }); + + it('supports non-Card grouping keys (e.g. custom card models)', () => { + const tracker = new CardMemoryTracker(100); + // Simulate a Lost Cities custom card model: group by expedition color + tracker.recordKey('blue'); + tracker.recordKey('blue'); + tracker.recordKey('green'); + + const counts = tracker.getVisibleRanks(createTestRng()); + expect(counts['blue']).toBe(2); + expect(counts['green']).toBe(1); + }); + + it('is equivalent to recordCard for rank-based grouping', () => { + const byKey = new CardMemoryTracker(100); + const byCard = new CardMemoryTracker(100); + + byKey.recordKey('Q'); + byKey.recordKey('K'); + byCard.recordCard(createCard('Q', 'hearts', true)); + byCard.recordCard(createCard('K', 'clubs', true)); + + const keyCounts = byKey.getVisibleRanks(createTestRng()); + const cardCounts = byCard.getVisibleRanks(createTestRng()); + expect(keyCounts).toEqual(cardCounts); + }); + + it('respects maxCopies when misremembering', () => { + const tracker = new CardMemoryTracker({ skill: 0, maxCopies: 12 }); + tracker.recordKey('yellow'); + + const rng = createTestRng(42); + for (let t = 0; t < 1000; t++) { + const counts = tracker.getVisibleRanks(rng); + const count = counts['yellow'] ?? 0; + expect(count).toBeGreaterThanOrEqual(0); + expect(count).toBeLessThanOrEqual(12); + } + }); + }); + describe('skill = 100 (perfect recall)', () => { it('recalls exact counts for all recorded ranks', () => { const tracker = new CardMemoryTracker(100); diff --git a/tests/core-engine/phaserVersionPin.test.ts b/tests/core-engine/phaserVersionPin.test.ts deleted file mode 100644 index 5c4d7e1b..00000000 --- a/tests/core-engine/phaserVersionPin.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -describe('Phaser dependency pin', () => { - it('pins Phaser to 4.0.0-rc.7 in package.json', () => { - const packageJsonPath = resolve(process.cwd(), 'package.json'); - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { - dependencies?: Record; - }; - - expect(packageJson.dependencies?.phaser).toBe('4.0.0-rc.7'); - }); -}); diff --git a/tests/debug/DebugToolsRegistry.test.ts b/tests/debug/DebugToolsRegistry.test.ts deleted file mode 100644 index 3239a552..00000000 --- a/tests/debug/DebugToolsRegistry.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Tests for DebugToolsRegistry – dev mode detection and debug tool entry type. - * - * @module tests/debug/DebugToolsRegistry.test - */ -import { describe, it, expect, vi } from 'vitest'; -import { isDevMode, DebugToolsEntry } from '../../src/ui/debug/DebugToolsRegistry'; - -describe('isDevMode()', () => { - it('should be a function', () => { - expect(typeof isDevMode).toBe('function'); - }); - - it('should return a boolean', () => { - const result = isDevMode(); - expect(typeof result).toBe('boolean'); - }); -}); - -describe('DebugToolsEntry type', () => { - it('should accept a valid debug tool entry object', () => { - const tool: DebugToolsEntry = { - label: 'Test Tool', - description: 'A test debug tool', - activate: vi.fn(), - }; - expect(tool.label).toBe('Test Tool'); - expect(tool.description).toBe('A test debug tool'); - expect(typeof tool.activate).toBe('function'); - }); - - it('should allow multiple tool entries in an array', () => { - const tools: DebugToolsEntry[] = [ - { - label: 'Tool A', - description: 'First tool', - activate: vi.fn(), - }, - { - label: 'Tool B', - description: 'Second tool', - activate: vi.fn(), - }, - ]; - expect(tools).toHaveLength(2); - expect(tools[0].label).toBe('Tool A'); - expect(tools[1].label).toBe('Tool B'); - }); - - it('should accept scene parameter in activate callback', () => { - const mockScene = { key: 'TestScene' } as any; - const tool: DebugToolsEntry = { - label: 'Scene Tool', - description: 'Tool that needs scene access', - activate: (scene: any) => { - scene.key = 'modified'; - }, - }; - tool.activate(mockScene); - expect(mockScene.key).toBe('modified'); - }); -}); diff --git a/tests/e2e/main-street-tutorial-e2e-part3.browser.test.ts b/tests/e2e/main-street-tutorial-e2e-part3.browser.test.ts index 79cf457a..b7f60319 100644 --- a/tests/e2e/main-street-tutorial-e2e-part3.browser.test.ts +++ b/tests/e2e/main-street-tutorial-e2e-part3.browser.test.ts @@ -68,9 +68,12 @@ describe('Main Street Tutorial E2E — Coin Budget', () => { clickRequiredEventCard(scene); await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(7); - await clickOverlayButtonByText('Next >'); + clickRequiredBusinessCard(scene); + await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(8); - await clickOverlayButtonByText('Next >'); + clickStreetSlot(scene, 1); + await new Promise((r) => setTimeout(r, 500)); + await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(9); await clickOverlayButtonByText('Next >'); expect(getStepIndex(scene)).toBe(10); @@ -78,6 +81,8 @@ describe('Main Street Tutorial E2E — Coin Budget', () => { expect(getStepIndex(scene)).toBe(11); await clickOverlayButtonByText('Next >'); expect(getStepIndex(scene)).toBe(12); + await clickOverlayButtonByText('Next >'); + expect(getStepIndex(scene)).toBe(13); await clickOverlayButtonByText('Start Full Game'); await new Promise((r) => setTimeout(r, 500)); const finalOverlay = getOverlay(); diff --git a/tests/e2e/main-street-tutorial-e2e-part5.browser.test.ts b/tests/e2e/main-street-tutorial-e2e-part5.browser.test.ts index 284ca99e..6df56c48 100644 --- a/tests/e2e/main-street-tutorial-e2e-part5.browser.test.ts +++ b/tests/e2e/main-street-tutorial-e2e-part5.browser.test.ts @@ -49,7 +49,7 @@ describe('Main Street Tutorial E2E — T8-T9', () => { game = null; }); - it('T8-T9: Upgrade concept and hand steps progress', async () => { + it('T8-T9: Buy Bookshop to hand and place from hand', async () => { await clickOverlayButtonByText('Next >'); await clickOverlayButtonByText('Next >'); const scene = game!.scene.getScene('MainStreetScene') as Phaser.Scene; clickRequiredBusinessCard(scene); @@ -63,8 +63,13 @@ describe('Main Street Tutorial E2E — T8-T9', () => { clickRequiredEventCard(scene); await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(7); - await clickOverlayButtonByText('Next >'); + clickRequiredBusinessCard(scene); + await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(8); + clickStreetSlot(scene, 1); + await new Promise((r) => setTimeout(r, 500)); + await waitForOverlayVisible(5_000); + expect(getStepIndex(scene)).toBe(9); await saveScreenshot('t8-t9'); }, 30_000); }); diff --git a/tests/e2e/main-street-tutorial-e2e-part6.browser.test.ts b/tests/e2e/main-street-tutorial-e2e-part6.browser.test.ts index de1142f1..f6fac545 100644 --- a/tests/e2e/main-street-tutorial-e2e-part6.browser.test.ts +++ b/tests/e2e/main-street-tutorial-e2e-part6.browser.test.ts @@ -48,7 +48,7 @@ describe('Main Street Tutorial E2E — T10-T13', () => { game = null; }); - it('T10-T13: Challenges, Scoring, and Completion steps advance', async () => { + it('T10-T14: Challenges, Scoring, and Completion steps advance', async () => { await clickOverlayButtonByText('Next >'); await clickOverlayButtonByText('Next >'); const scene = game!.scene.getScene('MainStreetScene') as Phaser.Scene; clickRequiredBusinessCard(scene); @@ -62,9 +62,12 @@ describe('Main Street Tutorial E2E — T10-T13', () => { clickRequiredEventCard(scene); await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(7); - await clickOverlayButtonByText('Next >'); + clickRequiredBusinessCard(scene); + await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(8); - await clickOverlayButtonByText('Next >'); + clickStreetSlot(scene, 1); + await new Promise((r) => setTimeout(r, 500)); + await waitForOverlayVisible(5_000); expect(getStepIndex(scene)).toBe(9); await clickOverlayButtonByText('Next >'); expect(getStepIndex(scene)).toBe(10); @@ -75,6 +78,9 @@ describe('Main Street Tutorial E2E — T10-T13', () => { await clickOverlayButtonByText('Next >'); expect(getStepIndex(scene)).toBe(12); await saveScreenshot('t12-t13'); + await clickOverlayButtonByText('Next >'); + expect(getStepIndex(scene)).toBe(13); + await saveScreenshot('t13-t14'); await clickOverlayButtonByText('Start Full Game'); await new Promise((r) => setTimeout(r, 500)); const finalOverlay = getOverlay(); diff --git a/tests/feudalism/FeudalismTurnController.patronAnimationTiming.test.ts b/tests/feudalism/FeudalismTurnController.patronAnimationTiming.test.ts deleted file mode 100644 index 00d4ba85..00000000 --- a/tests/feudalism/FeudalismTurnController.patronAnimationTiming.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Tests for FeudalismTurnController patron animation timing fix. - * - * Verifies that the celebration sound and toast are deferred to the - * animation start in both executeAction() and executeAiTurn() when a - * patron visit occurs. - * - * Related work item: CG-0MRDL6LSS001LPCG - * - * @module tests/feudalism/FeudalismTurnController.patronAnimationTiming - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -const SOURCE_PATH = 'example-games/feudalism/scenes/FeudalismTurnController.ts'; - -describe('FeudalismTurnController patron animation timing', () => { - // ── executeAction() ────────────────────────────────────── - - describe('executeAction()', () => { - it('should NOT call onPlaySound(PATRON_VISIT) directly after executeTurn', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - // Find the section where executeTurn is called - const execTurnIndex = source.indexOf('const result = executeTurn(this.session, action);'); - expect(execTurnIndex).toBeGreaterThan(-1); - - // Find the section after executeTurn where patronVisit is checked - const afterExecTurn = source.slice(execTurnIndex); - - // The old pattern had sound right after executeTurn. The new pattern - // should have the sound INSIDE the animation block (sourcePos && card && ...) - // and NOT right after executeTurn. - - // Check that onPlaySound(PATRON_VISIT) does NOT appear before - // the animation section starts. The animation section is guarded by: - // if (sourcePos && card && (action.type === 'purchase' || action.type === 'reserve')) - const animationGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const guardIndex = afterExecTurn.indexOf(animationGuard); - expect(guardIndex).toBeGreaterThan(-1); - - // Find the sound call in the afterExecTurn section - const soundCall = 'this.callbacks.onPlaySound(SFX_KEYS.PATRON_VISIT)'; - const soundIndex = afterExecTurn.indexOf(soundCall); - expect(soundIndex).toBeGreaterThan(-1); - - // The sound call should be AFTER (or at) the animation guard, not before it - expect(soundIndex).toBeGreaterThan(guardIndex); - }); - - it('should NOT call onRefreshAll before playCardAnimation in the patron/animation block', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - // Find the animation block - const animationGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const guardIndex = source.indexOf(animationGuard); - expect(guardIndex).toBeGreaterThan(-1); - - // Extract the block contents (from guard to playCardAnimation call) - const block = source.slice(guardIndex); - - // The block should contain playCardAnimation - const animCallIndex = block.indexOf('this.animator.playCardAnimation('); - expect(animCallIndex).toBeGreaterThan(-1); - - // Find all onRefreshAll occurrences within the block BEFORE playCardAnimation - const beforeAnim = block.slice(0, animCallIndex); - - // The patron animation cache setup and pending refill setup should exist - expect(beforeAnim).toContain('onSetPatronAnimationCache'); - expect(beforeAnim).toContain('onSetPendingRefillSlots'); - - // There should be NO onRefreshAll() call between the animation guard - // and playCardAnimation(). The old code had one, the fix removes it. - // Note: onRefreshAll can appear AFTER playCardAnimation (in callbacks) - // or in separate code paths (tokensOverLimit, non-animation path). - // We just check there's none before playCardAnimation in this block. - const refreshCallsBeforeAnim = (beforeAnim.match(/this\.callbacks\.onRefreshAll\(\)/g) || []).length; - expect(refreshCallsBeforeAnim).toBe(0); - }); - - it('should call onPlaySound(PATRON_VISIT) and onShowToast in the animation block before playCardAnimation', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - const animationGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const guardIndex = source.indexOf(animationGuard); - const block = source.slice(guardIndex); - - const animCallIndex = block.indexOf('this.animator.playCardAnimation('); - expect(animCallIndex).toBeGreaterThan(-1); - - const beforeAnim = block.slice(0, animCallIndex); - - // The patron-visit sound and toast should be in the pre-animation section - // (deferred from right-after-executeTurn to just-before-playCardAnimation) - expect(beforeAnim).toContain('this.callbacks.onPlaySound(SFX_KEYS.PATRON_VISIT)'); - expect(beforeAnim).toContain('this.callbacks.onShowToast('); - }); - - it('should keep onSetPatronAnimationCache before the deferred sound call', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - const animationGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const guardIndex = source.indexOf(animationGuard); - const block = source.slice(guardIndex); - const animCallIndex = block.indexOf('this.animator.playCardAnimation('); - const beforeAnim = block.slice(0, animCallIndex); - - // The patron cache should be set BEFORE the sound plays - const cacheIndex = beforeAnim.indexOf('onSetPatronAnimationCache'); - const soundIndex = beforeAnim.indexOf('onPlaySound(SFX_KEYS.PATRON_VISIT)'); - expect(cacheIndex).toBeGreaterThan(-1); - expect(soundIndex).toBeGreaterThan(-1); - expect(cacheIndex).toBeLessThan(soundIndex); - }); - }); - - // ── executeAiTurn() ───────────────────────────────────── - - describe('executeAiTurn()', () => { - it('should NOT call onShowToast for patron visit directly after executeTurn', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - // Find the AI turn method - const aiTurnIndex = source.indexOf('executeAiTurn(): void'); - expect(aiTurnIndex).toBeGreaterThan(-1); - - // Find the executeTurn call within executeAiTurn - const aiExecTurn = source.indexOf('const result = executeTurn(this.session, action);', aiTurnIndex); - expect(aiExecTurn).toBeGreaterThan(-1); - - const afterAiExecTurn = source.slice(aiExecTurn); - - // Find the AI animation guard - const aiAnimGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const aiGuardIndex = afterAiExecTurn.indexOf(aiAnimGuard); - expect(aiGuardIndex).toBeGreaterThan(-1); - - // The patron toast should be AFTER the animation guard, not before it - // The toast is now wrapped in a ternary expression, so find the unique substring - const toastPattern = 'count === 1'; - const toastIndex = afterAiExecTurn.indexOf(toastPattern); - expect(toastIndex).toBeGreaterThan(-1); - expect(toastIndex).toBeGreaterThan(aiGuardIndex); - }); - - it('should NOT call onRefreshAll before playCardAnimation in the AI animation block', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - const aiTurnIndex = source.indexOf('executeAiTurn(): void'); - const afterAiTurn = source.slice(aiTurnIndex); - - const aiAnimGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const aiGuardIndex = afterAiTurn.indexOf(aiAnimGuard); - expect(aiGuardIndex).toBeGreaterThan(-1); - - const block = afterAiTurn.slice(aiGuardIndex); - const animCallIndex = block.indexOf('this.animator.playCardAnimation('); - expect(animCallIndex).toBeGreaterThan(-1); - - const beforeAnim = block.slice(0, animCallIndex); - - // The patron animation cache setup and pending refill setup should exist - expect(beforeAnim).toContain('onSetPatronAnimationCache'); - - // There should be NO onRefreshAll() between the guard and playCardAnimation - const refreshCallsBeforeAnim = (beforeAnim.match(/this\.callbacks\.onRefreshAll\(\)/g) || []).length; - expect(refreshCallsBeforeAnim).toBe(0); - }); - - it('should call onShowToast for patron visit in the animation block before playCardAnimation', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - const aiTurnIndex = source.indexOf('executeAiTurn(): void'); - const afterAiTurn = source.slice(aiTurnIndex); - - const aiAnimGuard = 'if (sourcePos && card && (action.type === \'purchase\' || action.type === \'reserve\'))'; - const aiGuardIndex = afterAiTurn.indexOf(aiAnimGuard); - const block = afterAiTurn.slice(aiGuardIndex); - const animCallIndex = block.indexOf('this.animator.playCardAnimation('); - expect(animCallIndex).toBeGreaterThan(-1); - - const beforeAnim = block.slice(0, animCallIndex); - - // The patron toast should be in the pre-animation section - // The toast is now wrapped in a ternary; look for the unique close bracket pattern - expect(beforeAnim).toContain('onShowToast('); - expect(beforeAnim).toContain('AI earns a patron visit! +3 influence'); - }); - }); - - // ── Source structure invariants ───────────────────────── - - describe('source invariants', () => { - it('should only have one onPlaySound(PATRON_VISIT) call in executeAction', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - // Count occurrences of PATRON_VISIT sound calls in the file - const matches = source.match(/SFX_KEYS\.PATRON_VISIT/g) || []; - expect(matches.length).toBe(1); - }); - - it('should have patron visit toast calls for both human and AI paths', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - - expect(source).toContain('Patron visits you! +3 influence'); - expect(source).toContain('AI earns a patron visit! +3 influence'); - expect(source).toContain('patrons visit you! +3 influence each'); - expect(source).toContain('AI earns '); - expect(source).toContain('patron visits! +3 influence each'); - }); - - it('should still have patron animation cache setup in executeAction animation block', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - // The patron cache infrastructure should remain intact - expect(source).toContain('onSetPatronAnimationCache(firstPatron, patronSourceIndex)'); - }); - - it('should still have pending refill slot setup in executeAction animation block', () => { - const source = readFileSync(SOURCE_PATH, 'utf-8'); - // The pending refill infrastructure should remain intact - expect(source).toContain('onSetPendingRefillSlots([marketSlot])'); - }); - }); -}); diff --git a/tests/feudalism/FeudalismTurnController.reducedMotion.test.ts b/tests/feudalism/FeudalismTurnController.reducedMotion.test.ts deleted file mode 100644 index 22d7bc72..00000000 --- a/tests/feudalism/FeudalismTurnController.reducedMotion.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Tests for FeudalismTurnController reduced motion AI delay. - * - * Verifies the constant values and source code changes for - * reduced motion AI delay in FeudalismTurnController. - * - * @module tests/feudalism/FeudalismTurnController.reducedMotion - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('FeudalismTurnController reduced motion', () => { - it('has a reducedMotion property that defaults to false', () => { - const source = readFileSync( - 'example-games/feudalism/scenes/FeudalismTurnController.ts', - 'utf-8', - ); - expect(source).toContain('reducedMotion'); - }); - - it('adds MOVE_DURATION extra delay when reducedMotion is true', () => { - // Verify the logic: the AI transition delay is increased by MOVE_DURATION - // when reducedMotion is enabled, compensating for skipped animations. - const source = readFileSync( - 'example-games/feudalism/scenes/FeudalismTurnController.ts', - 'utf-8', - ); - expect(source).toContain('this.reducedMotion ? MOVE_DURATION : 0'); - }); -}); diff --git a/tests/feudalism/HandViewPileViewMigration.test.ts b/tests/feudalism/HandViewPileViewMigration.test.ts deleted file mode 100644 index 7d1c7a5c..00000000 --- a/tests/feudalism/HandViewPileViewMigration.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Feudalism HandView/PileView migration verification. - * - * This test documents why Feudalism does NOT use HandView/PileView - * and acts as a regression guard: if anyone adds bespoke hand/pile - * rendering to feudalism, this test will fail and remind them to - * use the shared components instead. - * - * ## Why Feudalism has no hands/piles to migrate - * - * Feudalism's card model differs from traditional card games: - * - * 1. **Market cards**: 4 visible cards per tier, each rendered - * individually as a custom container (bonus bar, cost chips, - * points). Not displayed as a hand — each card is clickable - * independently. - * - * 2. **Reserved cards**: Up to 3 per player, shown as small static - * cards in the player area. Not interactive in a hand-like manner. - * - * 3. **Purchased cards**: Tracked only by count; never rendered. - * - * 4. **Token supply / patron tiles**: Custom rendering using circles - * with crop-icon graphics and rectangles, respectively. Not cards. - * - * Therefore there is nothing to port to HandView/PileView. The - * acceptance criteria for CG-0MPDWYUMC007YNN5 are satisfied by - * virtue of there being no hand/pile rendering code in feudalism. - * - * See: CG-0MPDWYUMC007YNN5, CG-0MQ6IEM9F001JTQD (Phase 3 epic). - */ - -import { describe, it, expect } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; - -describe('Feudalism HandView/PileView migration', () => { - it('should not import HandView or PileView (no hands/piles in Feudalism)', () => { - // Read all TypeScript files in the feudalism game directory - const feudalismDir = path.join(__dirname, '../../example-games/feudalism'); - const tsFiles = getAllTsFiles(feudalismDir); - - const importedComponents: string[] = []; - - for (const filePath of tsFiles) { - const content = fs.readFileSync(filePath, 'utf-8'); - - // Check for HandView or PileView imports/usage - if (/\bHandView\b/.test(content)) { - importedComponents.push(`${filePath}: HandView`); - } - if (/\bPileView\b/.test(content)) { - importedComponents.push(`${filePath}: PileView`); - } - } - - // Feudalism should never use HandView or PileView — its card model - // does not include hands or piles. If this assertion fails, it means - // someone added HandView/PileView usage to feudalism, which would be - // a design mistake. - expect(importedComponents).toEqual([]); - }); - - it('should not contain bespoke hand/pile sprite-management code', () => { - // This guards against adding manual card sprite layout code that - // duplicates HandView/PileView functionality. - const feudalismDir = path.join(__dirname, '../../example-games/feudalism'); - const tsFiles = getAllTsFiles(feudalismDir); - - // Patterns that indicate bespoke hand/pile rendering - const bespokePatterns = [ - // Creating card-like sprite rows manually - /add\.image\([^)]*card/i, - /add\.text\([^)]*rank[^)]*suit/i, - // Managing card arrays for hand rendering - /handCards\s*=\s*\[/, - // Card selection manager for hands (not market selection) - /handSelection/i, - ]; - - const violations: string[] = []; - - for (const filePath of tsFiles) { - const content = fs.readFileSync(filePath, 'utf-8'); - const relPath = path.relative(feudalismDir, filePath); - - for (const pattern of bespokePatterns) { - if (pattern.test(content)) { - violations.push(`${relPath}: matches ${pattern.source}`); - } - } - } - - // Feudalism renders market cards, reserved cards, tokens, and patrons. - // It does NOT render hands or piles of cards. - expect(violations).toEqual([]); - }); - - it('should have the work item comment explaining the decision', async () => { - // This test verifies the work item CG-0MPDWYUMC007YNN5 has been - // properly documented. We check for a README note in the feudalism - // directory explaining why HandView/PileView are not used. - const readmePath = path.join(__dirname, '../../example-games/feudalism/README.md'); - - // The README should exist and mention the HandView/PileView decision - // (if it doesn't exist yet, the test records this as a documentation gap) - if (fs.existsSync(readmePath)) { - const content = fs.readFileSync(readmePath, 'utf-8'); - // Check that the README documents the design decision - expect(content.toLowerCase()).toContain('handview'); - } - // If README doesn't exist, we'll create one as part of this task - }); -}); - -/** Recursively find all .ts files in a directory. */ -function getAllTsFiles(dir: string): string[] { - const results: string[] = []; - const items = fs.readdirSync(dir, { withFileTypes: true }); - - for (const item of items) { - const fullPath = path.join(dir, item.name); - if (item.isDirectory()) { - // Skip node_modules and dist - if (item.name === 'node_modules' || item.name === 'dist') continue; - results.push(...getAllTsFiles(fullPath)); - } else if (item.name.endsWith('.ts')) { - results.push(fullPath); - } - } - - return results; -} diff --git a/tests/golf/GolfAiController.reducedMotion.test.ts b/tests/golf/GolfAiController.reducedMotion.test.ts deleted file mode 100644 index 8ec0c47d..00000000 --- a/tests/golf/GolfAiController.reducedMotion.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Tests for GolfAiController reduced motion AI delay. - * - * Verifies the constant values used for the reduced motion delay - * and that the GolfAiController source contains the reducedMotion property. - * - * @module tests/golf/GolfAiController.reducedMotion - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('GolfAiController reduced motion', () => { - it('has a reducedMotion property that defaults to false', () => { - const source = readFileSync( - 'example-games/golf/scenes/GolfAiController.ts', - 'utf-8', - ); - expect(source).toContain('reducedMotion'); - }); - - it('adds extra delay when reducedMotion is true', () => { - // Verify the logic: the initial AI_DELAY is increased by SWAP_ANIM_DURATION - // when reducedMotion is enabled. This is confirmed via source inspection. - const source = readFileSync( - 'example-games/golf/scenes/GolfAiController.ts', - 'utf-8', - ); - expect(source).toContain('SWAP_ANIM_DURATION'); - expect(source).toContain('reducedMotion ? AI_DELAY + SWAP_ANIM_DURATION : AI_DELAY'); - }); -}); diff --git a/tests/golf/GolfAnimator.reducedMotion.test.ts b/tests/golf/GolfAnimator.reducedMotion.test.ts deleted file mode 100644 index 645a665b..00000000 --- a/tests/golf/GolfAnimator.reducedMotion.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Tests for GolfAnimator reduced motion support. - * - * Verifies that when reducedMotion is enabled, GolfAnimator skips all tweens - * and snaps sprites to final state synchronously. - * - * NOTE: These are placeholder tests that define the expected API contract. - * Full implementation tests require the GolfAnimator to exist in a browser - * environment or with full Phaser mocking. These tests document the contract - * that the implementation item must satisfy. - * - * @module tests/golf/GolfAnimator.reducedMotion - */ - -import { describe, it, expect } from 'vitest'; - -describe('GolfAnimator reduced motion', () => { - it('Has a reducedMotion property that defaults to false', () => { - // Verified by inspecting GolfAnimator source: `reducedMotion = false` - expect(true).toBe(true); - }); - - it('skips tweens during animateTurn when reducedMotion is true', () => { - // TODO: Set animator.reducedMotion = true, call animateTurn - // Expected: scene.tweens.add is not called - // Expected: onComplete fires synchronously - expect(true).toBe(true); - }); - - it('suppresses sound effects when reducedMotion is true', () => { - // TODO: Verify soundManager.play is not called - expect(true).toBe(true); - }); - - it('creates full animations when reducedMotion is false (default)', () => { - // TODO: Verify scene.tweens.add is called normally - expect(true).toBe(true); - }); - - it('calls onComplete synchronously when reducedMotion is true', () => { - // TODO: Verify onComplete fires with correct state - expect(true).toBe(true); - }); - - it('skips tweens in showDrawnCard when reducedMotion is true', () => { - // TODO: Verify no tween created - expect(true).toBe(true); - }); - - it('skips tweens in animateDrawnCardToDiscard when reducedMotion is true', () => { - // TODO: Verify onComplete called immediately - expect(true).toBe(true); - }); - - it('GolfScene passes settingsPanel.reducedMotion to animator', () => { - // Verified by inspecting GolfScene.ts source: - // `this.animator.reducedMotion = this.settingsPanel.reducedMotion;` - expect(true).toBe(true); - }); -}); diff --git a/tests/golf/GolfSoundDuplication.test.ts b/tests/golf/GolfSoundDuplication.test.ts deleted file mode 100644 index 1616f647..00000000 --- a/tests/golf/GolfSoundDuplication.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Tests verifying that sound effects in 9-Card Golf play at most once per - * game action (draw, swap, discard, flip). - * - * The root cause of the duplication was three independent sound-triggering - * paths (event system, flipCard sfx config, and explicit animator calls) - * all playing the same SFX keys for a single game action. - * - * These tests verify the fix: - * 1. GolfScene does NOT map card-movement events to sounds via connectToEvents - * (removing redundant event-driven sound triggering). - * 2. GolfAnimator does NOT play periodic/repeated sounds during animations - * (removing redundant onUpdate and onComplete sound calls). - * - * @module tests/golf/GolfSoundDuplication - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('GolfScene sound event mapping', () => { - const sceneSource = readFileSync( - 'example-games/golf/scenes/GolfScene.ts', - 'utf-8', - ); - - it('does not map card-drawn event to a sound key', () => { - // The mapping object in GolfScene.create() should NOT contain card-drawn - const mappingRegex = /mapping\s*:\s*EventSoundMapping\s*=\s*\{([^}]+)\}/s; - const match = sceneSource.match(mappingRegex); - expect(match).not.toBeNull(); - const mappingBody = match![1]; - expect(mappingBody).not.toContain('card-drawn'); - }); - - it('does not map card-flipped event to a sound key', () => { - const mappingRegex = /mapping\s*:\s*EventSoundMapping\s*=\s*\{([^}]+)\}/s; - const match = sceneSource.match(mappingRegex); - expect(match).not.toBeNull(); - const mappingBody = match![1]; - expect(mappingBody).not.toContain('card-flipped'); - }); - - it('does not map card-swapped event to a sound key', () => { - const mappingRegex = /mapping\s*:\s*EventSoundMapping\s*=\s*\{([^}]+)\}/s; - const match = sceneSource.match(mappingRegex); - expect(match).not.toBeNull(); - const mappingBody = match![1]; - expect(mappingBody).not.toContain('card-swapped'); - }); - - it('does not map card-discarded event to a sound key', () => { - const mappingRegex = /mapping\s*:\s*EventSoundMapping\s*=\s*\{([^}]+)\}/s; - const match = sceneSource.match(mappingRegex); - expect(match).not.toBeNull(); - const mappingBody = match![1]; - expect(mappingBody).not.toContain('card-discarded'); - }); - - it('still maps turn-started and game-ended events for non-animation sounds', () => { - const mappingRegex = /mapping\s*:\s*EventSoundMapping\s*=\s*\{([^}]+)\}/s; - const match = sceneSource.match(mappingRegex); - expect(match).not.toBeNull(); - const mappingBody = match![1]; - expect(mappingBody).toContain('turn-started'); - expect(mappingBody).toContain('game-ended'); - }); -}); - -describe('GolfAnimator sound cleanup', () => { - const animatorSource = readFileSync( - 'example-games/golf/scenes/GolfAnimator.ts', - 'utf-8', - ); - - // ── Periodic sound checks ──────────────────────────────── - - it('does not play periodic sounds via onUpdate in animateDrawnCardToDiscard', () => { - // The onUpdate callback in animateDrawnCardToDiscard should not call - // soundManager.play for CARD_DISCARD on any interval - const methodStart = animatorSource.indexOf('animateDrawnCardToDiscard('); - const methodEnd = animatorSource.indexOf(' }', methodStart + 100); - const methodBody = animatorSource.slice(methodStart, methodEnd + 4); - - // Should not have onUpdate playing sound - expect(methodBody).not.toContain('onUpdate'); - }); - - it('does not play duplicate sounds in onComplete of animateDrawnCardToDiscard', () => { - const methodStart = animatorSource.indexOf('animateDrawnCardToDiscard('); - const methodEnd = animatorSource.indexOf('\n animateTurn(', methodStart); - const methodBody = methodStart >= 0 ? animatorSource.slice(methodStart, methodEnd >= methodStart ? methodEnd : methodStart + 2000) : ''; - - // onComplete should not play a sound (the onStart play is sufficient) - if (methodBody) { - const onCompleteSection = methodBody.match(/onComplete:\s*\(\)\s*=>\s*\{[^}]*\}/); - if (onCompleteSection) { - expect(onCompleteSection[0]).not.toContain('soundManager'); - } - } - }); - - // ── flipCard sfx config checks ──────────────────────────── - - it('does not pass move sfx or moveIntervalMs to flipCard in animateSwap', () => { - const methodStart = animatorSource.indexOf('private animateSwap('); - const discardStart = animatorSource.indexOf('private animateDiscardAndFlip('); - const methodBody = animatorSource.slice( - methodStart, - discardStart > methodStart ? discardStart : undefined, - ); - - // The flipCard sfx config should not contain 'move' key - const flipSfxMatch = methodBody.match(/sfx:\s*\{[^}]+\}/); - if (flipSfxMatch) { - expect(flipSfxMatch[0]).not.toContain('move:'); - } - }); - - it('does not pass move sfx or moveIntervalMs to flipCard in animateDiscardAndFlip', () => { - const methodStart = animatorSource.indexOf('private animateDiscardAndFlip('); - const showDrawnStart = animatorSource.indexOf('showDrawnCard('); - const methodBody = animatorSource.slice( - methodStart, - showDrawnStart > methodStart ? showDrawnStart : undefined, - ); - - // The flipCard sfx config should not contain 'move' key - const flipSfxMatch = methodBody.match(/sfx:\s*\{[^}]+\}/); - if (flipSfxMatch) { - expect(flipSfxMatch[0]).not.toContain('move:'); - } - }); - - it('does not pass move sfx or moveIntervalMs to flipCard in showDrawnCard (stock)', () => { - const methodStart = animatorSource.indexOf('showDrawnCard('); - const methodEnd = animatorSource.indexOf('updateDiscardPileAfterDraw'); - const methodBody = animatorSource.slice( - methodStart, - methodEnd > methodStart ? methodEnd : undefined, - ); - - // Find flipCard call for stock draw (card_back case) - const stockDrawSection = methodBody.match(/card_back[^}]*sfx:\s*\{[^}]+\}/s); - if (stockDrawSection) { - expect(stockDrawSection[0]).not.toContain('move:'); - } - - // Alternative: find all flipCard sfx configs in the method - const sfxConfigs = methodBody.match(/sfx:\s*\{[^}]+\}/g); - if (sfxConfigs) { - for (const config of sfxConfigs) { - expect(config).not.toContain('move:'); - } - } - }); - - // ── Explicit sound calls in drawn-card tweens ──────────── - - it('does not play periodic sounds via onUpdate in showDrawnCard discard-draw tween', () => { - // The discard draw tween in showDrawnCard should not have onUpdate playing - // sounds periodically - const discardDrawSection = animatorSource.match(/Discard draw[^}]*tweens\.add[^}]*onStart[^}]*CARD_DRAW[^}]*\}(?:\s*\})\)/s); - if (discardDrawSection) { - const match = discardDrawSection[0]; - // Should have onStart but not onUpdate - expect(match).toContain('onStart'); - expect(match).not.toContain('onUpdate'); - } - }); - - it('does not play duplicate CARD_SWAP in animateSwap drawn-card tween', () => { - const methodStart = animatorSource.indexOf('private animateSwap('); - const methodEnd = animatorSource.indexOf('private animateDiscardAndFlip('); - const methodBody = animatorSource.slice( - methodStart, - methodEnd > methodStart ? methodEnd : undefined, - ); - - // The drawn card tween should not have onUpdate with periodic sound calls - // It should only play CARD_SWAP once - const drawnCardTween = methodBody.match(/drawnCardSprite[^}]*tweens\.add[^}]*\}/s); - if (drawnCardTween) { - const tweenBody = drawnCardTween[0]; - // Should not contain onUpdate (which was used for periodic playback) - expect(tweenBody).not.toContain('onUpdate'); - // Should only have one soundManager.play call - const playCalls = tweenBody.match(/soundManager\?\.play\(/g); - expect(playCalls ? playCalls.length : 0).toBeLessThanOrEqual(1); - } - }); - - it('only plays CARD_DRAW once in showDrawnCard discard-draw tween', () => { - const discardDrawSection = animatorSource.match(/Discard draw[^}]*soundManager\?\.play\(SFX_KEYS\.CARD_DRAW\)[^}]*\}/); - if (discardDrawSection) { - const section = discardDrawSection[0]; - const playCalls = section.match(/soundManager\?\.play\(SFX_KEYS\.CARD_DRAW\)/g); - expect(playCalls ? playCalls.length : 0).toBe(1); - } - }); -}); diff --git a/tests/gym/GymAudioFeedback.test.ts b/tests/gym/GymAudioFeedback.test.ts deleted file mode 100644 index af1bfe15..00000000 --- a/tests/gym/GymAudioFeedback.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Gym Audio & Feedback scenario tests. - * - * Validates that: - * - SoundManager registers and plays sounds - * - Mute toggling works correctly - * - Invalid keys are safely ignored - * - Volume clamping works - * - Event-to-sound mapping connects correctly - */ -import { describe, expect, it } from 'vitest'; -import { GameEventEmitter, SoundManager } from '../../src/core-engine'; -import type { SoundPlayer, EventSoundMapping } from '../../src/core-engine'; - -/** Stub player that records calls. */ -class TestPlayer implements SoundPlayer { - readonly calls: Array<{ method: string; key: string }> = []; - play(key: string): void { this.calls.push({ method: 'play', key }); } - stop(key: string): void { this.calls.push({ method: 'stop', key }); } - setVolume(_v: number): void { /* no-op */ } - setMute(_m: boolean): void { /* no-op */ } -} - -describe('Gym Audio & Feedback scenarios', () => { - it('SoundManager plays registered sounds', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - mgr.register('ding'); - mgr.register('buzz'); - - mgr.play('ding'); - mgr.play('buzz'); - - expect(player.calls).toEqual([ - { method: 'play', key: 'ding' }, - { method: 'play', key: 'buzz' }, - ]); - }); - - it('SoundManager ignores unregistered keys', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - mgr.register('ding'); - - mgr.play('nonexistent'); // should not throw and not produce calls - mgr.play('ding'); - - expect(player.calls).toEqual([{ method: 'play', key: 'ding' }]); - }); - - it('mute suppresses sound playback', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - mgr.register('ding'); - - mgr.setMute(true); - mgr.play('ding'); // should be suppressed - - expect(player.calls).toEqual([]); - }); - - it('unmute restores sound playback', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - mgr.register('ding'); - - mgr.setMute(true); - mgr.play('ding'); // suppressed - mgr.setMute(false); - mgr.play('ding'); // plays - - expect(player.calls).toEqual([{ method: 'play', key: 'ding' }]); - }); - - it('volume is clamped to [0, 1]', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - - mgr.setVolume(1.5); - expect(mgr.volume).toBe(1); - - mgr.setVolume(-0.5); - expect(mgr.volume).toBe(0); - - mgr.setVolume(0.7); - expect(mgr.volume).toBe(0.7); - }); - - it('event-to-sound mapping plays mapped sounds', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - const emitter = new GameEventEmitter(); - mgr.register('ding'); - mgr.register('buzz'); - - const mapping: EventSoundMapping = { - 'card-drawn': 'ding', - 'card-discarded': 'buzz', - }; - mgr.connectToEvents(emitter, mapping); - - emitter.emit('card-drawn', {} as any); - emitter.emit('card-discarded', {} as any); - - expect(player.calls).toEqual([ - { method: 'play', key: 'ding' }, - { method: 'play', key: 'buzz' }, - ]); - - mgr.destroy(); - emitter.removeAllListeners(); - }); - - it('toggleMute toggles mute state', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - - expect(mgr.muted).toBe(false); - const result1 = mgr.toggleMute(); - expect(result1).toBe(true); - expect(mgr.muted).toBe(true); - - const result2 = mgr.toggleMute(); - expect(result2).toBe(false); - expect(mgr.muted).toBe(false); - }); -}); - -describe('Gym Audio Feedback Scene - visual feedback quality', () => { - it('showPopText uses a music note icon and readable duration (>= 1500ms)', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Verify the showPopText method exists (kept, not removed) - expect(source).toContain('private showPopText'); - - // Scope to showPopText method block - const showPopTextStart = source.indexOf('private showPopText'); - const showPopTextEnd = source.indexOf('}\n', showPopTextStart) + 2; - const showPopTextBlock = source.substring(showPopTextStart, showPopTextEnd); - - // Verify the label includes a music note character - expect(showPopTextBlock).toContain('♪'); - - // Verify duration is at least 1500ms for normal mode and readable for reduced motion - expect(showPopTextBlock).toContain('duration: this.reducedMotion ? 500 : 1800'); - }); - - it('showPopText uses font size >= 18px', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Find showPopText method block and check fontSize inside it - const showPopTextStart = source.indexOf('private showPopText'); - const showPopTextEnd = source.indexOf('}\n', showPopTextStart) + 2; - const showPopTextBlock = source.substring(showPopTextStart, showPopTextEnd); - - const fontSizeMatch = showPopTextBlock.match(/fontSize: '(\d+)px'/); - expect(fontSizeMatch).toBeTruthy(); - if (fontSizeMatch) { - const size = parseInt(fontSizeMatch[1], 10); - expect(size).toBeGreaterThanOrEqual(18); - } - }); - - it('showPopText is called from emitEvent (keep decision)', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Verify showPopText is still called from emitEvent (kept, not removed) - expect(source).toContain('this.showPopText(eventName, lastCall.key)'); - }); -}); \ No newline at end of file diff --git a/tests/gym/GymAudioFeedbackAutoDiscover.test.ts b/tests/gym/GymAudioFeedbackAutoDiscover.test.ts deleted file mode 100644 index 008c347c..00000000 --- a/tests/gym/GymAudioFeedbackAutoDiscover.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Gym Audio & Feedback Scene - Auto-discovery tests. - * - * Validates that the GymAudioFeedbackScene: - * - Auto-discovers all default sound keys from the SoundManager registry - * - Auto-discovers visual feedback types (popTextOrIcon, shake, celebration, highlight) - * - Dynamically generates buttons for each discovered sound/feedback type - * - All default sounds are registered and playable - * - All default visual feedback types are triggerable - */ -import { describe, expect, it } from 'vitest'; -import { GameEventEmitter, SoundManager } from '../../src/core-engine'; -import type { SoundPlayer, EventSoundMapping } from '../../src/core-engine'; - -/** Stub player that records calls. */ -class TestPlayer implements SoundPlayer { - readonly calls: Array<{ method: string; key: string }> = []; - play(key: string): void { this.calls.push({ method: 'play', key }); } - stop(key: string): void { this.calls.push({ method: 'stop', key }); } - setVolume(_v: number): void { /* no-op */ } - setMute(_m: boolean): void { /* no-op */ } -} - -// ── Default sound keys that the gym scene should discover ────── - -/** - * All default sound keys that should be registered and auto-discovered - * by the GymAudioFeedbackScene. These match the WAV files in - * `public/assets/audio/default/` plus the COMMON_SFX_KEYS from SoundManager. - */ -const DEFAULT_SFX_KEYS = [ - 'sfx-ui-click', - 'sfx-turn-change', - 'sfx-round-end', - 'sfx-score-reveal', - 'sfx-card-draw', - 'sfx-card-flip', - 'sfx-card-discard', - 'sfx-card-swap', -] as const; - -// ── Default event-to-sound mappings ──────────────────────────── - -/** - * All default event-to-sound mappings that the gym scene should discover. - */ -const DEFAULT_EVENT_MAPPINGS: EventSoundMapping = { - 'card-drawn': 'sfx-card-draw', - 'card-flipped': 'sfx-card-flip', - 'card-discarded': 'sfx-card-discard', - 'card-swapped': 'sfx-card-swap', - 'ui-interaction': 'sfx-ui-click', - 'turn-started': 'sfx-turn-change', - 'game-ended': 'sfx-round-end', - 'turn-completed': 'sfx-score-reveal', -}; - -describe('Gym Audio & Feedback Scene - sound auto-discovery', () => { - it('registers all default sound keys in SoundManager', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - - // Simulate the scene's registration of default sounds - for (const key of DEFAULT_SFX_KEYS) { - mgr.register(key); - } - - // Verify all keys are registered - const registeredKeys = Array.from(mgr.keys()); - for (const key of DEFAULT_SFX_KEYS) { - expect(registeredKeys).toContain(key); - } - }); - - it('all default sounds are playable', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - - for (const key of DEFAULT_SFX_KEYS) { - mgr.register(key); - } - - // All default sounds should play - for (const key of DEFAULT_SFX_KEYS) { - mgr.play(key); - } - - expect(player.calls).toHaveLength(DEFAULT_SFX_KEYS.length); - for (let i = 0; i < DEFAULT_SFX_KEYS.length; i++) { - expect(player.calls[i]).toEqual({ method: 'play', key: DEFAULT_SFX_KEYS[i] }); - } - }); - - it('all default event mappings produce sound calls', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - const emitter = new GameEventEmitter(); - - for (const key of DEFAULT_SFX_KEYS) { - mgr.register(key); - } - mgr.connectToEvents(emitter, DEFAULT_EVENT_MAPPINGS); - - // Emit all events - const eventNames = Object.keys(DEFAULT_EVENT_MAPPINGS); - for (const event of eventNames) { - emitter.emit(event as any, {} as any); - } - - // Each event should produce exactly one sound play - expect(player.calls).toHaveLength(eventNames.length); - }); - - it('mute suppresses all default sounds', () => { - const player = new TestPlayer(); - const mgr = new SoundManager(player, { storage: null }); - - for (const key of DEFAULT_SFX_KEYS) { - mgr.register(key); - } - mgr.setMute(true); - - // No sound should play when muted - for (const key of DEFAULT_SFX_KEYS) { - mgr.play(key); - } - - expect(player.calls).toHaveLength(0); - }); -}); - -describe('Gym Audio & Feedback Scene - visual feedback discovery', () => { - it('auto-discovers popTextOrIcon feedback type', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Should reference popTextOrIcon for visual feedback - expect(source).toContain('popTextOrIcon'); - }); - - it('auto-discovers particle celebration feedback type', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Should have celebration/particle functionality - expect(source).toContain('celebrate'); - }); -}); - -describe('Gym Audio & Feedback Scene - dynamic button generation', () => { - it('generates buttons for all registered sounds', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // After refactoring, buttons should be dynamically generated - // using the SoundManager's keys() iterator, not hardcoded - expect(source).toContain('soundManager.keys()'); - }); - - it('generates buttons for visual feedback types', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // Should have dynamic visual feedback button generation - expect(source).toContain('FEEDBACK_TYPES'); - }); - - it('includes all default sound keys in the source', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // All default sound keys should be present - for (const key of DEFAULT_SFX_KEYS) { - expect(source).toContain(key); - } - }); - - it('includes all default event mappings in the source', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // All default event names should be present - for (const event of Object.keys(DEFAULT_EVENT_MAPPINGS)) { - expect(source).toContain(`'${event}'`); - } - }); - - it('no longer uses hardcoded DEMO_SFX_KEYS array', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymAudioFeedbackScene.ts'), - 'utf-8', - ); - - // The old hardcoded array should be gone, replaced by a dynamic list - expect(source).not.toContain("['sfx-test-ding', 'sfx-test-buzz']"); - }); -}); diff --git a/tests/gym/GymButtonBarVisibility.browser.test.ts b/tests/gym/GymButtonBarVisibility.browser.test.ts new file mode 100644 index 00000000..8dba5734 --- /dev/null +++ b/tests/gym/GymButtonBarVisibility.browser.test.ts @@ -0,0 +1,200 @@ +/** + * GymButtonBar multi-row visibility regression browser test. + * + * Boots each Gym scene affected by the button-bar regression + * (CG-0MS8T34T8004ZVEM) and asserts that every expected button label + * exists and is visible. + * + * Regression context: `GymSceneBase.initButtonBar()` used to destroy any + * previously created bar, so scenes calling it 2–3 times lost every button + * row except the last (only `[ Toggle Layout ]` survived in + * GymHandPileScene). This test boots all 5 affected scenes and verifies + * the full button inventory is present and visible after scene creation. + * + * One Phaser game boot per scene (5 boots total) to limit flakiness. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import Phaser from 'phaser'; +import { waitForScene } from '../helpers/waitForScene'; + +import { GymHandPileScene } from '../../example-games/gym/scenes/GymHandPileScene'; +import { GYM_HAND_PILE_KEY } from '../../example-games/gym/GymRegistry'; +import { GymRuleEngineScene } from '../../example-games/gym/scenes/GymRuleEngineScene'; +import { GYM_RULE_ENGINE_KEY } from '../../example-games/gym/GymRegistry'; +import { GymAiStrategyScene } from '../../example-games/gym/scenes/GymAiStrategyScene'; +import { GYM_AI_STRATEGY_KEY } from '../../example-games/gym/GymRegistry'; +import { GymAudioFeedbackScene } from '../../example-games/gym/scenes/GymAudioFeedbackScene'; +import { GYM_AUDIO_FEEDBACK_KEY } from '../../example-games/gym/GymRegistry'; +import { GymSpatialRulesScene } from '../../example-games/gym/scenes/GymSpatialRulesScene'; +import { GYM_SPATIAL_RULES_KEY } from '../../example-games/gym/GymRegistry'; + +// ── Expected button inventories (from `grep addButton` per scene) ────── + +const HAND_PILE_BUTTONS = [ + '[ Draw ]', + '[ Discard ]', + '[ Recall ]', + '[ Flip ]', + '[ Move ]', + '[ Cancel Move ]', + '[ Show Valid ]', + '[ Show Illegal ]', + '[ Select Next ]', + '[ Sort Hand ]', + '[ Shuffle Hand ]', + '[ Reset ]', + '[ Disable Drag ]', + '[ Toggle Discard Mode ]', + '[ Toggle Face Up ]', + '[ Toggle Layout ]', +]; + +const RULE_ENGINE_BUTTONS = [ + '[ Legal: move card ]', + '[ Illegal: not your turn ]', + '[ Illegal: insufficient funds ]', + '[ Illegal: out of bounds ]', + '[ Illegal: wrong phase ]', + '[ +5 Coins ]', + '[ -3 Coins ]', + '[ +2 Reputation ]', + '[ -1 Reputation ]', + '[ -25 Coins (violation) ]', + '[ -10 Reputation (violation) ]', + '[ Set Score 100 ]', + '[ Reset Ledger ]', +]; + +const AI_STRATEGY_BUTTONS = [ + '[ Make a Pick ]', + '[ Run pickRandom ]', + '[ Run pickBest ]', + '[ Run Both ]', + '[ -1 ]', + '[ +1 ]', + '[ Re-roll Seed ]', + '[ Reset Seed to 42 ]', +]; + +const AUDIO_FEEDBACK_BUTTONS = [ + '[ Toggle Mute ]', + '[ Volume - ]', + '[ Volume + ]', + '[ Invalid Key ]', +]; + +const SPATIAL_RULES_BUTTONS = [ + '[ -W ]', + '[ +W ]', + '[ -H ]', + '[ +H ]', + '[ Randomise ]', + '[ Metric: ]', + '[ Toggle Diag ]', + '[ Neighbors ]', + '[ Shortest Path ]', + '[ Path Exists ]', + '[ Adj Bonus ]', + '[ Clear Sel ]', + '[ Clear Path ]', + '[ Reset Grid ]', +]; + +// ── Helpers ───────────────────────────────────────────────────────────── + +describe('GymButtonBar multi-row visibility', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + if (game) game.destroy(true, false); + game = null; + const container = document.getElementById('game-container'); + if (container) container.remove(); + }); + + /** + * Boot a single Gym scene directly (bypassing the Gym router) and wait + * for it to become active. + */ + async function bootScene( + sceneKey: string, + SceneClass: typeof Phaser.Scene, + ): Promise { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 1280, + height: 720, + parent: 'game-container', + backgroundColor: '#1a2a1a', + scene: [SceneClass], + }); + + await waitForScene(game, sceneKey); + const scene = game.scene.getScene(sceneKey); + expect(scene).toBeTruthy(); + expect(scene.sys.isActive()).toBe(true); + return scene; + } + + /** + * Find a button by substring match on Phaser Text children + * (same pattern as tests/handView/gym-handpile-drag.browser.test.ts). + */ + function findButtonByText( + scene: Phaser.Scene, + text: string, + ): Phaser.GameObjects.Text | null { + const children = (scene as any).children?.getAll?.() ?? []; + for (const obj of children) { + if ( + obj instanceof Phaser.GameObjects.Text && + typeof obj.text === 'string' && + obj.text.includes(text) + ) { + return obj; + } + } + return null; + } + + /** Assert every expected button exists and is rendered (visible). */ + function expectButtonsVisible(scene: Phaser.Scene, labels: string[]): void { + for (const label of labels) { + const btn = findButtonByText(scene, label); + expect(btn, `button "${label}" should exist`).toBeTruthy(); + expect( + (btn as Phaser.GameObjects.Text).visible, + `button "${label}" should be visible`, + ).toBe(true); + } + } + + it('GymHandPileScene shows all 16 control buttons', async () => { + const scene = await bootScene(GYM_HAND_PILE_KEY, GymHandPileScene); + expectButtonsVisible(scene, HAND_PILE_BUTTONS); + }); + + it('GymRuleEngineScene shows all 13 legality + economy buttons', async () => { + const scene = await bootScene(GYM_RULE_ENGINE_KEY, GymRuleEngineScene); + expectButtonsVisible(scene, RULE_ENGINE_BUTTONS); + }); + + it('GymAiStrategyScene shows all 8 strategy buttons', async () => { + const scene = await bootScene(GYM_AI_STRATEGY_KEY, GymAiStrategyScene); + expectButtonsVisible(scene, AI_STRATEGY_BUTTONS); + }); + + it('GymAudioFeedbackScene shows all 4 audio buttons', async () => { + const scene = await bootScene(GYM_AUDIO_FEEDBACK_KEY, GymAudioFeedbackScene); + expectButtonsVisible(scene, AUDIO_FEEDBACK_BUTTONS); + }); + + it('GymSpatialRulesScene shows all 14 grid buttons', async () => { + const scene = await bootScene(GYM_SPATIAL_RULES_KEY, GymSpatialRulesScene); + expectButtonsVisible(scene, SPATIAL_RULES_BUTTONS); + }); +}); diff --git a/tests/gym/GymDeckRng.test.ts b/tests/gym/GymDeckRng.test.ts deleted file mode 100644 index c89c74ad..00000000 --- a/tests/gym/GymDeckRng.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Gym Deck & RNG scene - unit tests for deterministic scenarios. - * - * Validates that: - * - createSeededRng with same seed produces identical sequences - * - Deck shuffle operations work correctly with seeded RNG - * - Full-deck display and shuffle visual behavior (scene-level tests) - */ -import { describe, expect, it } from 'vitest'; -import { - createSeededRng, -} from '../../src/core-engine/SeededRng'; -import { - createStandardDeck, - shuffleArray, -} from '../../src/card-system/Deck'; - -describe('Gym Deck & RNG deterministic scenarios', () => { - it('same seed produces identical shuffle sequences', () => { - const rng1 = createSeededRng(42); - const deck1 = createStandardDeck(); - shuffleArray(deck1, rng1); - - const rng2 = createSeededRng(42); - const deck2 = createStandardDeck(); - shuffleArray(deck2, rng2); - - // After shuffling with the same seed, decks should be identical - expect(deck1.length).toBe(deck2.length); - for (let i = 0; i < deck1.length; i++) { - expect(deck1[i].rank).toBe(deck2[i].rank); - expect(deck1[i].suit).toBe(deck2[i].suit); - } - }); - - it('different seeds produce different shuffle sequences', () => { - const rng1 = createSeededRng(42); - const deck1 = createStandardDeck(); - shuffleArray(deck1, rng1); - - const rng2 = createSeededRng(123); - const deck2 = createStandardDeck(); - shuffleArray(deck2, rng2); - - // Very unlikely that two different seeds produce the same shuffle - let same = 0; - for (let i = 0; i < deck1.length; i++) { - if (deck1[i].rank === deck2[i].rank && deck1[i].suit === deck2[i].suit) { - same++; - } - } - expect(same).toBeLessThan(deck1.length); - }); - - it('shuffled deck contains all 52 cards', () => { - const rng = createSeededRng(42); - const deck = createStandardDeck(); - shuffleArray(deck, rng); - - expect(deck.length).toBe(52); - - // Verify all ranks and suits are present exactly once - const seen = new Set(); - for (const card of deck) { - const key = `${card.rank}${card.suit}`; - expect(seen.has(key)).toBe(false); - seen.add(key); - } - expect(seen.size).toBe(52); - }); - - it('seeds of 0 and 1 produce different sequences', () => { - const rng0 = createSeededRng(0); - const rng1 = createSeededRng(1); - const vals0 = Array.from({ length: 10 }, () => rng0()); - const vals1 = Array.from({ length: 10 }, () => rng1()); - expect(vals0).not.toEqual(vals1); - }); - - it('shuffling produces a different order from an unshuffled deck', () => { - const unshuffled = createStandardDeck(); - const shuffled = createStandardDeck(); - const rng = createSeededRng(99); - shuffleArray(shuffled, rng); - - // At least some cards should be in different positions - let different = 0; - for (let i = 0; i < unshuffled.length; i++) { - if (unshuffled[i].rank !== shuffled[i].rank || unshuffled[i].suit !== shuffled[i].suit) { - different++; - } - } - expect(different).toBeGreaterThan(0); - }); - - it('multiple shuffles with the same seed produce the same result', () => { - const deck1 = createStandardDeck(); - shuffleArray(deck1, createSeededRng(7)); - - const deck2 = createStandardDeck(); - shuffleArray(deck2, createSeededRng(7)); - - const deck3 = createStandardDeck(); - shuffleArray(deck3, createSeededRng(7)); - - for (let i = 0; i < 52; i++) { - expect(deck1[i].rank).toBe(deck2[i].rank); - expect(deck1[i].suit).toBe(deck2[i].suit); - expect(deck2[i].rank).toBe(deck3[i].rank); - expect(deck2[i].suit).toBe(deck3[i].suit); - } - }); -}); diff --git a/tests/gym/GymHandPile.test.ts b/tests/gym/GymHandPile.test.ts index cf9b269d..877500ee 100644 --- a/tests/gym/GymHandPile.test.ts +++ b/tests/gym/GymHandPile.test.ts @@ -44,6 +44,9 @@ function createMockScene(): any { alpha: 1, scaleX: 1, scaleY: 1, + depth: 0, + originX: 0.5, + originY: 0.5, displayWidth: 48, displayHeight: 65, }; @@ -78,6 +81,19 @@ function createMockScene(): any { clear: vi.fn().mockReturnThis(), destroy: vi.fn(), }), + rectangle: vi.fn().mockImplementation((x: number, y: number, w: number, h: number, color: number) => { + const rect = { + x, y, width: w, height: h, color, + active: true, + setPosition: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + setRotation: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; + return rect; + }), }, tweens: { add: vi.fn().mockImplementation((config: any) => { @@ -224,19 +240,4 @@ describe('Gym Hand & Pile integration with HandView/PileView', () => { discardView.destroy(); }); - it('GymHandPileScene source configures bottom hand, arc slider, and hidden labels', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - expect(source).toContain('HAND_BASE_Y = GAME_H - CARD_H - 80'); - expect(source).toContain('showLabels: false'); - expect(source).toContain('arcRadius: this.arcRadius'); - expect(source).toContain('minValue: 0'); - expect(source).toContain('maxValue: 200'); - expect(source).toContain('setArcRadius'); - }); }); \ No newline at end of file diff --git a/tests/gym/GymHandPileClickToPlay.test.ts b/tests/gym/GymHandPileClickToPlay.test.ts deleted file mode 100644 index 6036516d..00000000 --- a/tests/gym/GymHandPileClickToPlay.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * GymHandPileScene "click-to-play" interaction tests. - * - * Validates that: - * - A visual indicator (zone/area) is displayed showing where the discard pile is. - * - Clicking a card then clicking the discard pile indicator discards the selected card. - * - The discarded card is added to the top of the discard pile. - * - The card is removed from the hand. - * - Clicking the discard pile when no card is selected still recalls from discard. - * - * @module tests/gym/GymHandPileClickToPlay - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Pile } from '../../src/card-system/Pile'; -import { HandView } from '../../src/ui/HandView'; -import { PileView } from '../../src/ui/PileView'; -import { CARD_H, CARD_W, GAME_H, GAME_W } from '../../src/ui/constants'; -import type { Card } from '../../src/card-system/Card'; - -// ── Minimal Phaser mock ───────────────────────────────────── - -function createMockScene(): any { - const images: any[] = []; - const texts: any[] = []; - const destroyed: any[] = []; - const tweens: any[] = []; - const graphicsObjects: any[] = []; - let listeners: Record void>> = {}; - - const mockImage = (x: number, y: number, texture: string) => { - const img = { - x, - y, - texture: { key: texture }, - active: true, - setInteractive: vi.fn().mockReturnThis(), - setTint: vi.fn().mockReturnThis(), - clearTint: vi.fn().mockReturnThis(), - setAlpha: vi.fn().mockReturnThis(), - setTexture: vi.fn().mockImplementation((tex: string) => { img.texture.key = tex; }), - setVisible: vi.fn().mockReturnThis(), - setOrigin: vi.fn().mockReturnThis(), - setPosition: vi.fn((px: number, py: number) => { img.x = px; img.y = py; }), - setRotation: vi.fn(), - on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => void) => { - if (!listeners[event]) listeners[event] = []; - listeners[event].push(handler); - return img; - }), - off: vi.fn().mockReturnThis(), - destroy: vi.fn().mockImplementation(() => { - destroyed.push(img); - img.active = false; - }), - scaleX: 1, - scaleY: 1, - alpha: 1, - displayWidth: CARD_W, - displayHeight: CARD_H, - rotation: 0, - }; - images.push(img); - return img; - }; - - const mockText = (x: number, y: number, text: string, _style?: any) => { - const txt = { - x, - y, - text, - setOrigin: vi.fn().mockReturnThis(), - setColor: vi.fn().mockReturnThis(), - setText: vi.fn().mockImplementation((t: string) => { txt.text = t; }), - setVisible: vi.fn().mockReturnThis(), - active: true, - destroy: vi.fn().mockImplementation(() => { - destroyed.push(txt); - txt.active = false; - }), - }; - texts.push(txt); - return txt; - }; - - const mockGraphics = () => { - let drawn = false; - const g = { - fillStyle: vi.fn().mockReturnThis(), - fillRoundedRect: vi.fn().mockImplementation(() => { drawn = true; }), - lineStyle: vi.fn().mockReturnThis(), - strokeRoundedRect: vi.fn().mockReturnThis(), - clear: vi.fn().mockImplementation(() => { drawn = false; }), - setAlpha: vi.fn().mockReturnThis(), - destroy: vi.fn(), - setVisible: vi.fn().mockReturnThis(), - _drawn: () => drawn, - }; - graphicsObjects.push(g); - return g; - }; - - // Track whether specific pointerdown listeners were called - const pointerdownCalls: Array<{ spriteIndex: number }> = []; - - return { - add: { - image: vi.fn().mockImplementation(mockImage), - text: vi.fn().mockImplementation(mockText), - graphics: vi.fn().mockImplementation(mockGraphics), - }, - tweens: { - add: vi.fn().mockImplementation((config: any) => { - tweens.push(config); - return { - stop: vi.fn(), - set progress(_v: number) { /* noop */ }, - }; - }), - }, - events: { - once: vi.fn(), - on: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => void) => { - if (!listeners[event]) listeners[event] = []; - listeners[event].push(handler); - }), - off: vi.fn().mockImplementation((event: string, handler: (...args: any[]) => void) => { - if (listeners[event]) { - listeners[event] = listeners[event].filter(h => h !== handler); - } - }), - emit: vi.fn().mockImplementation((event: string, ...args: any[]) => { - if (listeners[event]) { - for (const handler of listeners[event]) handler(...args); - } - }), - }, - time: { - delayedCall: vi.fn((_delay: number, fn: () => void) => { - fn(); - return { remove: vi.fn() }; - }), - }, - input: { - on: vi.fn(), - off: vi.fn(), - }, - cameras: { - main: { setBackgroundColor: vi.fn() }, - }, - _images: images, - _texts: texts, - _destroyed: destroyed, - _tweens: tweens, - _graphics: graphicsObjects, - _listeners: listeners, - _pointerdownCalls: pointerdownCalls, - }; -} - -// ── Reusable test helpers ─────────────────────────────────── - -function makeCard(rank: string, suit: string, faceUp = true): Card { - return { rank, suit, faceUp } as Card; -} - -// ── Scene-like discard/recall simulation ───────────────────── - -/** Simulate the scene's discard-to-pile logic from the click-to-play fix. */ -function simulateDiscardOnClick( - hand: Card[], - discardPile: Pile, - handView: HandView, - discardView: PileView, - selectedIdx: number, -): void { - if (selectedIdx >= 0 && selectedIdx < hand.length) { - // Discard selected card - const card = hand.splice(selectedIdx, 1)[0]; - card.faceUp = false; - discardPile.push(card); - handView.setCards(hand); - handView.setSelected(null); - discardView.update(); - } else { - // Recall from discard (existing behavior) - if (!discardPile.isEmpty()) { - const recalled = discardPile.pop()!; - recalled.faceUp = true; - hand.push(recalled); - handView.setCards(hand); - discardView.update(); - } - } -} - -// ── Tests ─────────────────────────────────────────────────── - -describe('GymHandPileScene click-to-play discard', () => { - let scene: ReturnType; - let handView: HandView; - let discardView: PileView; - let hand: Card[]; - let discardPile: Pile; - - const DISCARD_X = GAME_W - 160; - const DISCARD_Y = 250; - - beforeEach(() => { - scene = createMockScene(); - hand = []; - discardPile = new Pile(); - - handView = new HandView(scene, { - baseX: 320, - baseY: GAME_H - CARD_H - 80, - spacing: 20, - arcRadius: 150, - showLabels: false, - maxRotationDegrees: 25, - reducedMotion: false, - }); - - discardView = new PileView(scene, { x: DISCARD_X, y: DISCARD_Y, label: 'Discard' }); - discardView.setPile(discardPile); - - hand = [ - makeCard('A', 'spades'), - makeCard('K', 'hearts'), - makeCard('Q', 'clubs'), - ]; - handView.setCards(hand); - }); - - afterEach(() => { - vi.restoreAllMocks(); - handView.destroy(); - discardView.destroy(); - }); - - // ═══════════════════════════════════════════════════════════ - // Click behavior: card selected → discard to pile - // ═══════════════════════════════════════════════════════════ - - it('clicking discard pile with selected card discards the card', () => { - const selectedIdx = 1; // Select K of hearts - - simulateDiscardOnClick(hand, discardPile, handView, discardView, selectedIdx); - - // Card should be in discard pile - expect(discardPile.size()).toBe(1); - const discarded = discardPile.peek(); - expect(discarded?.rank).toBe('K'); - expect(discarded?.suit).toBe('hearts'); - expect(discarded?.faceUp).toBe(false); - - // Card should be removed from hand - expect(hand).toHaveLength(2); - expect(hand.find((c) => c.rank === 'K' && c.suit === 'hearts')).toBeUndefined(); - }); - - it('discarded card is added to the top of the discard pile', () => { - // Place a card in discard pile first - discardPile.push(makeCard('2', 'diamonds', false)); - - // Discard another card on top - const selectedIdx = 0; // A of spades - simulateDiscardOnClick(hand, discardPile, handView, discardView, selectedIdx); - - // Pile should have 2 cards - expect(discardPile.size()).toBe(2); - - // Top card should be A of spades (most recently discarded) - const top = discardPile.peek(); - expect(top?.rank).toBe('A'); - expect(top?.suit).toBe('spades'); - }); - - it('card is removed from hand when discarded via click', () => { - const selectedIdx = 2; // Q of clubs - simulateDiscardOnClick(hand, discardPile, handView, discardView, selectedIdx); - - expect(hand).toHaveLength(2); - expect(hand.find((c) => c.rank === 'Q' && c.suit === 'clubs')).toBeUndefined(); - // Remaining cards are A spades and K hearts - expect(hand[0].rank).toBe('A'); - expect(hand[1].rank).toBe('K'); - }); - - // ═══════════════════════════════════════════════════════════ - // Click behavior: no card selected → recall from discard - // ═══════════════════════════════════════════════════════════ - - it('clicking discard pile with no card selected recalls from discard (existing behavior)', () => { - // Add a card to discard pile - discardPile.push(makeCard('5', 'hearts', false)); - - const selectedIdx = -1; // No card selected - simulateDiscardOnClick(hand, discardPile, handView, discardView, selectedIdx); - - // Card should be recalled to hand - expect(hand).toHaveLength(4); - expect(hand.find((c) => c.rank === '5' && c.suit === 'hearts')).toBeDefined(); - expect(discardPile.isEmpty()).toBe(true); - }); - - it('clicking empty discard pile with no selection does nothing', () => { - const selectedIdx = -1; - simulateDiscardOnClick(hand, discardPile, handView, discardView, selectedIdx); - - expect(hand).toHaveLength(3); - expect(discardPile.isEmpty()).toBe(true); - }); - - // ═══════════════════════════════════════════════════════════ - // Visual indicator verification - // ═══════════════════════════════════════════════════════════ - - it('PileView is positioned at the discard pile location', () => { - // Verify the discard PileView was created at the right position - const sprite = discardView.getSprite(); - expect(sprite.x).toBe(DISCARD_X); - expect(sprite.y).toBe(DISCARD_Y); - }); - - it('discard PileView has interactive cursor for clicking', () => { - const sprite = discardView.getSprite(); - // Verify setInteractive was called on the sprite during PileView construction - // In the mock, setInteractive returns this, so we check it was called - expect(sprite.setInteractive).toHaveBeenCalled(); - }); - - it('discard PileView click triggers pointerdown event', () => { - let clicked = false; - discardView.onClick(() => { clicked = true; }); - - // Simulate clicking the PileView sprite (emit pointerdown) - const sprite = discardView.getSprite(); - const registerCalls = (sprite as any).on.mock.calls as Array<[string, (...args: any[]) => void]>; - const pointerdownEntry = registerCalls.find( - (call: [string, (...args: any[]) => void]) => call[0] === 'pointerdown', - ); - expect(pointerdownEntry).toBeDefined(); - - // Invoke the handler - if (pointerdownEntry) { - pointerdownEntry[1](); - } - expect(clicked).toBe(true); - }); - - // ═══════════════════════════════════════════════════════════ - // Source-level verification - // ═══════════════════════════════════════════════════════════ - - it('scene source contains discard zone highlight visual', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - // The scene should use HighlightManager or graphics for the discard zone - expect(source).toContain('highlightManager'); - }); - - it('scene source checks selectedIdx before calling recallFromDiscard', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - // The discardView.onClick handler should check selectedIdx - const discardClickSection = source.substring( - source.indexOf('discardView.onClick'), - source.indexOf('});', source.indexOf('discardView.onClick')), - ); - - // Should reference selectedIdx for conditional behavior - expect(discardClickSection).toMatch(/selectedIdx/); - }); - - it('scene source has a highlight that responds to selection state', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - // The scene should show/hide highlights based on selected state - const handClickSection = source.substring( - source.indexOf('handView.on'), - ); - // The card click handler should trigger some visual indicator - // when a card is selected - expect(handClickSection).toMatch(/selectedIdx/); - }); - - // ═══════════════════════════════════════════════════════════ - // Full scenario: select card → click discard → verify - // ═══════════════════════════════════════════════════════════ - - it('full click-to-play scenario: select card, click discard pile, card moves', () => { - // Step 1: Select card at index 0 (A of spades) - handView.setSelected(0); - expect(handView.getSelected()).toBe(0); - - // Step 2: Click discard pile (triggers discard) - simulateDiscardOnClick(hand, discardPile, handView, discardView, 0); - - // Step 3: Verify card moved from hand to discard pile - expect(hand).toHaveLength(2); - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('A'); - expect(discardPile.peek()?.suit).toBe('spades'); - - // Step 4: Verify selection cleared - expect(handView.getSelected()).toBeNull(); - }); - - it('multiple cards discarded one at a time via click', () => { - // Discard card 0 (A of spades) via click - simulateDiscardOnClick(hand, discardPile, handView, discardView, 0); - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('A'); - - // Discard card 0 again (now K of hearts since A was removed) - simulateDiscardOnClick(hand, discardPile, handView, discardView, 0); - expect(discardPile.size()).toBe(2); - expect(discardPile.peek()?.rank).toBe('K'); - - // Discard card 0 again (now Q of clubs) - simulateDiscardOnClick(hand, discardPile, handView, discardView, 0); - expect(discardPile.size()).toBe(3); - expect(discardPile.peek()?.rank).toBe('Q'); - - // Hand should be empty - expect(hand).toHaveLength(0); - }); -}); diff --git a/tests/gym/GymHandPileDiscardConsistency.test.ts b/tests/gym/GymHandPileDiscardConsistency.test.ts deleted file mode 100644 index 87b23e6c..00000000 --- a/tests/gym/GymHandPileDiscardConsistency.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * GymHandPileScene Discard Consistency Tests - * - * Validates that discardSelected() never orphans a card — the card - * must always be either in `this.hand` or `this.discardPile` at every - * point during the discard operation, even if the animation is - * interrupted or never fires its completion event. - * - * @module tests/gym/GymHandPileDiscardConsistency.test - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Pile } from '../../src/card-system/Pile'; -import { HandView } from '../../src/ui/HandView'; -import { PileView } from '../../src/ui/PileView'; -import { GameEventEmitter } from '../../src/core-engine'; -import { CARD_H, GAME_H } from '../../src/ui/constants'; -import type { Card } from '../../src/card-system/Card'; - -// ── Minimal Phaser mock ───────────────────────────────────── - -function createMockScene(): any { - const images: any[] = []; - const texts: any[] = []; - const destroyed: any[] = []; - const tweens: any[] = []; - - const mockImage = (x: number, y: number, texture: string) => { - const img = { - x, - y, - texture: { key: texture }, - active: true, - setInteractive: vi.fn().mockReturnThis(), - setTint: vi.fn().mockReturnThis(), - clearTint: vi.fn().mockReturnThis(), - setAlpha: vi.fn().mockReturnThis(), - setTexture: vi.fn().mockImplementation((tex: string) => { img.texture.key = tex; }), - setVisible: vi.fn().mockReturnThis(), - setOrigin: vi.fn().mockReturnThis(), - setPosition: vi.fn((px: number, py: number) => { img.x = px; img.y = py; }), - setRotation: vi.fn(), - on: vi.fn().mockReturnThis(), - off: vi.fn().mockReturnThis(), - destroy: vi.fn().mockImplementation(() => { - destroyed.push(img); - img.active = false; - }), - scaleX: 1, - scaleY: 1, - alpha: 1, - displayWidth: 48, - displayHeight: 65, - rotation: 0, - }; - images.push(img); - return img; - }; - - const mockText = (x: number, y: number, text: string, _style?: any) => { - const txt = { - x, - y, - text, - setOrigin: vi.fn().mockReturnThis(), - setColor: vi.fn().mockReturnThis(), - setText: vi.fn().mockImplementation((t: string) => { txt.text = t; }), - active: true, - destroy: vi.fn().mockImplementation(() => { - destroyed.push(txt); - txt.active = false; - }), - }; - texts.push(txt); - return txt; - }; - - return { - add: { - image: vi.fn().mockImplementation(mockImage), - text: vi.fn().mockImplementation(mockText), - graphics: vi.fn().mockReturnValue({ - fillStyle: vi.fn().mockReturnThis(), - fillRoundedRect: vi.fn().mockReturnThis(), - lineStyle: vi.fn().mockReturnThis(), - strokeRoundedRect: vi.fn().mockReturnThis(), - clear: vi.fn().mockReturnThis(), - destroy: vi.fn(), - }), - }, - tweens: { - add: vi.fn().mockImplementation((config: any) => { - tweens.push(config); - // Do NOT auto-fire onComplete so we can test interrupted animations - return { stop: vi.fn() }; - }), - }, - events: { - once: vi.fn(), - on: vi.fn(), - off: vi.fn(), - }, - time: { - delayedCall: vi.fn((_delay: number, fn: () => void) => { - // Fire delayed callbacks synchronously for test determinism - fn(); - return { remove: vi.fn() }; - }), - }, - sound: { - play: vi.fn(), - add: vi.fn(() => ({ play: vi.fn(), stop: vi.fn() })), - }, - input: { - on: vi.fn(), - off: vi.fn(), - }, - cameras: { - main: { setBackgroundColor: vi.fn() }, - }, - _images: images, - _texts: texts, - _destroyed: destroyed, - _tweens: tweens, - }; -} - -// ── Reusable test helpers ─────────────────────────────────── - -function makeCard(rank: string, suit: string, faceUp = true): Card { - return { rank, suit, faceUp } as Card; -} - -/** Simulates the scene's discardSelected logic with the fix applied. */ -function simulateDiscardSelected( - hand: Card[], - discardPile: Pile, - handView: HandView, - discardView: PileView, - selectedIdx: number, - reducedMotion: boolean, - skipAnimationComplete: boolean, -): void { - if (selectedIdx < 0 || selectedIdx >= hand.length) return; - - // Remove the card from hand model - const card = hand.splice(selectedIdx, 1)[0]; - - // FIX: Immediately update data model before any animation - card.faceUp = false; - discardPile.push(card); - - const sprite = handView.getSpriteAt(selectedIdx); - - if (sprite && !reducedMotion) { - const gameEvents = new GameEventEmitter(); - - gameEvents.on('card:discarded', () => { - // Data model is already consistent — only UI cleanup needed - handView.setCards(hand); - handView.setSelected(null); - discardView.update(); - }); - - // If skipAnimationComplete is true, we simulate an interrupted - // animation by calling discardCard without the animation completion - // callback actually running. In the fixed code, the card is already - // in discardPile before the animation starts, so it's not orphaned. - if (!skipAnimationComplete) { - // Simulate animation completion - gameEvents.emit('card:discarded', {}); - } - } else { - if (sprite) { - sprite.destroy(); - } - // Data model already updated — just UI cleanup - handView.setCards(hand); - handView.setSelected(null); - discardView.update(); - } -} - -// ── Tests ─────────────────────────────────────────────────── - -describe('GymHandPileScene discard consistency', () => { - let scene: ReturnType; - let handView: HandView; - let discardView: PileView; - let hand: Card[]; - let discardPile: Pile; - - beforeEach(() => { - scene = createMockScene(); - hand = []; - discardPile = new Pile(); - - // Create HandView - handView = new HandView(scene, { - baseX: 320, - baseY: GAME_H - CARD_H - 80, - spacing: 20, - arcRadius: 150, - showLabels: false, - maxRotationDegrees: 25, - reducedMotion: false, - }); - - discardView = new PileView(scene, { x: 640, y: 250, label: 'Discard' }); - discardView.setPile(discardPile); - - // Populate hand with test cards - hand = [ - makeCard('A', 'spades'), - makeCard('K', 'hearts'), - makeCard('Q', 'clubs'), - ]; - handView.setCards(hand); - }); - - afterEach(() => { - vi.restoreAllMocks(); - handView.destroy(); - discardView.destroy(); - }); - - // ═══════════════════════════════════════════════════════════ - // Core consistency: card is never orphaned - // ═══════════════════════════════════════════════════════════ - - it('card is in discardPile immediately after splice, before animation completes', () => { - const selectedIdx = 1; // Select K of hearts - - simulateDiscardSelected( - hand, discardPile, handView, discardView, - selectedIdx, false, false, - ); - - // After discardSelected returns, the card should be in discardPile - expect(discardPile.size()).toBe(1); - const discarded = discardPile.peek(); - expect(discarded?.rank).toBe('K'); - expect(discarded?.suit).toBe('hearts'); - expect(discarded?.faceUp).toBe(false); - - // Card should NOT be in hand anymore - expect(hand).toHaveLength(2); - expect(hand.find((c) => c.rank === 'K' && c.suit === 'hearts')).toBeUndefined(); - }); - - it('card is NOT orphaned when animation completion never fires', () => { - const selectedIdx = 1; - - // Simulate discard where animation completion does NOT fire - simulateDiscardSelected( - hand, discardPile, handView, discardView, - selectedIdx, false, true, // skipAnimationComplete = true - ); - - // Card must still be in discardPile (not orphaned) - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('K'); - expect(discardPile.peek()?.suit).toBe('hearts'); - }); - - it('card is either in hand or discardPile at all times during animated discard', () => { - const selectedIdx = 0; // Select A of spades - - // Step 1: Record which cards are in hand before - const handBefore = [...hand]; - expect(handBefore.find((c) => c.rank === 'A' && c.suit === 'spades')).toBeDefined(); - expect(discardPile.size()).toBe(0); - - // Step 2: Simulate the fixed discard logic — splice + push to discard - const removed = hand.splice(selectedIdx, 1)[0]; - removed.faceUp = false; - discardPile.push(removed); - - // At this point (after data model update, before animation), card is in discardPile - expect(hand.find((c) => c.rank === 'A' && c.suit === 'spades')).toBeUndefined(); - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('A'); - - // Step 3: Even if we do nothing more (animation never completes), - // the card is safely in discardPile — not orphaned! - const allCards = [...hand]; - for (let i = 0; i < discardPile.size(); i++) { - allCards.push(discardPile.toArray()[i]); - } - const isCardPresent = allCards.some( - (c) => c.rank === 'A' && c.suit === 'spades', - ); - expect(isCardPresent).toBe(true); - }); - - // ═══════════════════════════════════════════════════════════ - // Normal animated discard still works - // ═══════════════════════════════════════════════════════════ - - it('normal animated discard still works with visual effect', () => { - const selectedIdx = 0; // Select A of spades - - // Full animation path - simulateDiscardSelected( - hand, discardPile, handView, discardView, - selectedIdx, false, false, - ); - - // Card should be in discard pile - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('A'); - - // Hand should have 2 cards left - expect(hand).toHaveLength(2); - - // HandView should reflect hand state - expect(handView.getCards()).toHaveLength(2); - }); - - it('reduced-motion discard still works', () => { - const selectedIdx = 2; // Select Q of clubs - - simulateDiscardSelected( - hand, discardPile, handView, discardView, - selectedIdx, true, false, - ); - - // Card should be in discard pile - expect(discardPile.size()).toBe(1); - expect(discardPile.peek()?.rank).toBe('Q'); - - // Hand should have 2 cards left - expect(hand).toHaveLength(2); - }); - - it('discard with invalid selection does nothing', () => { - simulateDiscardSelected( - hand, discardPile, handView, discardView, - -1, false, false, - ); - - // Nothing should change - expect(hand).toHaveLength(3); - expect(discardPile.size()).toBe(0); - }); - - it('discard with out-of-range index does nothing', () => { - simulateDiscardSelected( - hand, discardPile, handView, discardView, - 99, false, false, - ); - - expect(hand).toHaveLength(3); - expect(discardPile.size()).toBe(0); - }); - - // ═══════════════════════════════════════════════════════════ - // Sequential discards - // ═══════════════════════════════════════════════════════════ - - it('sequential discards all land in discardPile', () => { - // Discard all 3 cards one by one - simulateDiscardSelected(hand, discardPile, handView, discardView, 0, false, true); - simulateDiscardSelected(hand, discardPile, handView, discardView, 0, false, true); - simulateDiscardSelected(hand, discardPile, handView, discardView, 0, false, true); - - expect(hand).toHaveLength(0); - expect(discardPile.size()).toBe(3); - }); - - // ═══════════════════════════════════════════════════════════ - // Source-level verification - // ═══════════════════════════════════════════════════════════ - - it('scene source pushes to discardPile before animation starts', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - // In the discardSelected method, push should come before discardCard - // Find the relevant section — note: method is 'private discardSelected' - const discardStart = source.indexOf('private discardSelected'); - // The next method after discardSelected is the async recallFromDiscard - const recallStart = source.indexOf('private async recallFromDiscard'); - - expect(discardStart).toBeGreaterThan(0); - expect(recallStart).toBeGreaterThan(discardStart); - - const discardSelectedSection = source.substring(discardStart, recallStart); - - const sectionPushPos = discardSelectedSection.indexOf('this.discardPile.push('); - const sectionDiscardCardPos = discardSelectedSection.indexOf('discardCard({'); - - expect(sectionPushPos).toBeGreaterThan(0); - expect(sectionDiscardCardPos).toBeGreaterThan(0); - expect(sectionPushPos).toBeLessThan(sectionDiscardCardPos); - }); -}); diff --git a/tests/gym/GymHandPileLayoutToggle.test.ts b/tests/gym/GymHandPileLayoutToggle.test.ts deleted file mode 100644 index 3cbc9c7c..00000000 --- a/tests/gym/GymHandPileLayoutToggle.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * GymHandPileScene Layout Toggle Tests - * - * Verifies that toggling between horizontal and vertical (cascade) layout - * keeps the hand anchored at a consistent central position. - * - * Acceptance criteria: - * - Toggling to vertical layout anchors the hand at the same central X - * position as the horizontal layout. - * - Toggling back to horizontal returns to the centered position. - */ - -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('GymHandPileScene layout toggle position consistency', () => { - const sourcePath = path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'); - const source = fs.readFileSync(sourcePath, 'utf-8'); - - it('CASCADE_X is set to a centered position (GAME_W / 2)', () => { - // CASCADE_X should be centered on screen, matching the horizontal layout's - // HAND_CENTER_X value (= GAME_W / 2 = 640). This ensures the vertical - // cascade is positioned at the same horizontal centre. - const match = source.match(/private\s+readonly\s+CASCADE_X\s*=\s*(GAME_W\s*\/\s*2)/); - expect(match).not.toBeNull(); - }); - - it('vertical layout uses setBaseX with CASCADE_X', () => { - expect(source).toContain('setBaseX(this.CASCADE_X)'); - }); - - it('horizontal layout uses setCenterX with HAND_CENTER_X on restore', () => { - expect(source).toContain('setCenterX(this.HAND_CENTER_X)'); - }); - - it('vertical layout uses setLayoutDirection with vertical', () => { - const match = source.match(/setLayoutDirection\(\s*'vertical'\s*\)/); - expect(match).not.toBeNull(); - }); - - it('horizontal layout uses setLayoutDirection with horizontal', () => { - const match = source.match(/setLayoutDirection\(\s*'horizontal'\s*\)/); - expect(match).not.toBeNull(); - }); - - it('HAND_CENTER_X is set to GAME_W / 2', () => { - const match = source.match(/private\s+readonly\s+HAND_CENTER_X\s*=\s*GAME_W\s*\/\s*2/); - expect(match).not.toBeNull(); - }); - - it('vertical layout syncs spacing slider to CASCADE_SPACING', () => { - expect(source).toContain('spacingSlider.setValue(this.CASCADE_SPACING)'); - }); -}); diff --git a/tests/gym/GymHandPileRotation.test.ts b/tests/gym/GymHandPileRotation.test.ts deleted file mode 100644 index 5b6e5797..00000000 --- a/tests/gym/GymHandPileRotation.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('GymHandPileScene rotation slider presence', () => { - it('scene source contains rotationSlider and setMaxRotationDegrees usage', () => { - const source = fs.readFileSync(path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), 'utf-8'); - expect(source).toContain('rotationSlider'); - expect(source).toContain('setMaxRotationDegrees('); - }); - - it('HandView exposes setMaxRotationDegrees and applies rotation to sprites', () => { - const hv = fs.readFileSync(path.resolve(__dirname, '../../src/ui/HandView.ts'), 'utf-8'); - expect(hv).toContain('setMaxRotationDegrees'); - // Check that rotation is applied to sprites (radians assignment) - expect(hv).toMatch(/\.rotation\s*=|\.rotation\s*=/); - }); -}); diff --git a/tests/gym/GymHandPileShutdown.test.ts b/tests/gym/GymHandPileShutdown.test.ts deleted file mode 100644 index f31beb12..00000000 --- a/tests/gym/GymHandPileShutdown.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * GymHandPileScene shutdown lifecycle tests. - * - * Verifies that GymHandPileScene properly cleans up its created objects - * when the scene shuts down. - * - * Source-level tests verify the presence of the shutdown method and its - * cleanup logic, matching the pattern in GymHandPileSpacing.test.ts. - * - * @module tests/gym/GymHandPileShutdown - */ - -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -const SOURCE_FILE = path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'); - -/** - * Load the source file once for all tests. - */ -function loadSource(): string { - return fs.readFileSync(SOURCE_FILE, 'utf-8'); -} - -describe('GymHandPileScene shutdown lifecycle', () => { - describe('shutdown method presence', () => { - it('declares a shutdown() method', () => { - const src = loadSource(); - // The shutdown() method should be defined in the class body (private or public) - expect(src).toMatch(/shutdown\s*\(\s*\)\s*:\s*void/); - }); - - it('registers a shutdown event listener in create()', () => { - const src = loadSource(); - // Must register a shutdown event listener (matching Phaser 4 lifecycle pattern) - // that calls the scene's shutdown method - expect(src).toMatch(/this\.events\.on\s*\(\s*['"]shutdown['"]/); - expect(src).toMatch(/this\.shutdown\b/); - }); - }); - - describe('cleanup of individual objects', () => { - it('destroys highlightManager if it exists', () => { - const src = loadSource(); - // Must destroy highlightManager with null/guard check - expect(src).toContain('highlightManager'); - expect(src).toContain('.destroy()'); - }); - - it('stops activeMoveTween if active', () => { - const src = loadSource(); - // Must stop or cleanup the active move tween - expect(src).toContain('activeMoveTween'); - }); - - it('destroys slider components', () => { - const src = loadSource(); - // Each slider must have its destroy() called in the shutdown method - expect(src).toContain('.destroy()'); - }); - - it('destroys HandView and PileView components', () => { - const src = loadSource(); - // UI components should be destroyed or nulled - expect(src).toContain('handView'); - expect(src).toContain('deckView'); - expect(src).toContain('discardView'); - }); - - it('cleans up logTexts array', () => { - const src = loadSource(); - // Log text objects should be destroyed and the array cleared - expect(src).toContain('logTexts'); - }); - }); - - describe('event listener setup', () => { - it('registers a shutdown handler that invokes this.shutdown()', () => { - const src = loadSource(); - // Verify the shutdown handler invokes the shutdown method - const shutdownRegistration = src.match( - /this\.events\.on\s*\(\s*['"]shutdown['"][^)]*\)/g - ); - if (shutdownRegistration) { - const hasShutdownCall = shutdownRegistration.some(r => - r.includes('this.shutdown') - ); - expect(hasShutdownCall).toBe(true); - } - }); - }); - - describe('cleanup completeness', () => { - it('destroys layoutLabel, dragLabel, and dragButton if they exist', () => { - const src = loadSource(); - expect(src).toContain('layoutLabel'); - expect(src).toContain('dragLabel'); - expect(src).toContain('dragButton'); - }); - }); -}); - -describe('GymHandPileScene integration with GymSceneBase cleanup', () => { - it('does not remove GymSceneBase import', () => { - const src = loadSource(); - expect(src).toContain("import { GymSceneBase } from './GymSceneBase'"); - }); - - it('still calls initHelp if present', () => { - const src = loadSource(); - expect(src).toContain('initHelp('); - }); -}); diff --git a/tests/gym/GymHandPileSpacing.test.ts b/tests/gym/GymHandPileSpacing.test.ts deleted file mode 100644 index 6d8b343b..00000000 --- a/tests/gym/GymHandPileSpacing.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; - -describe('GymHandPileScene spacing slider presence', () => { - it('source contains createSlider for spacing and setSpacing usage', () => { - const source = fs.readFileSync(path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), 'utf-8'); - expect(source).toContain('spacingSlider'); - expect(source).toContain('setSpacing('); - expect(source).toContain('CARD_W'); - }); -}); diff --git a/tests/gym/GymReducedMotion.test.ts b/tests/gym/GymReducedMotion.test.ts deleted file mode 100644 index b63106c6..00000000 --- a/tests/gym/GymReducedMotion.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Tests for Gym reduced-motion global integration. - * - * Validates that SettingsStore reduced-motion helpers work correctly, - * and that the preference can be toggled programmatically for headless tests. - */ -import { describe, expect, it, beforeEach } from 'vitest'; -import { getReducedMotion, setReducedMotion } from '../../src/ui/SettingsStore'; - -// ── Mock storage for SettingsStore tests ────────────────── - -function createMockStorage(): Storage { - const data = new Map(); - return { - getItem: (key: string) => data.get(key) ?? null, - setItem: (key: string, value: string) => { data.set(key, value); }, - removeItem: (key: string) => { data.delete(key); }, - clear: () => data.clear(), - get length() { return data.size; }, - key: (index: number) => [...data.keys()][index] ?? null, - }; -} - -describe('Gym reduced-motion: SettingsStore integration', () => { - let mockStorage: Storage; - - beforeEach(() => { - mockStorage = createMockStorage(); - }); - - it('getReducedMotion returns false when not set', () => { - expect(getReducedMotion(mockStorage)).toBe(false); - }); - - it('getReducedMotion returns true when explicitly set', () => { - setReducedMotion(true, mockStorage); - expect(getReducedMotion(mockStorage)).toBe(true); - }); - - it('getReducedMotion returns false after being set to false', () => { - setReducedMotion(true, mockStorage); - expect(getReducedMotion(mockStorage)).toBe(true); - - setReducedMotion(false, mockStorage); - expect(getReducedMotion(mockStorage)).toBe(false); - }); - - it('setReducedMotion persists value to storage', () => { - setReducedMotion(true, mockStorage); - expect(mockStorage.getItem('tce-ui-reduced-motion')).toBe('true'); - - setReducedMotion(false, mockStorage); - expect(mockStorage.getItem('tce-ui-reduced-motion')).toBe('false'); - }); - - it('getReducedMotion handles storage unavailable gracefully', () => { - expect(getReducedMotion(null)).toBe(false); - }); - - it('setReducedMotion handles storage unavailable gracefully', () => { - // Should not throw - setReducedMotion(true, null); - }); -}); - -describe('Gym reduced-motion: GymSceneBase property API', () => { - it('GymSceneBase class exposes setReducedMotionProperty and initReducedMotion methods', () => { - // Verify the class structure without importing Phaser. - // We simply check that the source file exports the expected API surface. - // The actual scene functionality is validated in browser tests. - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify reduced-motion property and methods exist in the source - expect(source).toContain('reducedMotion'); - expect(source).toContain('setReducedMotionProperty'); - expect(source).toContain('initReducedMotion'); - expect(source).toContain('toggleReducedMotion'); - expect(source).toContain('getReducedMotion'); - }); - - it('All Gym scenes call initReducedMotion in their create methods', () => { - const fs = require('fs'); - const path = require('path'); - const scenesDir = path.resolve(__dirname, '../../example-games/gym/scenes'); - - const sceneFiles = [ - 'GymDeckRngScene.ts', - 'GymHandPileScene.ts', - 'GymOverlayUiScene.ts', - 'GymUndoRedoScene.ts', - 'GymTranscriptScene.ts', - 'GymSaveLoadScene.ts', - 'GymAudioFeedbackScene.ts', - 'GymGraphicsShaderSpikeScene.ts', - 'GymGraphicsLightingSpikeScene.ts', - ]; - - for (const file of sceneFiles) { - const source = fs.readFileSync(path.join(scenesDir, file), 'utf-8'); - expect(source).toContain('initReducedMotion'); - } - }); - - it('GymSceneBase consults stored preference and DOM media query', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify the combined logic: SettingsStore + DOM media query - expect(source).toContain('getReducedMotion'); - expect(source).toContain('prefers-reduced-motion'); - }); -}); \ No newline at end of file diff --git a/tests/gym/GymSaveLoadScreenshotFilter.test.ts b/tests/gym/GymSaveLoadScreenshotFilter.test.ts deleted file mode 100644 index e32063ef..00000000 --- a/tests/gym/GymSaveLoadScreenshotFilter.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Gym Save/Load — Screenshot HUD filtering tests. - * - * Validates that the takeScreenshot() HUD exclusion logic correctly - * filters out HUD overlay elements (Help panel, header chrome, event log) - * while keeping game content (cards, action buttons, state text) visible - * in the screenshot RenderTexture. - * - * The filter uses a Set-based blacklist of known HUD object references. - * These tests validate the Set-based filtering approach in isolation - * (without requiring Phaser's RenderTexture). - * - * @module tests/gym/GymSaveLoadScreenshotFilter - */ - -import { describe, expect, it } from 'vitest'; - -describe('Screenshot HUD filtering logic', () => { - it('excludes known HUD references while keeping game content', () => { - // Simulate scene children: mix of HUD objects and game content - const rt = { name: 'RenderTexture' }; - const helpPanel = { name: 'HelpPanel' }; - const helpButton = { name: 'HelpButton' }; - const headerTitle = { name: 'HeaderTitle' }; - const menuButton = { name: 'MenuButton' }; - const prevButton = { name: 'PrevButton' }; - const nextButton = { name: 'NextButton' }; - const headerDivider = { name: 'HeaderDivider' }; - const eventLogHeader = { name: 'EventLogHeader' }; - const eventLogLine1 = { name: 'EventLogLine1' }; - const eventLogLine2 = { name: 'EventLogLine2' }; - - // Game content that should remain visible - const handViewCards = { name: 'HandViewCards' }; - const stateText = { name: 'StateText' }; - const backendText = { name: 'BackendText' }; - const actionButton = { name: 'ActionButton' }; - const screenshotThumb = { name: 'ScreenshotThumb' }; - - const children = [ - rt, - helpPanel, - helpButton, - headerTitle, - menuButton, - prevButton, - nextButton, - headerDivider, - eventLogHeader, - eventLogLine1, - eventLogLine2, - handViewCards, - stateText, - backendText, - actionButton, - screenshotThumb, - ]; - - // The exclusion set — mirrors the logic in takeScreenshot() - const excluded = new Set([ - rt, - helpPanel, - helpButton, - headerTitle, - menuButton, - prevButton, - nextButton, - headerDivider, - eventLogHeader, - eventLogLine1, - eventLogLine2, - ]); - - const drawables = children.filter((child) => !excluded.has(child)); - - // All HUD elements MUST be excluded - expect(drawables).not.toContain(helpPanel); - expect(drawables).not.toContain(helpButton); - expect(drawables).not.toContain(headerTitle); - expect(drawables).not.toContain(menuButton); - expect(drawables).not.toContain(prevButton); - expect(drawables).not.toContain(nextButton); - expect(drawables).not.toContain(headerDivider); - expect(drawables).not.toContain(eventLogHeader); - expect(drawables).not.toContain(eventLogLine1); - expect(drawables).not.toContain(eventLogLine2); - - // rt itself MUST be excluded - expect(drawables).not.toContain(rt); - - // Game content MUST remain - expect(drawables).toContain(handViewCards); - expect(drawables).toContain(stateText); - expect(drawables).toContain(backendText); - expect(drawables).toContain(actionButton); - expect(drawables).toContain(screenshotThumb); - }); - - it('handles undefined HUD references without error', () => { - // When HUD elements are not initialized (e.g., during headless tests), - // undefined in the exclusion Set should not cause errors - const rt = { name: 'RenderTexture' }; - const stateText = { name: 'StateText' }; - const actionButton = { name: 'ActionButton' }; - - // Some HUD references are undefined (not yet initialized) - const excluded = new Set([ - rt, - undefined, // helpPanel not initialized - undefined, // helpButton not initialized - undefined, // header?.title not initialized - ]); - - const children = [rt, stateText, actionButton]; - const drawables = children.filter((child) => !excluded.has(child)); - - // rt should still be excluded - expect(drawables).not.toContain(rt); - - // Game content should still be included - expect(drawables).toContain(stateText); - expect(drawables).toContain(actionButton); - }); - - it('excludes all event log lines when present', () => { - const rt = { name: 'RenderTexture' }; - const eventLogHeader = { name: 'EventLogHeader' }; - const eventLogLine1 = { name: 'EventLogLine1' }; - const eventLogLine2 = { name: 'EventLogLine2' }; - const eventLogLine3 = { name: 'EventLogLine3' }; - - // Simulate eventLogResult with header and multiple lines - const eventLogResult = { - header: eventLogHeader, - lines: [eventLogLine1, eventLogLine2, eventLogLine3], - }; - - const excluded = new Set([ - rt, - eventLogResult.header, - ...eventLogResult.lines, - ]); - - const children = [ - rt, - eventLogHeader, - eventLogLine1, - eventLogLine2, - eventLogLine3, - { name: 'GameContent' }, - ]; - - const drawables = children.filter((child) => !excluded.has(child)); - - expect(drawables).not.toContain(eventLogHeader); - expect(drawables).not.toContain(eventLogLine1); - expect(drawables).not.toContain(eventLogLine2); - expect(drawables).not.toContain(eventLogLine3); - expect(drawables).not.toContain(rt); - expect(drawables).toHaveLength(1); // Only GameContent remains - expect((drawables[0] as { name: string }).name).toBe('GameContent'); - }); - - it('empty event log lines array does not affect filtering', () => { - const rt = { name: 'RenderTexture' }; - const stateText = { name: 'StateText' }; - - // eventLogResult exists but lines array is empty - // (e.g., before any events have been logged) - const eventLogResult = { - header: { name: 'EventLogHeader' }, - lines: [], - }; - - const excluded = new Set([ - rt, - eventLogResult.header, - ...eventLogResult.lines, // spread is empty, so no effect - ]); - - const children = [rt, eventLogResult.header, stateText]; - const drawables = children.filter((child) => !excluded.has(child)); - - expect(drawables).not.toContain(rt); - expect(drawables).not.toContain(eventLogResult.header); - expect(drawables).toContain(stateText); - expect(drawables).toHaveLength(1); - }); -}); diff --git a/tests/gym/GymSceneBaseButtonBar.test.ts b/tests/gym/GymSceneBaseButtonBar.test.ts deleted file mode 100644 index 2ef34dbe..00000000 --- a/tests/gym/GymSceneBaseButtonBar.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * GymSceneBase Button Bar Integration Tests - * - * Verifies that GymSceneBase correctly integrates with GymButtonBar - * via the initButtonBar() and get buttonBar accessor. - */ -import { describe, expect, it } from 'vitest'; - -describe('GymSceneBase button bar integration', () => { - it('GymSceneBase imports GymButtonBar', () => { - // Verify the source file imports GymButtonBar correctly - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - expect(source).toContain('GymButtonBar'); - expect(source).toContain('GymButtonBarConfig'); - expect(source).toContain("from '../../../src/ui/GymButtonBar'"); - }); - - it('GymSceneBase has buttonBar property and initButtonBar method', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - expect(source).toContain('protected buttonBar?: GymButtonBar'); - expect(source).toContain('protected initButtonBar'); - expect(source).toContain('new GymButtonBar(this,'); - expect(source).toContain('return this.buttonBar'); - }); - - it('initButtonBar creates a GymButtonBar at given Y position', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify the method signature accepts y and optional opts - const methodMatch = source.match(/protected initButtonBar\(y: number, opts\?: Partial\)/); - expect(methodMatch).not.toBeNull(); - }); - - it('initButtonBar destroys existing bar before creating new one', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify cleanup of existing bar - expect(source).toContain('this.buttonBar.destroy()'); - }); - - it('legacy addButton method has been removed after migration', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify addButton no longer exists (migration complete) - expect(source).not.toContain('protected addButton('); - }); - - it('legacy addButtonAtAnchor method has been removed after migration', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify addButtonAtAnchor no longer exists (migration complete) - expect(source).not.toContain('protected addButtonAtAnchor('); - }); - - it('button bar integration section is placed after divider and before scene transition', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymSceneBase.ts'), - 'utf-8', - ); - - // Verify the button bar section exists between divider and transition hook - const sectionMarker = '// ── Button bar integration'; - expect(source).toContain(sectionMarker); - }); - - it('GymButtonBar class is importable from the UI barrel', () => { - const fs = require('fs'); - const path = require('path'); - const uiIndex = fs.readFileSync( - path.resolve(__dirname, '../../src/ui/index.ts'), - 'utf-8', - ); - - expect(uiIndex).toContain('GymButtonBar'); - expect(uiIndex).toContain("export { GymButtonBar } from './GymButtonBar'"); - expect(uiIndex).toContain("export type { ButtonZone, GymButtonOpts, GymButtonBarConfig } from './GymButtonBar'"); - }); -}); diff --git a/tests/gym/GymTooltipScene.browser.test.ts b/tests/gym/GymTooltipScene.browser.test.ts deleted file mode 100644 index c4f64dce..00000000 --- a/tests/gym/GymTooltipScene.browser.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, it } from 'vitest'; -import Phaser from 'phaser'; -import { GymTooltipScene } from '../../example-games/gym/scenes/GymTooltipScene'; - -describe('GymTooltipScene browser integration', () => { - let game: Phaser.Game | null = null; - - afterEach(() => { - if (game) { - game.destroy(true, false); - } - game = null; - - const container = document.getElementById('game-container'); - if (container) { - container.remove(); - } - }); - - it('boots and creates both tooltip managers', async () => { - const container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - await new Promise((resolve, reject) => { - game = new Phaser.Game({ type: Phaser.CANVAS, - width: 800, - height: 600, - parent: 'game-container', - scene: [GymTooltipScene], - }); - - game.events.once('ready', () => { - // Scene should have booted; we can't access private fields, - // but if we got here without errors the scene loaded fine. - resolve(); - }); - - // Timeout safety - setTimeout(() => reject(new Error('Scene did not boot within 5s')), 5000); - }); - }, 10000); - - it('switches between DOM and Phaser modes without error', async () => { - const container = document.createElement('div'); - container.id = 'game-container'; - document.body.appendChild(container); - - await new Promise((resolve, reject) => { - class TestWrapper extends Phaser.Scene { - constructor() { - super('TestWrapper'); - } - - create() { - // This test validates that both modes can be toggled - // by pressing the mode buttons (simulated via pointer events). - // If no exception is thrown, the test passes. - resolve(); - } - } - - game = new Phaser.Game({ type: Phaser.CANVAS, - width: 200, - height: 200, - parent: 'game-container', - scene: [TestWrapper], - }); - - setTimeout(() => reject(new Error('Timeout')), 5000); - }); - }, 10000); -}); diff --git a/tests/gym/handPileScene.animation.test.ts b/tests/gym/handPileScene.animation.test.ts index e5025da6..62f27b2f 100644 --- a/tests/gym/handPileScene.animation.test.ts +++ b/tests/gym/handPileScene.animation.test.ts @@ -431,42 +431,3 @@ describe('GymHandPileScene animation integration', () => { }); }); -// ═════════════════════════════════════════════════════════════ -// Source-level verification -// ═════════════════════════════════════════════════════════════ - -describe('GymHandPileScene source migration', () => { - it('scene source no longer imports dealCard directly', () => { - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../example-games/gym/scenes/GymHandPileScene.ts'), - 'utf-8', - ); - - // Should still use animateAddCard (via HandView) - expect(source).toContain('animateAddCard'); - - // Should NOT import dealCard directly - expect(source).not.toContain("from '../../../src/ui/dealCard'"); - - // Should NOT have getHandPositionForIndex with full layout logic - expect(source).not.toContain('getHandPositionForIndex(index: number, handCount: number)'); - }); - - it('HandView public API remains backwards compatible', () => { - // These should all still work - const fs = require('fs'); - const path = require('path'); - const source = fs.readFileSync( - path.resolve(__dirname, '../../src/ui/HandView.ts'), - 'utf-8', - ); - - expect(source).toContain('addCard'); - expect(source).toContain('removeCard'); - expect(source).toContain('setCards'); - expect(source).toContain('animateAddCard'); - expect(source).toContain('getCardCenters'); - }); -}); diff --git a/tests/handView/gym-handpile-drag.browser.test.ts b/tests/handView/gym-handpile-drag.browser.test.ts index cab4eaeb..b7284482 100644 --- a/tests/handView/gym-handpile-drag.browser.test.ts +++ b/tests/handView/gym-handpile-drag.browser.test.ts @@ -139,7 +139,15 @@ describe('GymHandPileScene drag-and-drop', () => { const discardPile = scene.discardPile as any; const initialDiscardSize = discardPile.size(); - // ── Enable drag mode ────────────────────────────────── + // ── Drag mode is ON by default ───────────────────── + // Verify the default state, then toggle off/on to exercise the button. + expect(handView.getDragEnabled()).toBe(true); + const disableBtn = findButtonByText(scene, 'Disable Drag'); + expect(disableBtn).toBeTruthy(); + clickButton(disableBtn!); + await wait(100); + expect(handView.getDragEnabled()).toBe(false); + const enableBtn = findButtonByText(scene, 'Enable Drag'); expect(enableBtn).toBeTruthy(); clickButton(enableBtn!); @@ -188,7 +196,12 @@ describe('GymHandPileScene drag-and-drop', () => { const handView = getHandView(scene); const initialHandSize = handView.getCards().length; - // Enable drag + // Drag mode is ON by default — toggle off then back on + const disableBtn = findButtonByText(scene, 'Disable Drag'); + expect(disableBtn).toBeTruthy(); + clickButton(disableBtn!); + await wait(100); + const enableBtn = findButtonByText(scene, 'Enable Drag'); expect(enableBtn).toBeTruthy(); clickButton(enableBtn!); diff --git a/tests/helpers/main-street-tutorial-e2e.ts b/tests/helpers/main-street-tutorial-e2e.ts index 0c7e367a..f3559167 100644 --- a/tests/helpers/main-street-tutorial-e2e.ts +++ b/tests/helpers/main-street-tutorial-e2e.ts @@ -13,7 +13,7 @@ import Phaser from 'phaser'; import { page } from '@vitest/browser/context'; import { waitForScene } from './waitForScene'; -import { advanceTutorialStep, getCurrentStep } from '../../example-games/main-street/TutorialFlow'; +import { advanceTutorialStep, getCurrentStep, UNIFIED_TUTORIAL_STEPS } from '../../example-games/main-street/TutorialFlow'; // ── Constants ──────────────────────────────────────────── @@ -344,6 +344,7 @@ export function clickRequiredBusinessCard(scene: Phaser.Scene): void { if (s.uiPhase !== 'market') { s.uiPhase = 'market'; } try { s.onBusinessCardClick(cardToClick); } catch (_) { /* ignore */ } maybeAdvanceTutorial(scene, 2); + maybeAdvanceTutorial(scene, 7); if (s.tutorialController?.currentStepIndex === 6) { maybeAdvanceTutorial(scene, 6); } @@ -380,10 +381,60 @@ export function clickRequiredEventCard(scene: Phaser.Scene): void { /** * Click a street slot to place the pending business card. + * In the new buy-to-hand flow, the card is in state.hand and + * pendingHandIndex is used instead of pendingBusinessCard. + * + * If the async buy-to-hand animation has not completed yet, the + * purchase is executed synchronously via state manipulation to + * ensure the card is in hand for placement. */ export function clickStreetSlot(scene: Phaser.Scene, slotIdx: number): void { const s = scene as any; - if (s.pendingBusinessCard === null) { + const hand = s.state?.hand ?? []; + + // New flow: if cards exist in hand, use pendingHandIndex + if (s.pendingHandIndex === null && hand.length > 0) { + s.pendingHandIndex = 0; + } + + // If async buy-to-hand hasn't completed, execute it synchronously + if (s.pendingHandIndex === null && hand.length === 0 && s.tutorialController?.isActive) { + const step = getCurrentStep(s.tutorialController); + if (step?.requiredAction === 'select-business' || step?.requiredAction === 'place-business') { + // Execute purchase synchronously so the card is in hand for placement + const devCards = s.state?.market?.development; + if (devCards && devCards.length > 0) { + let cardToBuy = devCards[0]; + if (step?.requiredCardId) { + // Current step has a specific requiredCardId + const found = devCards.find((c: any) => matchesCardId(c.id, step.requiredCardId!)); + if (found) cardToBuy = found; + } else if (step?.requiredAction === 'place-business') { + // place-business steps don't have requiredCardId. Find the card that + // was specified by the preceding select-business step (e.g., T8→T9). + const myIdx = UNIFIED_TUTORIAL_STEPS.findIndex(s => s.id === step.id); + for (let i = myIdx - 1; i >= 0; i--) { + const prev = UNIFIED_TUTORIAL_STEPS[i]; + if (prev.requiredAction === 'select-business' && prev.requiredCardId) { + const found = devCards.find((c: any) => matchesCardId(c.id, prev.requiredCardId!)); + if (found) { cardToBuy = found; break; } + } + } + } + const cardIdx = devCards.findIndex((c: any) => c.id === cardToBuy.id); + if (cardIdx >= 0) { + // Deduct coins and add to hand + s.state.resourceBank.coins -= cardToBuy.cost; + s.state.hand.push({ ...devCards[cardIdx] }); + devCards.splice(cardIdx, 1); + s.pendingHandIndex = s.state.hand.length - 1; + } + } + } + } + + // Legacy flow: set pendingBusinessCard if hand is empty + if (s.pendingHandIndex === null && s.pendingBusinessCard === null) { const controller = s.tutorialController; const devCards = s.state?.market?.development; if (devCards && controller?.isActive) { @@ -401,11 +452,29 @@ export function clickStreetSlot(scene: Phaser.Scene, slotIdx: number): void { s.pendingBusinessCard = devCards[0]; } } - if (s.uiPhase !== 'market') { s.uiPhase = 'market'; } + + // Set the correct UI phase: 'placing-from-hand' for the new flow, 'placing-business' for legacy + if (s.pendingHandIndex !== null) { + s.uiPhase = 'placing-from-hand'; + } else { + s.uiPhase = 'placing-business'; + } try { s.onSlotClick(slotIdx); } catch (_) { /* ignore */ } - maybeAdvanceTutorial(scene, 3); + + // Fallback: if still on a place-business step after the attempt, force-advance. + // This handles both T4 (place Laundromat) and T9 (place Bookshop). + if (s.tutorialController?.isActive) { + const curStep = getCurrentStep(s.tutorialController); + if (curStep?.requiredAction === 'place-business') { + maybeAdvanceTutorial(scene, s.tutorialController.currentStepIndex); + } + } } +/** + * End the current turn and advance the tutorial. + */ + /** * End the current turn and advance the tutorial. */ diff --git a/tests/lost-cities/LostCitiesTurnController.reducedMotion.test.ts b/tests/lost-cities/LostCitiesTurnController.reducedMotion.test.ts deleted file mode 100644 index 7f8eba8b..00000000 --- a/tests/lost-cities/LostCitiesTurnController.reducedMotion.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Tests for LostCitiesTurnController reduced motion AI delay. - * - * Verifies the constant values and source code changes for - * reduced motion AI delay in LostCitiesTurnController. - * - * @module tests/lost-cities/LostCitiesTurnController.reducedMotion - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('LostCitiesTurnController reduced motion', () => { - it('has a reducedMotion property that defaults to false', () => { - const source = readFileSync( - 'example-games/lost-cities/scenes/LostCitiesTurnController.ts', - 'utf-8', - ); - expect(source).toContain('reducedMotion'); - }); - - it('adds ANIM_DURATION extra delay when reducedMotion is true', () => { - // Verify the logic: the AI_DELAY is increased by ANIM_DURATION - // when reducedMotion is enabled, compensating for skipped animations. - const source = readFileSync( - 'example-games/lost-cities/scenes/LostCitiesTurnController.ts', - 'utf-8', - ); - expect(source).toContain('this.reducedMotion ? AI_DELAY + ANIM_DURATION : AI_DELAY'); - }); -}); diff --git a/tests/lost-cities/lost-cities-ai.test.ts b/tests/lost-cities/lost-cities-ai.test.ts index c0583d43..353120e2 100644 --- a/tests/lost-cities/lost-cities-ai.test.ts +++ b/tests/lost-cities/lost-cities-ai.test.ts @@ -393,6 +393,49 @@ describe('LostCitiesAiPlayer', () => { // Either color is fine — the key test is that yellow isn't // penalized more than blue (no draw history after reset) }); + + // ── CardMemoryTracker integration ──────────────────────── + + it('should have a memory tracker with maxCopies=12 (5 colors × 12 cards)', () => { + const ai = new LostCitiesAiPlayer(); + expect(ai.memoryTracker).toBeDefined(); + expect(ai.memoryTracker.getSkill()).toBe(80); + }); + + it('should record discarded cards grouped by expedition color', () => { + const ai = new LostCitiesAiPlayer(GreedyStrategy, createSeededRng(42)); + + ai.recordDiscard(makeNumbered('yellow', 5, 1)); + ai.recordDiscard(makeNumbered('yellow', 8, 2)); + ai.recordDiscard(makeNumbered('red', 3, 3)); + + const counts = ai.memoryTracker.getVisibleRanks(createSeededRng(42)); + // Grouping key is the expedition color + expect(counts['yellow']).toBe(2); + expect(counts['red']).toBe(1); + }); + + it('should record investment cards by color as well', () => { + const ai = new LostCitiesAiPlayer(GreedyStrategy, createSeededRng(42)); + + ai.recordDiscard(makeInvestment('blue', 1, 1)); + ai.recordDiscard(makeInvestment('blue', 2, 2)); + + const counts = ai.memoryTracker.getVisibleRanks(createSeededRng(42)); + expect(counts['blue']).toBe(2); + }); + + it('should expose recorded counts through getVisibleRanks', () => { + const ai = new LostCitiesAiPlayer(GreedyStrategy, createSeededRng(42)); + + ai.recordDiscard(makeNumbered('green', 4, 1)); + ai.recordDiscard(makeNumbered('green', 6, 2)); + ai.recordDiscard(makeNumbered('white', 9, 3)); + + const counts = ai.memoryTracker.getVisibleRanks(createSeededRng(42)); + expect(Object.keys(counts)).toContain('green'); + expect(Object.keys(counts)).toContain('white'); + }); }); // ═══════════════════════════════════════════════════════════ diff --git a/tests/lost-cities/lost-cities-hand-pile-migration.test.ts b/tests/lost-cities/lost-cities-hand-pile-migration.test.ts index c05bfe35..e25d175d 100644 --- a/tests/lost-cities/lost-cities-hand-pile-migration.test.ts +++ b/tests/lost-cities/lost-cities-hand-pile-migration.test.ts @@ -67,9 +67,16 @@ function createMockScene(): any { y, width: w, height: h, - setInteractive: vi.fn().mockReturnThis(), - destroy: vi.fn(), active: true, + setInteractive: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + setRotation: vi.fn().mockReturnThis(), + destroy: vi.fn().mockImplementation(() => { + rect.active = false; + }), }; rectangles.push(rect); return rect; diff --git a/tests/main-street/MainStreetOverlay.browser.test.ts b/tests/main-street/MainStreetOverlay.browser.test.ts index 4fabb915..aa7b2e0f 100644 --- a/tests/main-street/MainStreetOverlay.browser.test.ts +++ b/tests/main-street/MainStreetOverlay.browser.test.ts @@ -287,6 +287,31 @@ describe('Main Street overlay button tests', () => { } }); + it('rounds Coins in the game-over overlay to a whole number', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene')!; + + // Give the player a fractional coin balance (3-decimal precision) + const s = scene as any; + s.state.resourceBank.coins = 123.456; + forceGameOver(scene); + await waitFrames(3); + + const hud = (scene as any).hudContainer as { list: Phaser.GameObjects.GameObject[] } | undefined; + expect(hud).toBeDefined(); + + const allTexts = hud!.list.filter( + (child: Phaser.GameObjects.GameObject) => + child instanceof Phaser.GameObjects.Text, + ) as Phaser.GameObjects.Text[]; + + // The breakdown block contains Coins and Final Score lines + const breakdown = allTexts.find((t) => t.text.includes('Coins:') && t.text.includes('Final Score:')); + expect(breakdown).toBeDefined(); + expect(breakdown!.text).toContain('Coins: 123'); + expect(breakdown!.text).not.toContain('123.456'); + }); + it('should show Menu button in the HUD container', async () => { game = await bootGame(); const scene = game.scene.getScene('MainStreetScene')!; diff --git a/tests/main-street/MainStreetPlaceSell.test.ts b/tests/main-street/MainStreetPlaceSell.test.ts index b1c6b719..3c780738 100644 --- a/tests/main-street/MainStreetPlaceSell.test.ts +++ b/tests/main-street/MainStreetPlaceSell.test.ts @@ -164,7 +164,7 @@ describe('MainStreet Place/Sell System', () => { describe('Place from hand to tableau', () => { it.runIf(HAND_FEATURE_AVAILABLE && PLACE_SELL_API_AVAILABLE)( - 'should deduct 80% of purchase price when placing from hand to tableau', + 'should place card from hand to tableau without coin deduction', async () => { const state = createTestState(); executeDayStart(state); @@ -179,13 +179,12 @@ describe('MainStreet Place/Sell System', () => { if (slot < 0 || handIndex < 0) return; const coinsBefore = state.resourceBank.coins; - const expectedCost = Math.floor(card.cost * EXPECTED_PLACE_COST_RATIO); const engine = await import('../../example-games/main-street/MainStreetEngine'); (engine as any).placeFromHand(state, handIndex, slot); - // Coins deducted by 80% of purchase price - expect(state.resourceBank.coins).toBe(coinsBefore - expectedCost); + // Coins unchanged (placement is free) + expect(state.resourceBank.coins).toBe(coinsBefore); // Card removed from hand expect(getHand(state)).not.toContainEqual(expect.objectContaining({ id: card.id })); @@ -197,7 +196,7 @@ describe('MainStreet Place/Sell System', () => { ); it.runIf(HAND_FEATURE_AVAILABLE && PLACE_SELL_API_AVAILABLE)( - 'should use PLACE_COST_RATIO of 0.8 for cost calculation', + 'placement from hand does not deduct coins', async () => { const state = createTestState(); executeDayStart(state); @@ -212,24 +211,12 @@ describe('MainStreet Place/Sell System', () => { const engine = await import('../../example-games/main-street/MainStreetEngine'); - // Verify ratio constant - if (CONSTANTS_AVAILABLE) { - let ratio: number; - try { - const cards = await import('../../example-games/main-street/MainStreetCards'); - ratio = (cards as any).PLACE_COST_RATIO; - } catch { - ratio = (engine as any).PLACE_COST_RATIO; - } - expect(ratio).toBe(EXPECTED_PLACE_COST_RATIO); - } - const coinsBefore = state.resourceBank.coins; - const expectedCost = Math.floor(card.cost * EXPECTED_PLACE_COST_RATIO); (engine as any).placeFromHand(state, handIndex, slot); - expect(state.resourceBank.coins).toBe(coinsBefore - expectedCost); + // No coin deduction for placement + expect(state.resourceBank.coins).toBe(coinsBefore); }, ); @@ -514,7 +501,7 @@ describe('MainStreet Place/Sell System', () => { ); it.runIf(HAND_FEATURE_AVAILABLE && PLACE_SELL_API_AVAILABLE)( - 'should throw on execution when coins are insufficient', + 'should allow placement even with 0 coins (placement is free)', async () => { const state = createTestState(); executeDayStart(state); @@ -529,10 +516,14 @@ describe('MainStreet Place/Sell System', () => { const engine = await import('../../example-games/main-street/MainStreetEngine'); - // Executing placement should throw due to insufficient coins + // Placement with 0 coins should succeed (no cost) expect(() => { (engine as any).placeFromHand(state, handIndex, slot); - }).toThrow(); + }).not.toThrow(); + + // Card should be placed + expect(state.streetGrid[slot]).not.toBeNull(); + expect(state.hand.length).toBe(0); }, ); }); diff --git a/tests/main-street/MainStreetScene.browser.test.ts b/tests/main-street/MainStreetScene.browser.test.ts index 5d65dc5f..ede11bbd 100644 --- a/tests/main-street/MainStreetScene.browser.test.ts +++ b/tests/main-street/MainStreetScene.browser.test.ts @@ -312,9 +312,8 @@ describe('MainStreetScene browser tests', () => { const beforeBusinessAnimCount = scene.getTransferAnimationCountForTest(); scene.onBusinessCardClick(business); - scene.onSlotClick(targetSlot); - // Business should not appear in street immediately while transfer is playing + // Business should not appear in street or hand immediately while transfer is playing expect(state.streetGrid[targetSlot]).toBeNull(); expect(scene.getHiddenTransferSourceCardCountForTest()).toBeGreaterThan(0); @@ -323,6 +322,19 @@ describe('MainStreetScene browser tests', () => { { label: 'business transfer animation start' }, ); + // New flow: the business is bought to hand first (market → hand transfer), + // then placed on the grid (hand → street placement). + await waitForCondition( + () => scene.uiPhase === 'placing-from-hand', + { timeoutMs: 6000, label: 'business bought to hand' }, + ); + const handBusiness = (state.hand ?? []).find((c: any) => c.id === business.id); + expect(handBusiness).toBeTruthy(); + expect(scene.getHiddenTransferSourceCardCountForTest()).toBe(0); + + // Now place the business on the target slot. + scene.onSlotClick(targetSlot); + await waitForCondition( () => state.streetGrid[targetSlot]?.id === business.id, { timeoutMs: 6000, label: 'business transfer completion' }, @@ -468,4 +480,29 @@ describe('MainStreetScene browser tests', () => { destroyGame(game); game = null; }); + + it('rounds the HUD Coins display to a whole number (no fractional digits)', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as Phaser.Scene & Record; + + // Give the player a fractional coin balance (3-decimal precision) + scene.state.resourceBank.coins = 123.456; + scene.refreshHud(); + + // Find the transient HUD coin text + const hudList = scene.hudContainer.list as Phaser.GameObjects.GameObject[]; + const coinText = hudList.find( + (obj) => obj instanceof Phaser.GameObjects.Text + && (obj as any)._hudTransient + && (obj as Phaser.GameObjects.Text).text.startsWith('Coins:'), + ) as Phaser.GameObjects.Text | undefined; + + expect(coinText).toBeTruthy(); + // Rounded whole number, no decimal places (e.g. "Coins: 123", not "Coins: 123.456") + expect(coinText!.text).toBe('Coins: 123'); + expect(coinText!.text).not.toContain('.'); + + destroyGame(game); + game = null; + }); }); diff --git a/tests/main-street/MainStreetSellCards.test.ts b/tests/main-street/MainStreetSellCards.test.ts index 95673b00..4542c27c 100644 --- a/tests/main-street/MainStreetSellCards.test.ts +++ b/tests/main-street/MainStreetSellCards.test.ts @@ -637,16 +637,4 @@ describe('MainStreet Sell Cards', () => { ); }); - // ── Sell Dialog UI Specification (AC1) ─────────────────── - - describe('Sell dialog (AC1) - UI specification', () => { - it('should have sell button visible when clicking a placed card during MarketPhase', () => { - // This is primarily a UI test. The specification is: - // 1. During MarketPhase, clicking a placed card opens a sell overlay - // 2. The overlay shows card info + Sell button + Cancel button - // 3. Clicking Sell executes the sell - // 4. Clicking Cancel dismisses the overlay - expect(true).toBe(true); // Placeholder - UI test - }); - }); }); diff --git a/tests/main-street/MainStreetZOrder.browser.test.ts b/tests/main-street/MainStreetZOrder.browser.test.ts index aa2b987e..b0bc95ae 100644 --- a/tests/main-street/MainStreetZOrder.browser.test.ts +++ b/tests/main-street/MainStreetZOrder.browser.test.ts @@ -4,16 +4,17 @@ * Validates that Main Street's container depth ordering follows the expected * convention: HUD depth (1000) > all other zone containers > gameplay containers. * - * Main Street explicitly sets HUD container depth to 1000. Other containers - * (street, market, hand, action, incident queue) use default depth (0) and - * rely on creation-order depth sorting. + * Main Street explicitly sets HUD container depth to 1000. Gameplay + * containers (street, market, incident queue, hand) use default depth (0) + * and rely on creation-order depth sorting. `actionContainer` is raised to + * depth 100 so action buttons render above hand cards. * * Expected ordering (bottom → top): * 1. streetContainer – business cards on the street (depth 0) * 2. marketContainer – market cards (depth 0) * 3. incidentQueueContainer – incident queue (depth 0) * 4. handContainer – player hand cards (depth 0) - * 5. actionContainer – action buttons (depth 0) + * 5. actionContainer – action buttons (depth 100) * 6. hudContainer – HUD overlays (depth 1000) * 7. Game state overlays – depth 2000+ */ @@ -89,23 +90,27 @@ describe('Main Street container z-order', () => { expect(hudDepth).toBeGreaterThanOrEqual(1000); }); - it('gameplay containers use default depth (0)', async () => { + it('gameplay containers use default depth (0) except actionContainer (100)', async () => { game = await bootGame(); const scene = game.scene.getScene('MainStreetScene') as any; - const containers = [ + // These containers rely on creation-order depth sorting (default depth 0). + const defaultDepthContainers = [ 'streetContainer', 'marketContainer', 'incidentQueueContainer', 'handContainer', - 'actionContainer', ]; - for (const name of containers) { + for (const name of defaultDepthContainers) { if (scene[name]) { expect((scene[name] as any).depth ?? 0, `${name} should use default depth`).toBe(0); } } + + // actionContainer is deliberately raised above hand cards (depth 100). + expect(scene.actionContainer, 'actionContainer should exist').toBeDefined(); + expect((scene.actionContainer as any).depth, 'actionContainer depth').toBe(100); }); it('hudContainer depth is greater than all gameplay container depths', async () => { diff --git a/tests/main-street/TutorialOverlayManager.browser.test.ts b/tests/main-street/TutorialOverlayManager.browser.test.ts index 8c6061fe..05aa49a5 100644 --- a/tests/main-street/TutorialOverlayManager.browser.test.ts +++ b/tests/main-street/TutorialOverlayManager.browser.test.ts @@ -8,7 +8,7 @@ * Unified step mapping: * 0=T1 centerModal(confirm) 1=T2 hud(confirm) 2=T3 marketBusinessRow(action) * 3=T4 streetGrid(action) 4=T5 incidentQueue(confirm) 5=T6 endTurnButton(action) - * 6=T7 investmentsRow(action) 7=T8 marketBusinessRow(action) 8=T9 investmentsRow(confirm) + * 6=T7 investmentsRow(action) 7=T8 marketBusinessRow(action) 8=T9 streetGrid(action) * 9=T10 centerModal(confirm) 10=T11 endTurnButton(confirm) 11=T12 challengePanel(confirm) * 12=T13 hud(confirm) 13=T14 completionModal(confirm) */ @@ -370,27 +370,32 @@ describe('TutorialOverlayManager highlight zones', () => { } }); - // ── AC 12: T9 investments row highlight (confirm, upgrade concept) ── + // ── AC 12: T9 street grid highlight (action, place-business) ── - it('investmentsRow highlight (T9) covers the investments row for upgrade concept', async () => { + it('streetGrid highlight (T9) covers the street grid for place-business', async () => { const layout = scene.layout as { - marketTop: number; - marketRowH: number; - marketRowGap: number; - gameW: number; + streetX: number; + slotW: number; + slotH: number; + slotGap: number; + streetCols: number; + streetRowGap: number; } | undefined; expect(layout).toBeTruthy(); - const highlight = showStepAndGetHighlight('T9'); // T9 = confirm, investmentsRow zone + const highlight = showStepAndGetHighlight('T9'); // T9 = action, streetGrid zone expect(highlight).toBeTruthy(); const bounds = getHighlightBounds(highlight!); expect(bounds).toBeTruthy(); - // The investments row is the second (bottom) market row - const expectedTopY = layout!.marketTop + layout!.marketRowH + layout!.marketRowGap; - expect(bounds!.y).toBeLessThanOrEqual(expectedTopY + 4); - expect(bounds!.y).toBeGreaterThanOrEqual(layout!.marketTop - 10); + // Width should cover the full street grid width + const expectedW = layout!.streetCols * layout!.slotW + (layout!.streetCols - 1) * layout!.slotGap; + expect(bounds!.w).toBeGreaterThanOrEqual(expectedW - 10); // small tolerance + + // Height should cover both rows + const expectedH = 2 * layout!.slotH + layout!.streetRowGap; + expect(bounds!.h).toBeGreaterThanOrEqual(expectedH - 10); }); // ── AC 13: T12 challengePanel highlight (confirm, challenges info) ── diff --git a/tests/main-street/csv-checksum.test.ts b/tests/main-street/csv-checksum.test.ts index 5f349b49..1dc95684 100644 --- a/tests/main-street/csv-checksum.test.ts +++ b/tests/main-street/csv-checksum.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest'; import { setupMainStreetGame, serializeMainStreetState, deserializeMainStreetState } from '../../example-games/main-street/MainStreetState'; +import { executeDayStart } from '../../example-games/main-street/MainStreetEngine'; import { mainStreetStateSerializer } from '../../example-games/main-street/MainStreetSaveLoad'; import { computeCsvChecksum } from '../../example-games/main-street/CsvChecksum'; import { generateCardSvgFromCsvRow } from '../../example-games/main-street/scenes/MainStreetCardSvgGenerator'; @@ -126,6 +127,253 @@ describe('deserializeMainStreetState handles csvChecksum', () => { }); }); +// --------------------------------------------------------------------------- +// Tests for csvData field (AC1, AC5: embedded CSV data) +// --------------------------------------------------------------------------- + +describe('csvData field embedding', () => { + it('serializeMainStreetState includes csvData field', () => { + const state = setupMainStreetGame({ seed: 'csvdata-test' }); + const serialized = serializeMainStreetState(state); + expect(serialized).toHaveProperty('csvData'); + expect(typeof serialized.csvData).toBe('string'); + expect(serialized.csvData.length).toBeGreaterThan(0); + }); + + it('csvData contains valid CSV content with headers and rows', () => { + const state = setupMainStreetGame({ seed: 'csvdata-content' }); + const serialized = serializeMainStreetState(state); + // Should contain CSV header + expect(serialized.csvData).toContain('family,id,name,cost'); + // Should contain at least one business card row + expect(serialized.csvData).toContain('business'); + // Should contain at least one event card row + expect(serialized.csvData).toContain('event'); + }); + + it('round-trip save → load → re-save preserves csvData and csvChecksum', () => { + const state = setupMainStreetGame({ seed: 'roundtrip-csv' }); + const serialized = serializeMainStreetState(state); + const savedCsvData = serialized.csvData; + const savedChecksum = serialized.csvChecksum; + + // Rehydrate + const deserialized = deserializeMainStreetState(serialized); + + // Re-serialize + const reSerialized = serializeMainStreetState(deserialized); + + expect(reSerialized.csvData).toBe(savedCsvData); + expect(reSerialized.csvChecksum).toBe(savedChecksum); + }); + + it('csvData is preserved through checkpoint serializer', () => { + const state = setupMainStreetGame({ seed: 'serializer-test' }); + const serialized = mainStreetStateSerializer.serialize(state); + + expect(serialized).toHaveProperty('csvData'); + expect(serialized.csvData.length).toBeGreaterThan(0); + + // Round-trip through the full serializer + const deserialized = mainStreetStateSerializer.deserialize(serialized); + const reSerialized = mainStreetStateSerializer.serialize(deserialized); + + expect(reSerialized.csvData).toBe(serialized.csvData); + expect(reSerialized.csvChecksum).toBe(serialized.csvChecksum); + }); + + it('csvData is present in migrated legacy saves (backward compat)', () => { + const state = setupMainStreetGame({ seed: 'legacy-csv' }); + const serialized = serializeMainStreetState(state); + + // Remove csvData to simulate a legacy save + const withoutCsvData = { ...serialized }; + delete (withoutCsvData as any).csvData; + + const deserialized = deserializeMainStreetState(withoutCsvData as any); + const reSerialized = serializeMainStreetState(deserialized); + + // After round-trip, csvData should be present (set from current module-level CSV) + expect(reSerialized).toHaveProperty('csvData'); + expect(typeof reSerialized.csvData).toBe('string'); + expect(reSerialized.csvData.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests for AC2/AC3/AC4: CSV mismatch resolution and legacy save handling +// --------------------------------------------------------------------------- + +describe('CSV mismatch resolution', () => { + it('loads with matching csvChecksum (no CSV changes) works normally', () => { + const state = setupMainStreetGame({ seed: 'normal-load' }); + const serialized = serializeMainStreetState(state); + + // Matching checksum — should deserialize without template override + const deserialized = deserializeMainStreetState(serialized); + expect(deserialized.seed).toBe('normal-load'); + expect(deserialized.resourceBank.coins).toBeGreaterThan(0); + }); + + it('uses saved CSV data when csvChecksum differs and csvData is present', () => { + // Create a save state from the current CSV, then modify its checksum + // to simulate a CSV change, while keeping the csvData intact. + const state = setupMainStreetGame({ seed: 'mismatch-test' }); + const serialized = serializeMainStreetState(state); + + // Modify checksum to simulate CSV change + const modifiedSave = { + ...serialized, + csvChecksum: 'deadbeef', + // csvData is left intact from the real save + }; + + // Should deserialize without throwing (uses saved csvData) + const deserialized = deserializeMainStreetState(modifiedSave); + expect(deserialized.seed).toBe('mismatch-test'); + expect(deserialized.resourceBank.coins).toBeGreaterThan(0); + }); + + it('rejects legacy saves with mismatched checksum and no csvData', () => { + const state = setupMainStreetGame({ seed: 'legacy-reject' }); + const serialized = serializeMainStreetState(state); + + // Remove csvData AND modify checksum to simulate legacy save with CSV change + const modifiedSave = { + ...serialized, + csvChecksum: 'badc0de', + }; + delete (modifiedSave as any).csvData; + + expect(() => { + deserializeMainStreetState(modifiedSave); + }).toThrow(/different version of card-data.csv/); + }); + + it('accepts matching-checksum saves without csvData (legacy compat)', () => { + const state = setupMainStreetGame({ seed: 'matching-legacy' }); + const serialized = serializeMainStreetState(state); + + // Remove only csvData (keep matching checksum) + const modifiedSave = { ...serialized }; + delete (modifiedSave as any).csvData; + + // Should deserialize without throwing (matching checksum) + const deserialized = deserializeMainStreetState(modifiedSave); + expect(deserialized.seed).toBe('matching-legacy'); + }); + + it('template arrays are restored to defaults after fresh game setup', () => { + const state = setupMainStreetGame({ seed: 'restore-test' }); + const serialized = serializeMainStreetState(state); + + // First, load with CSV mismatch to override templates + const modifiedSave = { + ...serialized, + csvChecksum: 'f00dcafe', + }; + const deserialized = deserializeMainStreetState(modifiedSave); + expect(deserialized.seed).toBe('restore-test'); + + // Now create a fresh game — should reset templates to defaults + const newState = setupMainStreetGame({ seed: 'fresh-game' }); + expect(newState.seed).toBe('fresh-game'); + expect(newState.resourceBank.coins).toBeGreaterThan(0); + + // Verify a round-trip with default templates still works + const newSerialized = serializeMainStreetState(newState); + expect(newSerialized.csvChecksum.length).toBeGreaterThan(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests for AC3: Market state preservation on save/load +// --------------------------------------------------------------------------- + +describe('Market state preservation on save/load', () => { + it('save → load round-trip preserves development row cards', () => { + const state = setupMainStreetGame({ seed: 'market-save' }); + const serialized = serializeMainStreetState(state); + + // Record development row card IDs before reload + const devIds = serialized.market.development.map(c => c.id); + expect(devIds.length).toBeGreaterThan(0); + + // Load the saved state + const deserialized = deserializeMainStreetState(serialized); + + // Verify development row cards are unchanged + const reloadedDevIds = deserialized.market.development.map(c => c.id); + expect(reloadedDevIds).toEqual(devIds); + }); + + it('save → load round-trip preserves investments row cards', () => { + const state = setupMainStreetGame({ seed: 'market-save-2' }); + const serialized = serializeMainStreetState(state); + + // Record investments row card IDs before reload + const invIds = serialized.market.investments.map(c => c.id); + expect(invIds.length).toBeGreaterThan(0); + + // Load the saved state + const deserialized = deserializeMainStreetState(serialized); + + // Verify investments row cards are unchanged + const reloadedInvIds = deserialized.market.investments.map(c => c.id); + expect(reloadedInvIds).toEqual(invIds); + }); + + it('save → load → executeDayStart without skipMarketRefill replaces market cards', () => { + const state = setupMainStreetGame({ seed: 'market-cycle' }); + const serialized = serializeMainStreetState(state); + + // Load the saved state + const deserialized = deserializeMainStreetState(serialized); + + // Set phase to DayStart and call executeDayStart (normal flow — should refill) + deserialized.phase = 'DayStart'; + executeDayStart(deserialized); + + // Verify market cards exist (refilled from deck) + const newDevIds = deserialized.market.development.map(c => c.id); + expect(newDevIds.length).toBeGreaterThan(0); + }); + + it('save → load → executeDayStart with skipMarketRefill preserves market cards', () => { + const state = setupMainStreetGame({ seed: 'market-preserve' }); + const serialized = serializeMainStreetState(state); + const savedDevIds = serialized.market.development.map(c => c.id); + const savedInvIds = serialized.market.investments.map(c => c.id); + + // Load the saved state + const deserialized = deserializeMainStreetState(serialized); + + // Set phase to DayStart and call executeDayStart with skipMarketRefill=true + deserialized.phase = 'DayStart'; + executeDayStart(deserialized, true); + + // Verify market cards are preserved + const newDevIds = deserialized.market.development.map(c => c.id); + const newInvIds = deserialized.market.investments.map(c => c.id); + expect(newDevIds).toEqual(savedDevIds); + expect(newInvIds).toEqual(savedInvIds); + }); + + it('full market preservation on round-trip save → deserialize → re-serialize', () => { + const state = setupMainStreetGame({ seed: 'full-market' }); + const original = serializeMainStreetState(state); + + const deserialized = deserializeMainStreetState(original); + const reSerialized = serializeMainStreetState(deserialized); + + // Both development and investments row should match original + expect(reSerialized.market.development.map(c => c.id)) + .toEqual(original.market.development.map(c => c.id)); + expect(reSerialized.market.investments.map(c => c.id)) + .toEqual(original.market.investments.map(c => c.id)); + }); +}); + // --------------------------------------------------------------------------- // Tests for SVG regeneration function // --------------------------------------------------------------------------- diff --git a/tests/main-street/game-selector-integration.test.ts b/tests/main-street/game-selector-integration.test.ts deleted file mode 100644 index 0bdf20ff..00000000 --- a/tests/main-street/game-selector-integration.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Main Street Game Selector integration tests (Milestone 5). - * - * Verifies that Main Street's PRD-specified metadata is correct. - * The canonical source is the GAMES array in main.ts; this test - * mirrors the expected values to avoid the Phaser import chain. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -/** Parse the GAMES array from main.ts source (simple regex extraction). */ -function parseMainStreetEntry(): { - sceneKey: string; - title: string; - description: string; - thumbnail: string; -} | null { - const mainPath = join(__dirname, '../../main.ts'); - const source = readFileSync(mainPath, 'utf-8'); - - // Find the Main Street block - description is on a separate line from the key - const match = source.match( - /sceneKey:\s*'MainStreetScene',\s*\n\s*title:\s*'([^']+)',\s*\n\s*description:\s*\n\s*'([^']+)',\s*\n\s*thumbnail:\s*'([^']+)'/, - ); - if (!match) return null; - - return { - sceneKey: 'MainStreetScene', - title: match[1], - description: match[2], - thumbnail: match[3], - }; -} - -const MAIN_STREET = parseMainStreetEntry(); - -// ── Tests ──────────────────────────────────────────────────── - -describe('Main Street Game Selector integration (Milestone 5)', () => { - // ── Registration ────────────────────────────────────────── - - it('Main Street entry is found in main.ts', () => { - expect(MAIN_STREET).not.toBeNull(); - }); - - it('Main Street is registered exactly once in main.ts', () => { - const mainPath = join(__dirname, '../../main.ts'); - const source = readFileSync(mainPath, 'utf-8'); - const occurrences = (source.match(/sceneKey:\s*'MainStreetScene'/g) || []).length; - expect(occurrences).toBe(1); - }); - - // ── PRD Metadata ───────────────────────────────────────── - - it('has the correct sceneKey', () => { - expect(MAIN_STREET!.sceneKey).toBe('MainStreetScene'); - }); - - it('has the correct title', () => { - expect(MAIN_STREET!.title).toBe('Main Street'); - }); - - it('has the PRD-specified description', () => { - expect(MAIN_STREET!.description).toBe( - 'Single-player tableau builder. Purchase businesses, place them along a 10-slot street for synergy bonuses, manage coins and reputation, and build the highest-scoring Main Street in 20 turns.', - ); - }); - - it('has the PRD-specified thumbnail key', () => { - expect(MAIN_STREET!.thumbnail).toBe('games/main-street/thumbnail'); - }); - - // ── Thumbnail Convention ───────────────────────────────── - - it('thumbnail follows assets/${thumbnail}.png convention', () => { - const thumb = MAIN_STREET!.thumbnail; - expect(thumb).not.toMatch(/\.png$/); - expect(thumb).not.toMatch(/\.jpg$/); - expect(thumb).toContain('/'); - }); - - it('Main Street scene is registered in the scenes array', () => { - const mainPath = join(__dirname, '../../main.ts'); - const source = readFileSync(mainPath, 'utf-8'); - // Check MainStreetScene is in the scenes array passed to createCardGame - expect(source).toMatch(/MainStreetScene/); - }); -}); diff --git a/tests/main-street/hand-business-click.test.ts b/tests/main-street/hand-business-click.test.ts new file mode 100644 index 00000000..cb10e940 --- /dev/null +++ b/tests/main-street/hand-business-click.test.ts @@ -0,0 +1,303 @@ +/** + * Hand Business Card Click Tests + * + * Tests for hand business card interactivity in the market phase. + * Verifies that clicking a hand card during market phase sets + * pendingHandIndex and switches uiPhase to 'placing-from-hand'. + * + * @module + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { setupMainStreetGame } from '../../example-games/main-street/MainStreetState'; +import { MainStreetTurnController } from '../../example-games/main-street/scenes/MainStreetTurnController'; +import { executeDayStart } from '../../example-games/main-street/MainStreetEngine'; + +// ── Helpers ───────────────────────────────────────────────── + +/** + * Creates a minimal mock scene that satisfies the properties + * accessed by MainStreetTurnController.onHandBusinessCardClick. + */ +function createMockScene(): any { + const mockScene: any = { + state: setupMainStreetGame({ seed: 'hand-click-test' }), + uiPhase: 'market', + pendingHandIndex: null, + instructionText: { setText: vi.fn() }, + refreshAll: vi.fn(), + overlayObjects: [], + hudContainer: null, + undoManager: null, + tooltipManager: { + hide: vi.fn(), + show: vi.fn(), + }, + gameEvents: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, + time: { + delayedCall: vi.fn().mockReturnValue({ remove: vi.fn() }), + }, + refreshStreetGrid: vi.fn(), + refreshActionButtons: vi.fn(), + refreshAllAction: vi.fn(), + hintBar: null, + msLifecycleManager: { + isTutorialActionAllowed: vi.fn().mockReturnValue({ allowed: true }), + onTutorialActionComplete: vi.fn(), + }, + // SVG card loading artifacts + cardSvgLoadPromise: Promise.resolve(), + prewarmVisibleCardTextures: vi.fn().mockResolvedValue(undefined), + updateSvgDebugOverlay: vi.fn(), + // Animator stubs + animateTransferFromMarket: vi.fn().mockResolvedValue(undefined), + // HUD animation state + previousCoins: null, + previousReputation: null, + transferAnimationCount: 0, + activeTransferTweens: new Set(), + activeTransferVisuals: new Set(), + hiddenTransferSourceCardIds: new Set(), + }; + + // Derive a simple layout from the state + mockScene.layout = { + gameW: 1280, + gameH: 720, + handX: 40, + handY: 620, + handCardW: 140, + handCardH: 80, + handCenterX: 400, + hudY: 50, + marketTop: 120, + marketRowH: 100, + marketRowGap: 10, + marketCardW: 140, + marketCardH: 80, + marketCardGap: 12, + marketLabelW: 90, + queueTop: 340, + queueCardW: 120, + queueCardH: 69, + queueCardGap: 10, + eventsHeight: 0, + streetTop: 220, + slotW: 140, + slotH: 80, + slotGap: 20, + streetX: 40, + streetRowGap: 12, + streetCols: 5, + instructionY: 680, + actionY: 620, + actionButtonH: 28, + actionButtonW: 100, + hintButtonW: 60, + smallButtonW: 96, + challengeX: 0, + challengeY: 0, + challengeW: 0, + logX: 820, + logY: 340, + logW: 200, + logH: 340, + }; + + // Helper to run day start + mockScene.startDayPhase = () => { + executeDayStart(mockScene.state); + mockScene.uiPhase = 'market'; + mockScene.pendingHandIndex = null; + }; + + return mockScene; +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('Hand business card click', () => { + describe('onHandBusinessCardClick (turn controller)', () => { + it('sets pendingHandIndex and switches to placing-from-hand during market phase', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + // Add cards to hand + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; // skip if no market card available + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'market'; + + const controller = new MainStreetTurnController(scene); + + // Simulate clicking the first hand card + controller.onHandBusinessCardClick(0); + + expect(scene.pendingHandIndex).toBe(0); + expect(scene.uiPhase).toBe('placing-from-hand'); + expect(scene.instructionText.setText).toHaveBeenCalled(); + expect(scene.refreshAll).toHaveBeenCalled(); + }); + + it('does nothing during non-market phases', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'placing-from-hand'; // not market + + const controller = new MainStreetTurnController(scene); + + controller.onHandBusinessCardClick(0); + + // Should not change since we're not in market phase + expect(scene.pendingHandIndex).toBeNull(); + // instructionText should not have been called by this handler + // (only refreshAll might be called if phase check passes) + }); + + it('does nothing during animating phase', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'animating'; // not market + + const controller = new MainStreetTurnController(scene); + + controller.onHandBusinessCardClick(0); + + expect(scene.pendingHandIndex).toBeNull(); + }); + + it('does nothing during game-over phase', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'game-over'; // not market + + const controller = new MainStreetTurnController(scene); + + controller.onHandBusinessCardClick(0); + + expect(scene.pendingHandIndex).toBeNull(); + }); + + it('allows switching to a different hand card during placing-from-hand', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + // Add two cards to hand + const bizCard1 = scene.state.market.development[0]; + const bizCard2 = scene.state.market.development[1]; + if (!bizCard1 || !bizCard2) return; + scene.state.hand = [bizCard1, bizCard2]; + scene.pendingHandIndex = 0; + scene.uiPhase = 'placing-from-hand'; + + const controller = new MainStreetTurnController(scene); + + // Clicking a different card should switch selection + controller.onHandBusinessCardClick(1); + + expect(scene.pendingHandIndex).toBe(1); + expect(scene.uiPhase).toBe('placing-from-hand'); + }); + }); + + describe('Scene delegation', () => { + it('MainStreetScene.onHandBusinessCardClick delegates to turn controller', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'market'; + + const controller = new MainStreetTurnController(scene); + // Wire up the scene to delegate to the controller + scene.onHandBusinessCardClick = (index: number) => { + controller.onHandBusinessCardClick(index); + }; + + scene.onHandBusinessCardClick(0); + + expect(scene.pendingHandIndex).toBe(0); + expect(scene.uiPhase).toBe('placing-from-hand'); + }); + }); + + describe('Edge cases', () => { + it('handles out-of-bounds index gracefully', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'market'; + + const controller = new MainStreetTurnController(scene); + + // Should not throw + expect(() => controller.onHandBusinessCardClick(99)).not.toThrow(); + }); + + it('handles empty hand gracefully', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + scene.state.hand = []; + scene.pendingHandIndex = null; + scene.uiPhase = 'market'; + + const controller = new MainStreetTurnController(scene); + + // Should not throw + expect(() => controller.onHandBusinessCardClick(0)).not.toThrow(); + }); + + it('tutorial gating prevents action when tutorial disallows it', () => { + const scene = createMockScene(); + scene.startDayPhase(); + + const bizCard = scene.state.market.development[0]; + if (!bizCard) return; + scene.state.hand = [bizCard]; + scene.pendingHandIndex = null; + scene.uiPhase = 'market'; + + // Tutorial blocks the action + scene.msLifecycleManager.isTutorialActionAllowed = vi.fn().mockReturnValue({ + allowed: false, + reason: 'Complete the highlighted step first.', + }); + + const controller = new MainStreetTurnController(scene); + + controller.onHandBusinessCardClick(0); + + // Should have been blocked by tutorial + expect(scene.pendingHandIndex).toBeNull(); + expect(scene.uiPhase).toBe('market'); + expect(scene.instructionText.setText).toHaveBeenCalledWith( + 'Complete the highlighted step first.', + ); + }); + }); +}); diff --git a/tests/main-street/hud-tooltips.test.ts b/tests/main-street/hud-tooltips.test.ts index 809d472e..163ce1de 100644 --- a/tests/main-street/hud-tooltips.test.ts +++ b/tests/main-street/hud-tooltips.test.ts @@ -321,7 +321,7 @@ describe('buildScoreTooltip', () => { expect(tooltip).toContain('Rising Street'); }); - it('includes numeric score estimate', () => { + it('includes numeric score estimate (rounded)', () => { const state = setupMainStreetGame({ seed: 'test-score-num' }); state.resourceBank.coins = 50; state.resourceBank.reputation = 10; @@ -329,7 +329,8 @@ describe('buildScoreTooltip', () => { const expectedScore = computeScore(state); const tooltip = buildScoreTooltip(state, null); - expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${expectedScore}`); + // Score estimate should be rounded to nearest whole number + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${Math.round(expectedScore)}`); }); it('includes the win threshold as the target score', () => { @@ -361,13 +362,14 @@ describe('buildScoreTooltip', () => { expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreBreakdownReputation); expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreBreakdownChallenges); - // Should contain contribution values + // Should contain contribution values (coins value 30 is already a whole number) expect(tooltip).toContain(`${30}`); + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreBreakdownCoins}: 30`); expect(tooltip).toContain(`${repContribution}`); expect(tooltip).toContain(`${challengeContribution}`); }); - it('shows remaining score needed to reach win threshold when score is below target', () => { + it('shows remaining score needed to reach win threshold when score is below target (rounded)', () => { const state = setupMainStreetGame({ seed: 'test-score-remaining' }); // Starting game: score should be well below threshold const score = computeScore(state); @@ -376,7 +378,8 @@ describe('buildScoreTooltip', () => { const tooltip = buildScoreTooltip(state, null); if (remaining > 0) { - expect(tooltip).toContain(`${remaining} ${HUD_TOOLTIP_STRINGS.scoreRemainingToWin}`); + // Remaining score should be rounded to nearest whole number + expect(tooltip).toContain(`${Math.round(remaining)} ${HUD_TOOLTIP_STRINGS.scoreRemainingToWin}`); } }); @@ -395,15 +398,38 @@ describe('buildScoreTooltip', () => { expect(tooltip).toContain(HUD_TOOLTIP_STRINGS.scoreThresholdMet); }); - it('score estimate label includes win threshold as x of y format', () => { + it('rounds score values to nearest whole number in tooltip', () => { + const state = setupMainStreetGame({ seed: 'test-rounding' }); + // Set fractional values that produce a non-integer score + state.resourceBank.coins = 123.456; + state.resourceBank.reputation = 15; + + const score = computeScore(state); + const tooltip = buildScoreTooltip(state, null); + + // The estimate line should show the rounded score + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreEstimateLabel}: ${Math.round(score)}`); + // Score should not contain fractional part in the estimate line + const estimateLine = tooltip.split('\n').find(l => l.startsWith(HUD_TOOLTIP_STRINGS.scoreEstimateLabel)); + expect(estimateLine).toBeDefined(); + const match = estimateLine!.match(/: (\d+)\/\d+/); + expect(match).not.toBeNull(); + const displayedScore = parseInt(match![1], 10); + expect(displayedScore).toBe(Math.round(score)); + // Breakdown coins should be rounded to nearest whole number; other breakdown values stay raw + expect(tooltip).toContain(`${HUD_TOOLTIP_STRINGS.scoreBreakdownCoins}: 123`); + expect(tooltip).not.toContain(`${HUD_TOOLTIP_STRINGS.scoreBreakdownCoins}: 123.456`); + }); + + it('score estimate label includes win threshold as x of y format (rounded)', () => { const state = setupMainStreetGame({ seed: 'test-score-xy' }); const score = computeScore(state); const threshold = state.config.winThreshold; const tooltip = buildScoreTooltip(state, null); - // The score estimate should show "score / threshold" or "score of threshold" - expect(tooltip).toContain(`${score}/${threshold}`); + // The score estimate should show rounded "score / threshold" + expect(tooltip).toContain(`${Math.round(score)}/${threshold}`); }); }); diff --git a/tests/main-street/incident-queue-card-aspect.test.ts b/tests/main-street/incident-queue-card-aspect.test.ts index 98e9e616..968da7ca 100644 --- a/tests/main-street/incident-queue-card-aspect.test.ts +++ b/tests/main-street/incident-queue-card-aspect.test.ts @@ -47,22 +47,4 @@ describe('Incident queue card aspect ratio', () => { expect(layout.queueCardH).not.toBe(layout.marketCardH); }); - it('two queue cards at 120×69 fit within the 300px wide panel', () => { - const cardW = 120; - const gap = 6; - // Panel width is ~300px, card width is 120px, so card fits with room to spare - expect(cardW).toBeLessThanOrEqual(300); - // Two cards stacked vertically: 2 * 69 + gap = 144px panel height for cards - const twoCardsHeight = 2 * 69 + gap; - // Panel height should accommodate this - expect(twoCardsHeight).toBeLessThan(300); // well within any reasonable panel - }); - - it('queue cards are smaller than market cards (14% reduction)', () => { - const layout = computeMainStreetLayoutWithSll(); - const marketArea = layout.marketCardW * layout.marketCardH; // 140*80 = 11200 - const queueArea = layout.queueCardW * layout.queueCardH; // 120*69 = 8280 - const reduction = 1 - queueArea / marketArea; - expect(reduction).toBeCloseTo(0.26, 0); // ~26% area reduction - }); }); diff --git a/tests/main-street/layout-adapter.test.ts b/tests/main-street/layout-adapter.test.ts index 6ae98d8b..c389f78d 100644 --- a/tests/main-street/layout-adapter.test.ts +++ b/tests/main-street/layout-adapter.test.ts @@ -74,6 +74,12 @@ describe('MainStreetLayoutAdapter', () => { expect(layout.logW).toBe(layout.challengeW); }); + it('computes handCenterX from the street zone topCenter anchor', () => { + const layout = computeMainStreetLayoutWithSll(); + // street topCenter.x = 0.3203125 * 1280 = 410, representing the midpoint of the left column + expect(layout.handCenterX).toBe(410); + }); + it('right column does not overlap with left-area sections horizontally', () => { const layout = computeMainStreetLayoutWithSll(); // Right column starts at logX (960). Street grid ends at streetX + rowWidth (20 + 780 = 800). diff --git a/tests/main-street/monte-carlo-greedy-guardrail.test.ts b/tests/main-street/monte-carlo-greedy-guardrail.test.ts index 76f67403..bf551118 100644 --- a/tests/main-street/monte-carlo-greedy-guardrail.test.ts +++ b/tests/main-street/monte-carlo-greedy-guardrail.test.ts @@ -14,13 +14,4 @@ describe('Main Street greedy AI strategy CI guardrail', () => { expect(metrics.winRate).toBeLessThanOrEqual(0.8); }); - it('random strategy produces valid win rate over 100 deterministic seeds', () => { - const seeds = Array.from({ length: 100 }, (_, i) => `mc-random-${i}`); - const { metrics } = runMonteCarlo({ seeds, maxTurns: 25, strategy: 'random' }); - - expect(metrics.runs).toBe(100); - // Random strategy should produce at least some wins (basic sanity check). - expect(metrics.winRate).toBeGreaterThanOrEqual(0); - expect(metrics.winRate).toBeLessThanOrEqual(1); - }); }); diff --git a/tests/main-street/stats-button-icon.test.ts b/tests/main-street/stats-button-icon.test.ts index 6d015ab6..36e2a699 100644 --- a/tests/main-street/stats-button-icon.test.ts +++ b/tests/main-street/stats-button-icon.test.ts @@ -3,8 +3,6 @@ * * Verifies: * - The ms-icon-stats.svg asset exists and follows the 16x16 icon pattern. - * - The MainStreetLifecycleManager preload includes the stats icon. - * - The StatsButton class references the ms-icon-stats texture key. */ import fs from 'fs'; @@ -49,45 +47,3 @@ describe('Stats icon SVG asset', () => { }); }); -// ── Preload integration ──────────────────────────────────── - -describe('Stats texture preload integration', () => { - const lifeCyclePath = 'example-games/main-street/scenes/MainStreetLifecycleManager.ts'; - - it('includes "stats" in the preloaded icons list', () => { - const content = fs.readFileSync(lifeCyclePath, 'utf8'); - expect(content).toMatch(/'stats'/); - }); - - it('loads ms-icon-stats via template interpolation', () => { - const content = fs.readFileSync(lifeCyclePath, 'utf8'); - // The preload loop uses: s.load.image(`ms-icon-${k}`, ...) - // Since 'stats' is in the icons array, ms-icon-stats gets loaded. - expect(content).toMatch(/load\.image\(`ms-icon-/); - }); -}); - -// ── StatsButton class integration ────────────────────────── - -describe('StatsButton icon reference', () => { - const statsOverlayPath = 'example-games/main-street/scenes/StatsOverlay.ts'; - - it('references ms-icon-stats texture key', () => { - const content = fs.readFileSync(statsOverlayPath, 'utf8'); - expect(content).toMatch(/ms-icon-stats/); - }); - - it('replaces the Greek Sigma Σ text with an icon', () => { - const content = fs.readFileSync(statsOverlayPath, 'utf8'); - // The old Σ character should no longer be the primary label text - // It may still appear as a fallback in code comments - expect(content).not.toMatch(/'\u03A3'/); - }); - - it('provides a fallback text label when the texture is unavailable', () => { - const content = fs.readFileSync(statsOverlayPath, 'utf8'); - // The class should have fallback logic to show text when texture is missing - expect(content).toMatch(/\u03A3/); - expect(content).toMatch(/fallback/i); - }); -}); diff --git a/tests/main-street/tfRuntimeSynthPreset.test.ts b/tests/main-street/tfRuntimeSynthPreset.test.ts deleted file mode 100644 index cb7f5afb..00000000 --- a/tests/main-street/tfRuntimeSynthPreset.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -describe('tf runtime synth generation presets', () => { - it('uses tuned movement and transfer-family presets', () => { - const scriptPath = resolve(process.cwd(), 'scripts/tf-generate-synths.sh'); - const script = readFileSync(scriptPath, 'utf8'); - - expect(script).toContain('function movementVoice(durationMs = 1500)'); - expect(script).toContain('const output = gainNode(0.28125);'); - expect(script).toContain("noise: { type: 'brown' }"); - expect(script).toContain("new Tone.Filter(1100, 'lowpass')"); - expect(script).toContain('output.gain.value = clamp(v);'); - - expect(script).toContain("'construction-hammer': () => constructionHammerVoice()"); - expect(script).toContain("'construction-saw': () => constructionSawVoice()"); - expect(script).toContain("'construction-lite-hammer': () => constructionHammerVoice({ lite: true })"); - expect(script).toContain("'construction-lite-saw': () => constructionSawVoice({ lite: true })"); - expect(script).toContain("'crowd-cheer': () => crowdCheerVoice()"); - }); -}); diff --git a/tests/main-street/tutorial-flow.test.ts b/tests/main-street/tutorial-flow.test.ts index b876ebec..1618e708 100644 --- a/tests/main-street/tutorial-flow.test.ts +++ b/tests/main-street/tutorial-flow.test.ts @@ -30,14 +30,14 @@ describe('UNIFIED_TUTORIAL_STEPS', () => { }); it('each step has valid highlightZone', () => { for(const step of UNIFIED_TUTORIAL_STEPS) expect(['centerModal','hud','marketBusinessRow','streetGrid','endTurnButton','incidentQueue','investmentsRow','challengePanel','helpButton','completionModal']).toContain(step.highlightZone); }); it('each step has gate confirm or action', () => { for(const step of UNIFIED_TUTORIAL_STEPS) expect(['confirm','action']).toContain(step.gate); }); - it('has correct distribution: 9 confirm + 5 action', () => { expect(UNIFIED_TUTORIAL_STEPS.filter(s=>s.gate==='confirm').length).toBe(9); expect(UNIFIED_TUTORIAL_STEPS.filter(s=>s.gate==='action').length).toBe(5); }); + it('has correct distribution: 8 confirm + 6 action', () => { expect(UNIFIED_TUTORIAL_STEPS.filter(s=>s.gate==='confirm').length).toBe(8); expect(UNIFIED_TUTORIAL_STEPS.filter(s=>s.gate==='action').length).toBe(6); }); it('confirm steps do not have requiredAction', () => { for(const step of UNIFIED_TUTORIAL_STEPS) if(step.gate==='confirm') expect(step.requiredAction).toBeUndefined(); }); it('confirm steps do not have requiredCardId', () => { for(const step of UNIFIED_TUTORIAL_STEPS) if(step.gate==='confirm') expect(step.requiredCardId).toBeUndefined(); }); it('action steps have requiredAction', () => { for(const step of UNIFIED_TUTORIAL_STEPS) if(step.gate==='action') expect(step.requiredAction).toBeDefined(); }); it('T1 is confirm gate with centerModal highlight', () => { expect(findStep('T1').gate).toBe('confirm'); expect(findStep('T1').highlightZone).toBe('centerModal'); }); it('T2 is confirm gate with hud highlight', () => { expect(findStep('T2').gate).toBe('confirm'); expect(findStep('T2').highlightZone).toBe('hud'); }); it('T5 is confirm gate with incidentQueue highlight', () => { expect(findStep('T5').gate).toBe('confirm'); expect(findStep('T5').highlightZone).toBe('incidentQueue'); }); - it('T9 is confirm gate with investmentsRow highlight (upgrade concept)', () => { const t=findStep('T9'); expect(t.gate).toBe('confirm'); expect(t.highlightZone).toBe('investmentsRow'); }); + it('T9 is action gate with streetGrid highlight (place from hand)', () => { const t=findStep('T9'); expect(t.gate).toBe('action'); expect(t.requiredAction).toBe('place-business'); expect(t.highlightZone).toBe('streetGrid'); }); it('T10 is confirm gate with centerModal highlight', () => { expect(findStep('T10').gate).toBe('confirm'); expect(findStep('T10').highlightZone).toBe('centerModal'); }); it('T11 is confirm gate with endTurnButton highlight', () => { expect(findStep('T11').gate).toBe('confirm'); expect(findStep('T11').highlightZone).toBe('endTurnButton'); }); it('T12 is confirm gate with challengePanel highlight', () => { expect(findStep('T12').gate).toBe('confirm'); expect(findStep('T12').highlightZone).toBe('challengePanel'); }); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts deleted file mode 100644 index 74eb4159..00000000 --- a/tests/smoke.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('Smoke Test', () => { - it('should verify the test framework is working', () => { - expect(1 + 1).toBe(2); - }); - - it('should verify basic string operations', () => { - expect('Tableau Card Engine').toContain('Card'); - }); -}); diff --git a/tests/sushi-go/SushiGoScene.reducedMotion.test.ts b/tests/sushi-go/SushiGoScene.reducedMotion.test.ts deleted file mode 100644 index 864f0baf..00000000 --- a/tests/sushi-go/SushiGoScene.reducedMotion.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Tests for SushiGoScene reduced motion AI delay. - * - * Verifies the constant values and source code changes for - * reduced motion AI delay in SushiGoScene. - * - * @module tests/sushi-go/SushiGoScene.reducedMotion - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; - -describe('SushiGoScene reduced motion AI delay', () => { - it('adds ANIM_DURATION extra delay when reducedMotion is true', () => { - const source = readFileSync( - 'example-games/sushi-go/scenes/SushiGoScene.ts', - 'utf-8', - ); - expect(source).toContain('reducedMotion'); - expect(source).toContain('TURN_ANIMATION_DELAY + ANIM_DURATION'); - expect(source).toContain('TURN_ANIMATION_DELAY'); - }); -}); diff --git a/tests/ui/CardDesign.browser.test.ts b/tests/ui/CardDesign.browser.test.ts new file mode 100644 index 00000000..b49cc3bb --- /dev/null +++ b/tests/ui/CardDesign.browser.test.ts @@ -0,0 +1,163 @@ +/** + * Browser tests for card design cross-session persistence. + * + * Validates the fix for CG-0MRO5W3CL000CNGO (card style preference is not + * persisted): + * - A design saved via `setCardDesign()` (no storage arg) is written to + * `window.localStorage` and survives a simulated page reload (fresh + * Phaser game instance booting `preloadCardAssets()`). + * - `preloadCardAssets()` reads the persisted design from localStorage and + * requests the correct SVG asset paths for the selected design. + * - Clearing the preference (or storing an unknown key) falls back to the + * default "Classic" design. + * + * The test simulates a page reload by creating a brand-new Phaser.Game whose + * scene calls `preloadCardAssets()` — the same code path that runs when a + * player refreshes the page in 9-Card Golf / Beleaguered Castle. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import Phaser from 'phaser'; +import { + getCardDesign, + setCardDesign, + getCardDesignAssetPath, + CARD_DESIGN_DEFAULT, +} from '../../src/ui/SettingsStore'; +import { preloadCardAssets } from '../../src/ui/CardTextureHelpers'; +import { waitForScene } from '../helpers/waitForScene'; + +const STORAGE_KEY_CARD_DESIGN = 'tce-card-design'; +const WEBISSO_DESIGN_KEY = 'webisso'; + +// ── Recording preload scene ──────────────────────────────── +// +// Records every SVG URL requested via `preloadCardAssets()` during +// `preload()`. This lets tests assert which design's assets a freshly +// booted scene loads. + +class RecordingPreloadScene extends Phaser.Scene { + static loadedSvgUrls: string[] = []; + + constructor(key: string) { + super({ key }); + } + + preload(): void { + RecordingPreloadScene.loadedSvgUrls = []; + const loader = this.load; + const originalSvg = loader.svg.bind(loader); + loader.svg = ((key: string | Phaser.Types.Loader.FileTypes.SVGFileConfig + | Phaser.Types.Loader.FileTypes.SVGFileConfig[], url?: string, + svgConfig?: Phaser.Types.Loader.FileTypes.SVGSizeConfig, + xhrSettings?: Phaser.Types.Loader.XHRSettingsObject) => { + if (typeof key === 'string' && typeof url === 'string') { + RecordingPreloadScene.loadedSvgUrls.push(url); + } + return originalSvg(key, url, svgConfig, xhrSettings); + }) as typeof loader.svg; + + preloadCardAssets(this); + } + + create(): void { + this.scene.stop(); + } +} + +// ── Boot helpers ─────────────────────────────────────────── + +async function bootRecordingScene( + sceneKey: string, +): Promise { + const container = document.createElement('div'); + container.id = 'game-container'; + document.body.appendChild(container); + + const game = new Phaser.Game({ + type: Phaser.CANVAS, + width: 800, + height: 600, + parent: 'game-container', + scene: [new RecordingPreloadScene(sceneKey)], + }); + await waitForScene(game, sceneKey); + return game; +} + +function destroyGame(game: Phaser.Game | null): void { + if (game) game.destroy(true, false); + const container = document.getElementById('game-container'); + if (container) container.remove(); +} + +// ── Tests ────────────────────────────────────────────────── + +describe('card design cross-session persistence (browser)', () => { + let game: Phaser.Game | null = null; + + afterEach(() => { + destroyGame(game); + game = null; + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(STORAGE_KEY_CARD_DESIGN); + } + }); + + it('setCardDesign() without a storage argument writes to window.localStorage', () => { + setCardDesign(WEBISSO_DESIGN_KEY); + expect(window.localStorage.getItem(STORAGE_KEY_CARD_DESIGN)).toBe(WEBISSO_DESIGN_KEY); + expect(getCardDesign()).toBe(WEBISSO_DESIGN_KEY); + }); + + it('preloadCardAssets() loads the persisted (non-default) design after a simulated page reload', async () => { + // Simulate the previous session having selected "Modern" (webisso). + window.localStorage.setItem(STORAGE_KEY_CARD_DESIGN, WEBISSO_DESIGN_KEY); + + // Simulate a page reload: a brand-new game booting the scene preload path. + game = await bootRecordingScene('reload-webisso'); + + // The no-arg read must surface the persisted preference. + expect(getCardDesign()).toBe(WEBISSO_DESIGN_KEY); + expect(getCardDesignAssetPath(getCardDesign())).toBe('assets/cards/alternative/webisso/'); + + // All 53 SVGs (52 faces + back) must be requested from the webisso path. + const urls = RecordingPreloadScene.loadedSvgUrls; + expect(urls.length).toBe(53); + for (const url of urls) { + expect(url.startsWith('assets/cards/alternative/webisso/')).toBe(true); + } + expect(urls).toContain('assets/cards/alternative/webisso/card_back.svg'); + expect(urls).toContain('assets/cards/alternative/webisso/ace_of_spades.svg'); + }); + + it('preloadCardAssets() falls back to the default design when nothing is stored', async () => { + // Ensure no preference is stored. + window.localStorage.removeItem(STORAGE_KEY_CARD_DESIGN); + + game = await bootRecordingScene('reload-default'); + + expect(getCardDesign()).toBe(CARD_DESIGN_DEFAULT); + expect(getCardDesignAssetPath(getCardDesign())).toBe('assets/cards/'); + + const urls = RecordingPreloadScene.loadedSvgUrls; + expect(urls.length).toBe(53); + for (const url of urls) { + expect(url.startsWith('assets/cards/')).toBe(true); + } + }); + + it('preloadCardAssets() falls back to the default design for an unknown stored key', async () => { + window.localStorage.setItem(STORAGE_KEY_CARD_DESIGN, 'extinct-design'); + + game = await bootRecordingScene('reload-invalid'); + + expect(getCardDesign()).toBe(CARD_DESIGN_DEFAULT); + + const urls = RecordingPreloadScene.loadedSvgUrls; + expect(urls.length).toBe(53); + for (const url of urls) { + expect(url.startsWith('assets/cards/')).toBe(true); + } + }); +}); diff --git a/tests/ui/CardDesign.test.ts b/tests/ui/CardDesign.test.ts index 52107335..abbd28ba 100644 --- a/tests/ui/CardDesign.test.ts +++ b/tests/ui/CardDesign.test.ts @@ -9,7 +9,7 @@ * - Design selection can be persisted and restored */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getCardDesign, setCardDesign, @@ -29,6 +29,26 @@ function createMockStorage(): StorageLike { }; } +// ── globalThis.localStorage stubbing (Node test env has no localStorage) ── + +let originalGlobalStorage: unknown; + +beforeEach(() => { + originalGlobalStorage = (globalThis as any).localStorage; +}); + +afterEach(() => { + if (originalGlobalStorage === undefined) { + delete (globalThis as any).localStorage; + } else { + (globalThis as any).localStorage = originalGlobalStorage; + } +}); + +function stubGlobalStorage(storage: StorageLike): void { + (globalThis as any).localStorage = storage; +} + describe('CardDesign registry', () => { it('should export the default design key as "default"', () => { expect(CARD_DESIGN_DEFAULT).toBe('default'); @@ -109,6 +129,33 @@ describe('CardDesign persistence', () => { expect(getCardDesign(storage)).toBe(d.key); } }); + + it('should read the persisted design from globalThis.localStorage when called without arguments', () => { + const storage = createMockStorage(); + storage.setItem('tce-card-design', 'webisso'); + stubGlobalStorage(storage); + + // Regression test for CG-0MRO5W3CL000CNGO: getCardDesign() used to + // default its storage argument to null, so resolveStorage(null) + // returned null immediately and never read globalThis.localStorage. + expect(getCardDesign()).toBe('webisso'); + }); + + it('should return the default design when no preference is stored globally', () => { + const storage = createMockStorage(); + stubGlobalStorage(storage); + + expect(getCardDesign()).toBe(CARD_DESIGN_DEFAULT); + }); + + it('should persist to globalThis.localStorage when called without arguments', () => { + const storage = createMockStorage(); + stubGlobalStorage(storage); + + setCardDesign('webisso'); + expect(storage.getItem('tce-card-design')).toBe('webisso'); + expect(getCardDesign()).toBe('webisso'); + }); }); describe('CardDesign display name lookup', () => { diff --git a/tests/ui/ReducedMotion.test.ts b/tests/ui/ReducedMotion.test.ts index 21733097..df119d24 100644 --- a/tests/ui/ReducedMotion.test.ts +++ b/tests/ui/ReducedMotion.test.ts @@ -130,106 +130,6 @@ describe('getEffectiveReducedMotion utility', () => { }); }); -// --------------------------------------------------------------------------- -// 2. placeCard — reducedMotion parameter -// --------------------------------------------------------------------------- - -describe('placeCard with reduced motion', () => { - beforeEach(() => { - vi.resetModules(); - }); - - it('skips tween creation when reducedMotion=true and snaps to destination', async () => { - const { placeCard } = await import('../../src/ui/placeCard'); - void placeCard; - // TODO: Implement after PlaceCardOptions gets reducedMotion parameter - // Expected: placeCard({ scene, target, destX: 200, destY: 300, reducedMotion: true }) - // -> mockScene.tweens.add should not be called - // -> target.setPosition should have been called with 200, 300 - expect(true).toBe(true); - }); - - it('creates full animation when reducedMotion=false or undefined', async () => { - const { placeCard } = await import('../../src/ui/placeCard'); - void placeCard; - expect(true).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 3. dealCard — reducedMotion parameter -// --------------------------------------------------------------------------- - -describe('dealCard with reduced motion', () => { - it('skips tween creation when reducedMotion=true and snaps to destination', async () => { - const { dealCard } = await import('../../src/ui/dealCard'); - void dealCard; - // TODO: Implement after DealCardOptions gets reducedMotion parameter - expect(true).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 4. discardCard — reducedMotion parameter -// --------------------------------------------------------------------------- - -describe('discardCard with reduced motion', () => { - it('hides and destroys sprite without tween when reducedMotion=true', async () => { - const { discardCard } = await import('../../src/ui/discardCard'); - void discardCard; - // TODO: Implement after DiscardCardOptions gets reducedMotion parameter - expect(true).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 5. flipCard — reducedMotion parameter -// --------------------------------------------------------------------------- - -describe('flipCard with reduced motion', () => { - it('applies new texture immediately without tween when reducedMotion=true', async () => { - const { flipCard } = await import('../../src/ui/flipCard'); - void flipCard; - // TODO: Implement after FlipCardOptions gets reducedMotion parameter - expect(true).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 6. moveGameObject — reducedMotion parameter -// --------------------------------------------------------------------------- - -describe('moveGameObject with reduced motion', () => { - it('snaps to destination without tween when reducedMotion=true', async () => { - const { moveGameObject } = await import('../../src/ui/moveGameObject'); - void moveGameObject; - // TODO: Implement after MoveGameObjectOptions gets reducedMotion parameter - expect(true).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// 7. popTextOrIcon — SettingsStore preference check (beyond existing CSS) -// --------------------------------------------------------------------------- - -describe('popTextOrIcon with SettingsStore preference', () => { - it('respects reducedMotion=false parameter even when SettingsStore says reduced motion', async () => { - // TODO: Add real assertions once popTextOrIcon checks SettingsStore preference - // Skipped until CG-0MQLESCC7009L7PO implements SettingsStore checking - }); -}); - -// --------------------------------------------------------------------------- -// 8. sceneTransition — SettingsStore preference check (beyond existing CSS) -// --------------------------------------------------------------------------- - -describe('runSceneTransition with SettingsStore preference', () => { - it('runs animation when SettingsStore says no reduced motion', async () => { - // TODO: Add real assertions once sceneTransition checks SettingsStore preference - // Skipped until CG-0MQLESCC7009L7PO implements SettingsStore checking - }); -}); - // --------------------------------------------------------------------------- // 9. Verifying the API surfaces exist (compile-time contract) // --------------------------------------------------------------------------- diff --git a/tests/ui/SettingsPanelTooltips.browser.test.ts b/tests/ui/SettingsPanelTooltips.browser.test.ts index 4ec3f31d..2b8c7c7c 100644 --- a/tests/ui/SettingsPanelTooltips.browser.test.ts +++ b/tests/ui/SettingsPanelTooltips.browser.test.ts @@ -46,16 +46,25 @@ function destroyGame(game: Phaser.Game | null): void { // ── Helper: find text objects by content in a container ──── +/** + * Find all Text objects whose `text` matches, recursing into nested + * containers (e.g. the settings panel's scrollable content container). + */ function findTextObjects( container: Phaser.GameObjects.Container, text: string, ): Phaser.GameObjects.Text[] { const results: Phaser.GameObjects.Text[] = []; - container.each((child: Phaser.GameObjects.GameObject) => { - if (child instanceof Phaser.GameObjects.Text && child.text === text) { - results.push(child); - } - }); + const visit = (c: Phaser.GameObjects.Container) => { + c.each((child: Phaser.GameObjects.GameObject) => { + if (child instanceof Phaser.GameObjects.Text && child.text === text) { + results.push(child); + } else if (child instanceof Phaser.GameObjects.Container) { + visit(child); + } + }); + }; + visit(container); return results; } diff --git a/tests/ui/debug/DebugToolsRegistry.test.ts b/tests/ui/debug/DebugToolsRegistry.test.ts deleted file mode 100644 index 21b47bdb..00000000 --- a/tests/ui/debug/DebugToolsRegistry.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Unit tests for the debug tools registry module. - * - * Tests run in Node via Vitest. `import.meta.env.DEV` is a Vite - * build-time define and resolves to `true` during Vitest execution - * (development mode). The test verifies the structural contract - * of the exported members rather than the actual dev-mode value. - */ - -import { describe, it, expect } from 'vitest'; -import { isDevMode, type DebugToolsEntry } from '../../../src/ui/debug/DebugToolsRegistry'; - -describe('isDevMode', () => { - it('returns a boolean value', () => { - const result = isDevMode(); - expect(typeof result).toBe('boolean'); - }); -}); - -describe('DebugToolsEntry interface contract', () => { - it('can be satisfied by a plain object with label, description, activate', () => { - const entry: DebugToolsEntry = { - label: 'Test Tool', - description: 'A test debug tool entry', - activate: (_scene: Phaser.Scene) => { - // no-op - }, - }; - - expect(entry).toHaveProperty('label'); - expect(typeof entry.label).toBe('string'); - expect(entry).toHaveProperty('description'); - expect(typeof entry.description).toBe('string'); - expect(entry).toHaveProperty('activate'); - expect(typeof entry.activate).toBe('function'); - }); -}); diff --git a/tests/ui/debug/GameEventLogOverlay.test.ts b/tests/ui/debug/GameEventLogOverlay.test.ts deleted file mode 100644 index 85570fb0..00000000 --- a/tests/ui/debug/GameEventLogOverlay.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Unit tests for the Game Event Log debug tool. - */ - -import { describe, it, expect } from 'vitest'; -import { createGameEventLogTool } from '../../../src/ui/debug/GameEventLogOverlay'; - -describe('GameEventLogTool', () => { - it('returns a valid DebugToolsEntry', () => { - const entry = createGameEventLogTool(); - - expect(entry).toHaveProperty('label'); - expect(typeof entry.label).toBe('string'); - expect(entry).toHaveProperty('description'); - expect(typeof entry.description).toBe('string'); - expect(entry).toHaveProperty('activate'); - expect(typeof entry.activate).toBe('function'); - }); - - it('has expected label', () => { - const entry = createGameEventLogTool(); - expect(entry.label).toBe('Game Events'); - }); - - it('has a non-empty description', () => { - const entry = createGameEventLogTool(); - expect(entry.description.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/ui/debug/SessionExportTool.test.ts b/tests/ui/debug/SessionExportTool.test.ts deleted file mode 100644 index 09ca2114..00000000 --- a/tests/ui/debug/SessionExportTool.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Unit tests for the Session Export debug tool. - */ - -import { describe, it, expect } from 'vitest'; -import { createSessionExportTool } from '../../../src/ui/debug/SessionExportTool'; - -describe('SessionExportTool', () => { - it('returns a valid DebugToolsEntry', () => { - const entry = createSessionExportTool(); - - expect(entry).toHaveProperty('label'); - expect(typeof entry.label).toBe('string'); - expect(entry).toHaveProperty('description'); - expect(typeof entry.description).toBe('string'); - expect(entry).toHaveProperty('activate'); - expect(typeof entry.activate).toBe('function'); - }); - - it('has expected label', () => { - const entry = createSessionExportTool(); - expect(entry.label).toBe('Export Session'); - }); - - it('has a non-empty description', () => { - const entry = createSessionExportTool(); - expect(entry.description.length).toBeGreaterThan(0); - }); -}); diff --git a/tests/ui/debug/StateInspectorOverlay.test.ts b/tests/ui/debug/StateInspectorOverlay.test.ts deleted file mode 100644 index 0e4a1792..00000000 --- a/tests/ui/debug/StateInspectorOverlay.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Unit tests for the State Inspector debug tool. - * - * Tests the factory function contract and any testable helpers. - * The overlay rendering requires Phaser and is tested via browser tests. - */ - -import { describe, it, expect } from 'vitest'; -import { createStateInspectorTool } from '../../../src/ui/debug/StateInspectorOverlay'; - -describe('StateInspectorTool', () => { - it('returns a valid DebugToolsEntry', () => { - const entry = createStateInspectorTool(); - - expect(entry).toHaveProperty('label'); - expect(typeof entry.label).toBe('string'); - expect(entry).toHaveProperty('description'); - expect(typeof entry.description).toBe('string'); - expect(entry).toHaveProperty('activate'); - expect(typeof entry.activate).toBe('function'); - }); - - it('has expected label', () => { - const entry = createStateInspectorTool(); - expect(entry.label).toBe('State Inspector'); - }); - - it('has a non-empty description mentioning filter', () => { - const entry = createStateInspectorTool(); - expect(entry.description.length).toBeGreaterThan(0); - expect(entry.description.toLowerCase()).toContain('filter'); - }); -}); diff --git a/tests/ui/handView.raise.test.ts b/tests/ui/handView.raise.test.ts new file mode 100644 index 00000000..8b91321f --- /dev/null +++ b/tests/ui/handView.raise.test.ts @@ -0,0 +1,475 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HandView } from '../../src/ui/HandView'; +import { createCard } from '../../src/card-system/Card'; +import type { Card } from '../../src/card-system/Card'; + +// ── Minimal Phaser mock ───────────────────────────────────── +// HandView uses scene.add.image(), scene.add.text(), scene.add.rectangle(), +// scene.tweens (add + killTweensOf), scene.events and scene.input. + +function createMockScene(): any { + const tweens: any[] = []; + const images: any[] = []; + const texts: any[] = []; + const rectangles: any[] = []; + const inputHandlers: Record = {}; + + const mockImage = (x: number, y: number, _texture: string) => { + const img: any = { + x, + y, + rotation: 0, + active: true, + setInteractive: vi.fn().mockReturnThis(), + setTint: vi.fn().mockReturnThis(), + clearTint: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + off: vi.fn().mockReturnThis(), + destroy: vi.fn(), + displayWidth: 48, + displayHeight: 65, + }; + images.push(img); + return img; + }; + + const mockText = (x: number, y: number, text: string, _style?: any) => { + const txt: any = { + x, + y, + text, + active: true, + setOrigin: vi.fn().mockReturnThis(), + setTint: vi.fn().mockReturnThis(), + clearTint: vi.fn().mockReturnThis(), + setColor: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }; + texts.push(txt); + return txt; + }; + + return { + add: { + image: vi.fn().mockImplementation(mockImage), + text: vi.fn().mockImplementation(mockText), + graphics: vi.fn().mockReturnValue({ + fillStyle: vi.fn().mockReturnThis(), + fillRoundedRect: vi.fn().mockReturnThis(), + lineStyle: vi.fn().mockReturnThis(), + strokeRoundedRect: vi.fn().mockReturnThis(), + clear: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }), + rectangle: vi.fn().mockImplementation((x: number, y: number, w: number, h: number, color: number) => { + const rect: any = { + x, + y, + width: w, + height: h, + color, + fillColor: color, + fillAlpha: 0.35, + active: true, + setPosition: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + setRotation: vi.fn().mockReturnThis(), + setFillStyle: vi.fn().mockImplementation((c: number, a?: number) => { + rect.fillColor = c; + rect.color = c; + rect.fillAlpha = a ?? rect.fillAlpha; + return rect; + }), + destroy: vi.fn().mockImplementation(() => { + rect.active = false; + }), + }; + rectangles.push(rect); + return rect; + }), + }, + tweens: { + add: vi.fn().mockImplementation((config: any) => { + tweens.push(config); + return { stop: vi.fn() }; + }), + killTweensOf: vi.fn(), + }, + input: { + on: vi.fn((event: string, handler: any) => { + if (!inputHandlers[event]) inputHandlers[event] = []; + inputHandlers[event].push(handler); + }), + off: vi.fn(), + }, + events: { once: vi.fn(), on: vi.fn(), off: vi.fn() }, + time: { delayedCall: vi.fn() }, + _images: images, + _texts: texts, + _rectangles: rectangles, + _tweens: tweens, + _inputHandlers: inputHandlers, + }; +} + +function card(rank: string, suit: string): Card { + return createCard(rank as any, suit as any, true); +} + +/** Invoke the pointerdown handler registered on a sprite. */ +function triggerPointerDown(scene: any, spriteIndex: number, pointerX: number, pointerY: number): void { + const sprite = scene._images[spriteIndex]; + expect(sprite).toBeDefined(); + const onCalls = sprite.on.mock.calls; + const pointerdownCall = onCalls.find((c: any[]) => c[0] === 'pointerdown'); + expect(pointerdownCall).toBeDefined(); + pointerdownCall[1]({ x: pointerX, y: pointerY }); +} + +/** Retrieve a scene input handler by event name. */ +function getInputHandler(scene: any, event: string): any { + const handlers = scene._inputHandlers[event]; + expect(handlers).toBeDefined(); + return handlers[handlers.length - 1]; +} + +// ── Tests ─────────────────────────────────────────────────── + +describe('HandView selection raise (selectionLift)', () => { + let scene: ReturnType; + + beforeEach(() => { + scene = createMockScene(); + }); + + it('defaults selectionLift to 0 — selecting does not move cards (backward compatible)', () => { + const hv = new HandView(scene, { baseX: 100, baseY: 200, spacing: 56 }); + expect(hv.getSelectionLift()).toBe(0); + + hv.setCards([card('A', 'spades'), card('2', 'hearts')]); + hv.setSelected(0); + + // No positional change with the default lift of 0 + expect(scene._images[0].x).toBe(72); + expect(scene._images[0].y).toBe(200); + hv.destroy(); + }); + + it('setSelectionLift stores the distance and clamps invalid values to 0', () => { + const hv = new HandView(scene, { baseX: 100, baseY: 200, spacing: 56 }); + hv.setSelectionLift(-10); + expect(hv.getSelectionLift()).toBe(0); + hv.setSelectionLift(NaN); + expect(hv.getSelectionLift()).toBe(0); + hv.setSelectionLift(Infinity); + expect(hv.getSelectionLift()).toBe(0); + hv.setSelectionLift(25); + expect(hv.getSelectionLift()).toBe(25); + hv.destroy(); + }); + + it('horizontal layout: selected card raises straight up at 0° rotation (dx=0, dy=−d)', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + hv.setSelected(0); + + const sprite = scene._images[0]; + expect(sprite.x).toBe(100); + expect(sprite.y).toBe(180); // 200 − 20 + hv.destroy(); + }); + + it('horizontal layout: raise follows the card rotation (dx=d·sin θ, dy=−d·cos θ)', () => { + const hv = new HandView(scene, { + baseX: 300, + baseY: 200, + spacing: 56, + reducedMotion: true, + maxRotationDegrees: 25, + }); + hv.setSelectionLift(20); + + // 5 cards centred on baseX — edge cards receive ±25° rotation + hv.setCards([ + card('A', 'spades'), + card('2', 'hearts'), + card('3', 'clubs'), + card('4', 'diamonds'), + card('5', 'spades'), + ]); + + // Select card 0 — the raise follows its −25° rotation + hv.setSelected(0); + + // Card 0 sits at baseX − 2·spacing = 188 and is rotated −25° + const sprite = scene._images[0]; + const rot = (-25 * Math.PI) / 180; + expect(sprite.rotation).toBeCloseTo(rot, 5); + expect(sprite.x).toBeCloseTo(188 + 20 * Math.sin(rot), 5); + expect(sprite.y).toBeCloseTo(200 - 20 * Math.cos(rot), 5); + + // Unselected cards are NOT offset + expect(scene._images[2].x).toBe(300); + expect(scene._images[2].y).toBe(200); + hv.destroy(); + }); + + it('vertical layout: selected card(s) shift right by d (dx=+d, dy=0)', () => { + const hv = new HandView(scene, { + baseX: 200, + baseY: 100, + spacing: 50, + layoutDirection: 'vertical', + reducedMotion: true, + }); + hv.setSelectionLift(15); + hv.setCards([card('A', 'spades'), card('2', 'hearts'), card('3', 'clubs')]); + + // Cascade selection: index 1 selects cards [0..1] + hv.setSelected(1); + + expect(scene._images[0].x).toBe(215); + expect(scene._images[0].y).toBe(100); + expect(scene._images[1].x).toBe(215); + expect(scene._images[1].y).toBe(150); + + // Unselected card below the cascade range is not shifted + expect(scene._images[2].x).toBe(200); + expect(scene._images[2].y).toBe(200); + hv.destroy(); + }); + + it('getBasePosition returns the un-raised resting position even while selected', () => { + const hv = new HandView(scene, { + baseX: 300, + baseY: 200, + spacing: 56, + reducedMotion: true, + maxRotationDegrees: 25, + }); + hv.setSelectionLift(20); + hv.setCards([ + card('A', 'spades'), + card('2', 'hearts'), + card('3', 'clubs'), + card('4', 'diamonds'), + card('5', 'spades'), + ]); + + // Select card 0 — the raised sprite position differs from the base position + hv.setSelected(0); + const sprite = scene._images[0]; + const rot = (-25 * Math.PI) / 180; + expect(sprite.x).toBeCloseTo(188 + 20 * Math.sin(rot), 5); // raised + expect(sprite.y).toBeCloseTo(200 - 20 * Math.cos(rot), 5); + + // Base position is the resting spot WITHOUT the selection raise — + // callers restoring a moved card (e.g. Cancel Move) must use this, + // not the sprite's current x/y which includes the raise offset. + expect(hv.getBasePosition(0)).toEqual({ x: 188, y: 200 }); + expect(hv.getBasePosition(1)).toEqual({ x: 244, y: 200 }); + expect(hv.getBasePosition(2)).toEqual({ x: 300, y: 200 }); + expect(hv.getBasePosition(4)).toEqual({ x: 412, y: 200 }); + expect(hv.getBasePosition(99)).toBeUndefined(); + hv.destroy(); + }); + + it('clearing the selection returns the card to its resting position', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + expect(scene._images[0].y).toBe(180); + + hv.setSelected(null); + expect(scene._images[0].y).toBe(200); + hv.destroy(); + }); + + it('setSelectionLift(0) removes the raise live', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + expect(scene._images[0].y).toBe(180); + + hv.setSelectionLift(0); + expect(scene._images[0].y).toBe(200); + hv.destroy(); + }); + + it('animated path: selection change tweens the sprite to the raised target', () => { + const hv = new HandView(scene, { baseX: 100, baseY: 200, spacing: 56 }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + + expect(scene._tweens.length).toBeGreaterThan(0); + const tween = scene._tweens[scene._tweens.length - 1]; + expect(tween.targets).toContain(scene._images[0]); + expect(tween.y).toBeCloseTo(180, 5); + expect(tween.duration).toBeLessThanOrEqual(300); // short raise tween + hv.destroy(); + }); + + it('reduced-motion: raise applies instantly with no tween', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + + expect(scene._tweens.length).toBe(0); + expect(scene._images[0].y).toBe(180); + hv.destroy(); + }); + + it('tint overlay stays aligned with the raised sprite', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + + const sprite = scene._images[0]; + const overlay = scene._rectangles.find((r: any) => r.active && r.color === 0x88ff88); + expect(overlay).toBeDefined(); + expect(overlay.x).toBe(sprite.x); + expect(overlay.y).toBe(sprite.y); + hv.destroy(); + }); + + it('animated path: first-selection highlight overlay rides the raise tween with the sprite', () => { + const hv = new HandView(scene, { baseX: 100, baseY: 200, spacing: 56 }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + + // The highlight overlay must be created BEFORE the raise tween starts + // so it is included in the tween targets and raises with the card — a + // card must never rise away from its selection highlight. + const overlay = scene._rectangles.find((r: any) => r.active && r.color === 0x88ff88); + expect(overlay).toBeDefined(); + const tween = scene._tweens[scene._tweens.length - 1]; + expect(tween.targets).toContain(overlay); + expect(tween.y).toBeCloseTo(180, 5); + hv.destroy(); + }); + + it('hover repaint reuses the highlight overlay instead of recreating it (no orphaning mid-raise)', () => { + const hv = new HandView(scene, { baseX: 100, baseY: 200, spacing: 56 }); + hv.setSelectionLift(20); + hv.setCards([card('A', 'spades')]); + + hv.setSelected(0); + const overlayBefore = scene._rectangles.find((r: any) => r.active && r.color === 0x88ff88); + expect(overlayBefore).toBeDefined(); + + // Hover the selected card while the raise tween is in flight: the tint + // must be repainted IN PLACE so the raise tween keeps moving the same + // overlay object. Destroying + recreating would leave the new overlay + // orphaned at the resting position while the card continues to rise. + const sprite = scene._images[0]; + const pointeroverCall = sprite.on.mock.calls.find((c: any[]) => c[0] === 'pointerover'); + expect(pointeroverCall).toBeDefined(); + pointeroverCall[1](); + + const overlayAfter = scene._rectangles.find((r: any) => r.active && r.fillColor === 0x66ff66); + expect(overlayAfter).toBe(overlayBefore); + expect(scene._rectangles.filter((r: any) => r.active).length).toBe(1); + hv.destroy(); + }); + + it('vertical cascade: first-selection overlays raise with their cards', () => { + const hv = new HandView(scene, { + baseX: 200, + baseY: 100, + spacing: 50, + layoutDirection: 'vertical', + reducedMotion: true, + }); + hv.setSelectionLift(15); + hv.setCards([card('A', 'spades'), card('2', 'hearts'), card('3', 'clubs')]); + + // Cascade selection: index 1 selects cards [0..1], both shift right by 15 + hv.setSelected(1); + + const overlays = scene._rectangles.filter((r: any) => r.active && r.color === 0x88ff88); + expect(overlays.length).toBe(2); + for (let i = 0; i < 2; i++) { + expect(overlays[i].x).toBe(scene._images[i].x); + expect(overlays[i].y).toBe(scene._images[i].y); + } + hv.destroy(); + }); + + + it('drag lift composes with the selection raise (no stale offsets after a rejected drag)', () => { + const hv = new HandView(scene, { + baseX: 100, + baseY: 200, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(20); + hv.setDragEnabled(true); + hv.setCards([card('A', 'spades')]); + const sprite = scene._images[0]; + + // Click to select — raise applies instantly (reduced motion) + triggerPointerDown(scene, 0, 100, 100); + expect(sprite.y).toBe(180); // raised 20px + + // Drag beyond the threshold: raised origin + drag lift (−8) + pointer delta + const pointerMove = getInputHandler(scene, 'pointermove'); + pointerMove({ x: 130, y: 150 }); + expect(sprite.x).toBe(130); // 100 + dx(30) + expect(sprite.y).toBe(222); // 180 − 8 lift + dy(50) + + // Rejected drop (no validator) → snap back to the raised resting position + const pointerUp = getInputHandler(scene, 'pointerup'); + pointerUp(); + expect(sprite.x).toBe(100); + expect(sprite.y).toBe(180); + + // Tint is still applied to the selected card after the drag ends + const lastTintCall = sprite.setTint.mock.calls.slice(-1)[0]; + expect(lastTintCall).toEqual([0x88ff88]); + + hv.destroy(); + }); +}); diff --git a/tests/ui/handView.test.ts b/tests/ui/handView.test.ts index fb844c0d..96daa400 100644 --- a/tests/ui/handView.test.ts +++ b/tests/ui/handView.test.ts @@ -12,6 +12,7 @@ function createMockScene(): any { const images: any[] = []; const texts: any[] = []; const destroyed: any[] = []; + const rectangles: any[] = []; const mockImage = (x: number, y: number, texture: string) => { const img = { @@ -24,12 +25,14 @@ function createMockScene(): any { clearTint: vi.fn().mockReturnThis(), setOrigin: vi.fn().mockReturnThis(), setAlpha: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(), off: vi.fn().mockReturnThis(), destroy: vi.fn().mockImplementation(() => { destroyed.push(img); }), scaleX: 1, scaleY: 1, alpha: 1, + rotation: 0, displayWidth: 48, displayHeight: 65, }; @@ -46,6 +49,7 @@ function createMockScene(): any { setTint: vi.fn().mockReturnThis(), clearTint: vi.fn().mockReturnThis(), setColor: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), active: true, destroy: vi.fn().mockImplementation(() => { destroyed.push(txt); }), }; @@ -67,6 +71,28 @@ function createMockScene(): any { clear: vi.fn().mockReturnThis(), destroy: vi.fn(), }), + rectangle: vi.fn().mockImplementation((x: number, y: number, w: number, h: number, color: number) => { + const rect = { + x, y, width: w, height: h, color, fillColor: color, + active: true, + setPosition: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + setRotation: vi.fn().mockReturnThis(), + setFillStyle: vi.fn().mockImplementation((c: number, _a?: number) => { + rect.fillColor = c; + rect.color = c; + return rect; + }), + destroy: vi.fn().mockImplementation(() => { + rect.active = false; + destroyed.push(rect); + }), + }; + rectangles.push(rect); + return rect; + }), }, tweens: { add: vi.fn().mockImplementation((config: any) => { @@ -77,6 +103,7 @@ function createMockScene(): any { } return { stop: vi.fn() }; }), + killTweensOf: vi.fn(), }, input: { on: vi.fn((event: string, handler: any) => { @@ -98,6 +125,7 @@ function createMockScene(): any { _images: images, _texts: texts, _destroyed: destroyed, + _rectangles: rectangles, }; } @@ -499,6 +527,193 @@ describe('HandView', () => { hv.destroy(); }); + // ── Canvas-compatible tint overlays ─────────────────────── + + it('setSelected creates tint overlay rectangles on cards', () => { + const hv = new HandView(scene, { + baseX: 60, + baseY: 130, + spacing: 56, + }); + + const cards = [card('A', 'spades'), card('2', 'hearts'), card('3', 'clubs')]; + hv.setCards(cards); + + // Initially no tint overlays + const beforeRects = scene._rectangles.filter((r: any) => r.active); + expect(beforeRects.length).toBe(0); + + // Select card at index 1 + hv.setSelected(1); + const selectedRects = scene._rectangles.filter((r: any) => r.active); + expect(selectedRects.length).toBeGreaterThanOrEqual(1); + // The selected card should have a green-ish (0x88ff88) overlay + const selectedRect = selectedRects.find((r: any) => r.color === 0x88ff88); + expect(selectedRect).toBeDefined(); + + // Clear selection should remove overlays + hv.setSelected(null); + const clearedRects = scene._rectangles.filter((r: any) => r.active); + const greenRects = clearedRects.filter((r: any) => r.color === 0x88ff88); + expect(greenRects.length).toBe(0); + + hv.destroy(); + }); + + it('tint overlay rectangles match the rotated sprite angle', () => { + const hv = new HandView(scene, { + baseX: 60, + baseY: 130, + spacing: 56, + maxRotationDegrees: 25, + }); + + // 5 cards so the outer cards receive non-zero proportional rotation + const cards = [ + card('A', 'spades'), + card('2', 'hearts'), + card('3', 'clubs'), + card('4', 'diamonds'), + card('5', 'spades'), + ]; + hv.setCards(cards); + + // Select an edge card — it has a non-zero rotation in an arc layout + hv.setSelected(0); + const selectedRects = scene._rectangles.filter((r: any) => r.active && r.color === 0x88ff88); + expect(selectedRects.length).toBeGreaterThanOrEqual(1); + + // The overlay's setRotation must have been called with the sprite's rotation + const spriteRotation = (scene._images[0] as any).rotation ?? 0; + const rectRotationCalls = selectedRects[0].setRotation.mock.calls; + expect(rectRotationCalls.length).toBeGreaterThanOrEqual(1); + const lastRotation = rectRotationCalls[rectRotationCalls.length - 1][0]; + expect(Math.abs(lastRotation - spriteRotation)).toBeLessThan(0.001); + + hv.destroy(); + }); + + it('card sprites get per-index depth so the highlight cannot render over the card to the right', () => { + const hv = new HandView(scene, { baseX: 60, baseY: 130, spacing: 56 }); + hv.setCards([card('A', 'spades'), card('2', 'hearts'), card('3', 'clubs')]); + + // Sprites are assigned depth equal to their index (cards to the right + // render on top of the highlight of cards to their left). + expect(scene._images[0].setDepth).toHaveBeenCalledWith(0); + expect(scene._images[1].setDepth).toHaveBeenCalledWith(1); + expect(scene._images[2].setDepth).toHaveBeenCalledWith(2); + + hv.setSelected(0); + + // The green selection overlay renders at sprite depth + 0.01 = 0.01, + // i.e. above card 0 but below card 1 (depth 1) — no bleed. + const overlay = scene._rectangles.find((r: any) => r.active && r.color === 0x88ff88); + expect(overlay).toBeDefined(); + const overlayDepthCalls = overlay.setDepth.mock.calls; + const overlayDepth = overlayDepthCalls[overlayDepthCalls.length - 1][0]; + expect(overlayDepth).toBe(0.01); + expect(overlayDepth).toBeLessThan(1); + + hv.destroy(); + }); + + it('vertical cascade: highlight of selected cards does not render over unselected cards below', () => { + const hv = new HandView(scene, { + baseX: 200, + baseY: 100, + spacing: 50, + layoutDirection: 'vertical', + }); + hv.setCards([card('A', 'spades'), card('2', 'hearts'), card('3', 'clubs')]); + + // Cascade selection: index 1 selects cards [0..1] + hv.setSelected(1); + + const overlays = scene._rectangles.filter((r: any) => r.active && r.color === 0x88ff88); + expect(overlays.length).toBeGreaterThanOrEqual(1); + // Every selection overlay must sit below the first unselected card (index 2) + for (const o of overlays) { + const calls = o.setDepth.mock.calls; + const depth = calls[calls.length - 1][0]; + expect(depth).toBeLessThan(2); + } + + hv.destroy(); + }); + + it('selected-card raise keeps the highlight depth below the card to the right', () => { + const hv = new HandView(scene, { + baseX: 60, + baseY: 130, + spacing: 56, + reducedMotion: true, + }); + hv.setSelectionLift(25); + hv.setCards([card('A', 'spades'), card('2', 'hearts')]); + hv.setSelected(0); + + // The raised sprite keeps depth 0 and its overlay 0.01 → still below + // card 1 (depth 1) at any raise distance. + const overlay = scene._rectangles.find((r: any) => r.active && r.color === 0x88ff88); + expect(overlay).toBeDefined(); + const calls = overlay.setDepth.mock.calls; + const depth = calls[calls.length - 1][0]; + expect(depth).toBe(0.01); + expect(depth).toBeLessThan(1); + + hv.destroy(); + }); + + it('hover events create and remove tint overlay rectangles', () => { + const hv = new HandView(scene, { + baseX: 60, + baseY: 130, + spacing: 56, + }); + + hv.setCards([card('A', 'spades'), card('2', 'hearts')]); + const firstImage = scene._images[0]; + + // Find pointerover handler + const onCalls = firstImage.on.mock.calls; + const pointerOver = onCalls.find((c: any[]) => c[0] === 'pointerover'); + const pointerOut = onCalls.find((c: any[]) => c[0] === 'pointerout'); + expect(pointerOver).toBeDefined(); + expect(pointerOut).toBeDefined(); + + // Simulate hover in + pointerOver[1](); + const hoverRects = scene._rectangles.filter((r: any) => r.active && r.color === 0x66ff66); + expect(hoverRects.length).toBeGreaterThanOrEqual(1); + + // Simulate hover out + pointerOut[1](); + const afterOutRects = scene._rectangles.filter((r: any) => r.active && r.color === 0x66ff66); + expect(afterOutRects.length).toBe(0); + + hv.destroy(); + }); + + it('destroy cleans up all tint overlay rectangles', () => { + const hv = new HandView(scene, { + baseX: 60, + baseY: 130, + spacing: 56, + }); + + hv.setCards([card('A', 'spades'), card('2', 'hearts')]); + hv.setSelected(0); + + // Verify overlays are created + expect(scene._rectangles.length).toBeGreaterThan(0); + + hv.destroy(); + + // After destroy, overlays should be inactive/destroyed + const activeRects = scene._rectangles.filter((r: any) => r.active); + expect(activeRects.length).toBe(0); + }); + // ── Event emission ───────────────────────────────────────── it('on registers event listeners that are emitted for card clicks', () => { diff --git a/tests/ui/hud-layer-contract.browser.test.ts b/tests/ui/hud-layer-contract.browser.test.ts index 3387f991..e51575bc 100644 --- a/tests/ui/hud-layer-contract.browser.test.ts +++ b/tests/ui/hud-layer-contract.browser.test.ts @@ -15,28 +15,6 @@ import Phaser from 'phaser'; import { waitForScene } from '../helpers/waitForScene'; import { createOverlayBackground, dismissOverlay } from '../../src/ui/Overlay'; -// ── Test Configuration ───────────────────────────────────── - -/** Fixed seed for reproducible rendering across test runs. */ -const TEST_SEED = 12345; - -/** - * Temporarily replace `Math.random` with a seeded RNG, execute - * `fn`, then restore the original `Math.random`. - */ -async function withSeededRandom(seed: number, fn: () => Promise): Promise { - const original = Math.random; - const seeded = await import('../../src/core-engine/SeededRng').then( - (mod) => mod.createSeededRng(seed) - ); - Math.random = seeded; - try { - return await fn(); - } finally { - Math.random = original; - } -} - // ── Boot helper ──────────────────────────────────────────── async function bootBeleagueredCastle(): Promise { @@ -60,15 +38,6 @@ function destroyGame(game: Phaser.Game | null): void { if (container) container.remove(); } -// ── Helper to extract depth from display objects ─────────── - -function getDepth(obj: unknown): number { - if (obj && typeof obj === 'object' && 'depth' in obj) { - return (obj as { depth: number }).depth; - } - return -1; // Indicates no depth property found -} - // ── Tests ────────────────────────────────────────────────── let hudGame: Phaser.Game | null = null; @@ -83,27 +52,6 @@ afterAll(() => { }); describe('HUD Layer Contract (browser)', () => { - it('HUD container exists at depth ≥ 1000 when initialized', async () => { - // Re-seed random for reproducibility (only matters on first test since seed is set at boot) - await withSeededRandom(TEST_SEED, async () => { - // game already booted in beforeAll - }); - - const scene = hudGame!.scene.getScene('BeleagueredCastleScene') as unknown as Record; - - // Check if HUD container exists (will be undefined until Feature 3 is implemented) - // This test documents the expected contract and will pass once Feature 3 is complete - const hudContainer = scene.hudContainer; - if (hudContainer) { - // If HUD container exists (after implementation), verify its depth is ≥ 1000 - const hudDepth = getDepth(hudContainer); - expect(hudDepth).toBeGreaterThanOrEqual(1000); - } else { - // Before implementation, this is expected - test passes as documentation - expect(true).toBe(true); - } - }, 30_000); - it('HelpPanel and SettingsPanel are created during scene initialization', async () => { const scene = hudGame!.scene.getScene('BeleagueredCastleScene') as unknown as Record; diff --git a/tests/ui/shakeIllegalMove.test.ts b/tests/ui/shakeIllegalMove.test.ts index 44d94688..234b363d 100644 --- a/tests/ui/shakeIllegalMove.test.ts +++ b/tests/ui/shakeIllegalMove.test.ts @@ -21,7 +21,28 @@ function createMockTween(): Phaser.Tweens.Tween { /** Create a mock Phaser scene with a tweens.add spy and sound.play mock. */ function createMockScene(): Phaser.Scene { tweenConfigs = []; + const destroyed: any[] = []; + const rectangles: any[] = []; return { + add: { + rectangle: vi.fn().mockImplementation((x: number, y: number, w: number, h: number, color: number) => { + const rect = { + x, y, width: w, height: h, color, + active: true, + setPosition: vi.fn().mockReturnThis(), + setOrigin: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setAlpha: vi.fn().mockReturnThis(), + setRotation: vi.fn().mockReturnThis(), + destroy: vi.fn().mockImplementation(() => { + rect.active = false; + destroyed.push(rect); + }), + }; + rectangles.push(rect); + return rect; + }), + }, tweens: { add: vi.fn((config: Phaser.Types.Tweens.TweenBuilderConfig) => { tweenConfigs.push(config); @@ -31,6 +52,8 @@ function createMockScene(): Phaser.Scene { sound: { play: vi.fn(), }, + _rectangles: rectangles, + _destroyed: destroyed, } as unknown as Phaser.Scene; } @@ -38,6 +61,13 @@ function createMockScene(): Phaser.Scene { function createMockTarget(x = 100): Phaser.GameObjects.Image { return { x, + displayWidth: 96, + displayHeight: 130, + width: 96, + height: 130, + originX: 0.5, + originY: 0.5, + depth: 0, setTint: vi.fn(), clearTint: vi.fn(), setX: vi.fn(), @@ -110,6 +140,7 @@ describe('shakeIllegalMove', () => { (tweenConfigs[0].onComplete as Function)(); expect(onComplete).toHaveBeenCalledOnce(); + // clearTint and setX are still called; destroy on the overlay is also called expect(callOrder).toEqual(['clearTint', 'setX', 'onComplete']); }); diff --git a/tests/ui/visibility-ownership-runtime.test.ts b/tests/ui/visibility-ownership-runtime.test.ts deleted file mode 100644 index badc8d61..00000000 --- a/tests/ui/visibility-ownership-runtime.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Visibility Ownership Runtime Tests - * - * Unit tests for the layout ownership runtime. The core controller lives in - * `src/core-engine/VisibilityOwnership` and is re-exported from `src/ui` for - * UI-layer consumers. These tests exercise the contract directly from the - * core-engine module (same as the existing test in - * `tests/core-engine/VisibilityOwnership.test.ts`) and add a re-export check. - * - * - Register targets to ownership groups (shell, scene, shared, ungrouped) - * - Toggle visibility based on active layout mode - * - Default behavior for ungrouped targets - * - Diagnostic reporting for ungrouped and unknown groups - * - Re-exported from the UI barrel - * - * @module tests/ui/visibility-ownership-runtime - */ - -import { describe, expect, it, vi } from 'vitest'; -import { - VisibilityOwnershipController, - type VisibilityOwnershipIssue, -} from '../../src/core-engine/VisibilityOwnership'; - -// Note: The VisibilityOwnershipController is re-exported from -// src/ui for UI-layer consumers. The re-export is verified by the -// TypeScript build (it compiles without errors) and by the fact that -// GymSllScene imports it via the UI barrel path. The unit tests below -// import directly from core-engine to avoid pulling in the full Phaser- -// dependent UI barrel in headless Vitest runs. - -type MockTarget = { - visible: boolean; - setVisible: (visible: boolean) => void; -}; - -function createTarget(initialVisible = false): MockTarget { - const target: MockTarget = { - visible: initialVisible, - setVisible: vi.fn((visible: boolean) => { - target.visible = visible; - }), - }; - return target; -} - -function createController( - issues: VisibilityOwnershipIssue[] = [], - groupRules: Record> = { - shell: { 'shell-only': true, 'composed': true }, - scene: { 'scene-only': true, 'composed': true }, - shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, - }, -): VisibilityOwnershipController { - return new VisibilityOwnershipController({ - groupRules, - reportIssue: (issue) => issues.push(issue), - }); -} - -describe('VisibilityOwnershipController (UI barrel re-export)', () => { - it('is available from the UI barrel', () => { - expect(VisibilityOwnershipController).toBeDefined(); - expect(VisibilityOwnershipController).toBeInstanceOf(Function); - }); - - it('registers targets to groups and toggles visibility by mode', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const shellTarget = createTarget(); - const sceneTarget = createTarget(); - const sharedTarget = createTarget(); - - controller.register(shellTarget, 'shell'); - controller.register(sceneTarget, 'scene'); - controller.register(sharedTarget, 'shared'); - - // shell-only: shell and shared visible, scene hidden - controller.setMode('shell-only'); - expect(shellTarget.visible).toBe(true); - expect(sceneTarget.visible).toBe(false); - expect(sharedTarget.visible).toBe(true); - - // scene-only: scene and shared visible, shell hidden - controller.setMode('scene-only'); - expect(shellTarget.visible).toBe(false); - expect(sceneTarget.visible).toBe(true); - expect(sharedTarget.visible).toBe(true); - - // composed: all visible - controller.setMode('composed'); - expect(shellTarget.visible).toBe(true); - expect(sceneTarget.visible).toBe(true); - expect(sharedTarget.visible).toBe(true); - - expect(issues).toHaveLength(0); - }); - - it('registers targets to multiple groups (OR logic)', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const multiTarget = createTarget(); - controller.register(multiTarget, ['shell', 'scene']); - - // shell-only: visible (shell group allows) - controller.setMode('shell-only'); - expect(multiTarget.visible).toBe(true); - - // scene-only: visible (scene group allows) - controller.setMode('scene-only'); - expect(multiTarget.visible).toBe(true); - - // composed: visible - controller.setMode('composed'); - expect(multiTarget.visible).toBe(true); - - expect(issues).toHaveLength(0); - }); - - it('hides ungrouped targets by default and reports a diagnostic', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const ungrouped = createTarget(true); - controller.register(ungrouped); // no group - - controller.setMode('shell-only'); - expect(ungrouped.visible).toBe(false); - - controller.setMode('scene-only'); - expect(ungrouped.visible).toBe(false); - - controller.setMode('composed'); - expect(ungrouped.visible).toBe(false); - - expect(issues).toHaveLength(1); - expect(issues[0]).toMatchObject({ - code: 'UNGROUPED_TARGET', - severity: 'warning', - }); - expect(issues[0].message).toContain('ungrouped'); - }); - - it('handles unknown groups with warnings', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const unknown = createTarget(); - controller.register(unknown, 'experimental'); - - controller.setMode('composed'); - expect(unknown.visible).toBe(false); - - expect(issues).toHaveLength(1); - expect(issues[0]).toMatchObject({ - code: 'UNKNOWN_GROUP', - severity: 'warning', - }); - expect(issues[0].message).toContain('experimental'); - - // Defining the group makes it visible in the configured mode - controller.setGroupRules('experimental', { - 'composed': true, - }); - - controller.setMode('composed'); - expect(unknown.visible).toBe(true); - }); - - it('supports dynamic group rule updates', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const shellTarget = createTarget(); - controller.register(shellTarget, 'shell'); - - // Initially visible in composed mode - controller.setMode('composed'); - expect(shellTarget.visible).toBe(true); - - // Disable shell in composed mode - controller.setGroupRules('shell', { - 'shell-only': true, - 'composed': false, - }); - - controller.setMode('composed'); - expect(shellTarget.visible).toBe(false); - - controller.setMode('shell-only'); - expect(shellTarget.visible).toBe(true); - - // Restore - controller.setGroupRules('shell', { - 'shell-only': true, - 'composed': true, - }); - - controller.setMode('composed'); - expect(shellTarget.visible).toBe(true); - }); - - it('registerAll registers multiple targets', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const targets = [createTarget(), createTarget(), createTarget()]; - controller.registerAll(targets, 'shell'); - - controller.setMode('shell-only'); - expect(targets[0].visible).toBe(true); - expect(targets[1].visible).toBe(true); - expect(targets[2].visible).toBe(true); - - controller.setMode('scene-only'); - expect(targets[0].visible).toBe(false); - expect(targets[1].visible).toBe(false); - expect(targets[2].visible).toBe(false); - - expect(issues).toHaveLength(0); - }); - - it('clear() removes all registered targets', () => { - const controller = new VisibilityOwnershipController({ - groupRules: { - shell: { 'shell-only': true, 'composed': true }, - scene: { 'scene-only': true, 'composed': true }, - shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, - }, - }); - - const target = createTarget(); - controller.register(target, 'shell'); - controller.setMode('composed'); - expect(target.visible).toBe(true); - - controller.clear(); - - // After clearing, the target is no longer managed - controller.setMode('scene-only'); - // The target should retain its last state since it's unregistered - expect(target.visible).toBe(true); - }); - - it('getMode() returns the current mode', () => { - const controller = new VisibilityOwnershipController({ - groupRules: { - shell: { 'shell-only': true, 'composed': true }, - scene: { 'scene-only': true, 'composed': true }, - shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, - }, - }); - - expect(controller.getMode()).toBe('composed'); - controller.setMode('shell-only'); - expect(controller.getMode()).toBe('shell-only'); - controller.setMode('scene-only'); - expect(controller.getMode()).toBe('scene-only'); - }); - - it('supports string group names (not just arrays)', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const target = createTarget(); - controller.register(target, 'scene'); - - controller.setMode('scene-only'); - expect(target.visible).toBe(true); - - controller.setMode('composed'); - expect(target.visible).toBe(true); - - controller.setMode('shell-only'); - expect(target.visible).toBe(false); - - expect(issues).toHaveLength(0); - }); - - it('emits diagnostics for multiple ungrouped targets', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - controller.register(createTarget()); - controller.register(createTarget()); - controller.register(createTarget()); - - expect(issues).toHaveLength(3); - for (const issue of issues) { - expect(issue.code).toBe('UNGROUPED_TARGET'); - expect(issue.severity).toBe('warning'); - } - }); - - it('throws on empty group name in setGroupRules', () => { - const controller = new VisibilityOwnershipController({ - groupRules: { - shell: { 'shell-only': true, 'composed': true }, - scene: { 'scene-only': true, 'composed': true }, - shared: { 'shell-only': true, 'scene-only': true, 'composed': true }, - }, - }); - - expect(() => controller.setGroupRules('', {})).toThrow( - 'Group name must not be empty.', - ); - }); - - it('trims whitespace from group names', () => { - const issues: VisibilityOwnershipIssue[] = []; - const controller = createController(issues); - - const target = createTarget(); - controller.register(target, ' shell '); - - controller.setMode('shell-only'); - expect(target.visible).toBe(true); - - controller.setMode('scene-only'); - expect(target.visible).toBe(false); - - expect(issues).toHaveLength(0); - }); -});