diff --git a/.llm-wiki/meta/backlinks.json b/.llm-wiki/meta/backlinks.json index 0967ef42..a707ca46 100644 --- a/.llm-wiki/meta/backlinks.json +++ b/.llm-wiki/meta/backlinks.json @@ -1 +1,8 @@ -{} +{ + "sources/direct-sound-playback-for-game-specific-audio": [], + "sources/obs-2026-06-24-activity-log-windowed-rendering-fix": [], + "sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound": [], + "sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns": [], + "sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over": [], + "sources/tutorial-i18n-already-implemented": [] +} diff --git a/.llm-wiki/meta/events.jsonl b/.llm-wiki/meta/events.jsonl index 6e45eeeb..5a746ee0 100644 --- a/.llm-wiki/meta/events.jsonl +++ b/.llm-wiki/meta/events.jsonl @@ -1 +1,7 @@ {"timestamp":"2026-06-06T22:21:48.841Z","kind":"bootstrap","topic":"Tableau Card Engine","mode":"personal"} +{"timestamp":"2026-06-24T11:39:05.029Z","kind":"observe","slug":"obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns","title":"Neutral income sound added for zero-income turns","relevance":"medium"} +{"timestamp":"2026-06-24T11:39:10.273Z","kind":"retro","slug":"direct-sound-playback-for-game-specific-audio","title":"Direct sound playback for game-specific audio events","category":"audio"} +{"timestamp":"2026-06-24T12:10:43.832Z","kind":"observe","slug":"obs-2026-06-24-activity-log-windowed-rendering-fix","title":"Activity Log windowed rendering fix","relevance":"medium"} +{"timestamp":"2026-06-24T13:36:23.423Z","kind":"observe","slug":"obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound","title":"Intake completed: Main Street challenge celebration VFX/sound","relevance":"medium"} +{"timestamp":"2026-06-24T13:50:19.632Z","kind":"retro","slug":"tutorial-i18n-already-implemented","title":"Main Street tutorial i18n externalization already implemented","category":"uncategorized"} +{"timestamp":"2026-06-24T15:36:12.978Z","kind":"observe","slug":"obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over","title":"Stats button moved to lower-left to fix Settings button overlap","relevance":"medium"} diff --git a/.llm-wiki/meta/index.md b/.llm-wiki/meta/index.md index 57833481..a06be134 100644 --- a/.llm-wiki/meta/index.md +++ b/.llm-wiki/meta/index.md @@ -2,5 +2,14 @@ > Auto-generated from meta/registry.json. Do not edit manually. +## Sources + +- [[sources/direct-sound-playback-for-game-specific-audio]] — "Direct sound playback for game-specific audio events" *(created: 2026-06-24)* +- [[sources/obs-2026-06-24-activity-log-windowed-rendering-fix]] — "Observation: Activity Log windowed rendering fix" *(created: 2026-06-24)* +- [[sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound]] — "Observation: Intake completed: Main Street challenge celebration VFX/sound" *(created: 2026-06-24)* +- [[sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns]] — "Observation: Neutral income sound added for zero-income turns" *(created: 2026-06-24)* +- [[sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over]] — "Observation: Stats button moved to lower-left to fix Settings button overlap" *(created: 2026-06-24)* +- [[sources/tutorial-i18n-already-implemented]] — "Main Street tutorial i18n externalization already implemented" *(created: 2026-06-24)* + --- -*Last updated: 2026-06-06T22:21:48.840Z* | *Total pages: 0* +*Last updated: 2026-06-24T15:36:12.984Z* | *Total pages: 6* diff --git a/.llm-wiki/meta/log.md b/.llm-wiki/meta/log.md index c1a79a8d..1e79837d 100644 --- a/.llm-wiki/meta/log.md +++ b/.llm-wiki/meta/log.md @@ -2,5 +2,24 @@ > Auto-generated from meta/events.jsonl. Do not edit manually. -_No events recorded yet._ +## [2026-06-06T22:21:48.841Z] bootstrap +- topic: "Tableau Card Engine", mode: "personal" + +## [2026-06-24T11:39:05.029Z] observe +- slug: "obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns", title: "Neutral income sound added for zero-income turns", relevance: "medium" + +## [2026-06-24T11:39:10.273Z] retro +- slug: "direct-sound-playback-for-game-specific-audio", title: "Direct sound playback for game-specific audio events", category: "audio" + +## [2026-06-24T12:10:43.832Z] observe +- slug: "obs-2026-06-24-activity-log-windowed-rendering-fix", title: "Activity Log windowed rendering fix", relevance: "medium" + +## [2026-06-24T13:36:23.423Z] observe +- slug: "obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound", title: "Intake completed: Main Street challenge celebration VFX/sound", relevance: "medium" + +## [2026-06-24T13:50:19.632Z] retro +- slug: "tutorial-i18n-already-implemented", title: "Main Street tutorial i18n externalization already implemented", category: "uncategorized" + +## [2026-06-24T15:36:12.978Z] observe +- slug: "obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over", title: "Stats button moved to lower-left to fix Settings button overlap", relevance: "medium" diff --git a/.llm-wiki/meta/registry.json b/.llm-wiki/meta/registry.json index 8e1a9ca4..3f82911c 100644 --- a/.llm-wiki/meta/registry.json +++ b/.llm-wiki/meta/registry.json @@ -1,5 +1,85 @@ { "version": "1.0", - "last_updated": "2026-06-06T22:21:48.840Z", - "pages": {} + "last_updated": "2026-06-24T15:36:12.984Z", + "pages": { + "sources/direct-sound-playback-for-game-specific-audio": { + "type": "source", + "title": "\"Direct sound playback for game-specific audio events\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "direct-sound-playback-for-game-specific-audio", + "status": "insight", + "category": "audio" + }, + "sources/obs-2026-06-24-activity-log-windowed-rendering-fix": { + "type": "source", + "title": "\"Observation: Activity Log windowed rendering fix\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "obs-2026-06-24-activity-log-windowed-rendering-fix", + "status": "observation", + "relevance": "medium", + "observed_at": "2026-06-24T12:10:43.831Z", + "tags": [ + "main-street", + "ui", + "activity-log", + "scrolling", + "bugfix" + ], + "source_context": "\"Implementing CG-0MQK4H9180049050: Activity Log scrolls off top of container\"" + }, + "sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound": { + "type": "source", + "title": "\"Observation: Intake completed: Main Street challenge celebration VFX/sound\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound", + "status": "observation", + "relevance": "medium", + "observed_at": "2026-06-24T13:36:23.421Z", + "tags": [ + "main-street", + "intake", + "challenge", + "VFX", + "audio" + ], + "source_context": "\"Intake command for Main Street challenge celebration feature\"" + }, + "sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns": { + "type": "source", + "title": "\"Observation: Neutral income sound added for zero-income turns\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns", + "status": "observation", + "relevance": "medium", + "observed_at": "2026-06-24T11:39:05.027Z", + "tags": [ + "main-street", + "audio", + "sound" + ], + "source_context": "\"Implementing CG-0MQK4EGJ0001JN6N: No sound on some turns\"" + }, + "sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over": { + "type": "source", + "title": "\"Observation: Stats button moved to lower-left to fix Settings button overlap\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over", + "status": "observation", + "relevance": "medium", + "observed_at": "2026-06-24T15:36:12.954Z" + }, + "sources/tutorial-i18n-already-implemented": { + "type": "source", + "title": "\"Main Street tutorial i18n externalization already implemented\"", + "created": "2026-06-24", + "updated": "2026-06-24", + "slug": "tutorial-i18n-already-implemented", + "status": "insight" + } + } } diff --git a/.llm-wiki/wiki/sources/direct-sound-playback-for-game-specific-audio.md b/.llm-wiki/wiki/sources/direct-sound-playback-for-game-specific-audio.md new file mode 100644 index 00000000..8f680fb0 --- /dev/null +++ b/.llm-wiki/wiki/sources/direct-sound-playback-for-game-specific-audio.md @@ -0,0 +1,16 @@ +--- +type: source +title: "Direct sound playback for game-specific audio events" +slug: direct-sound-playback-for-game-specific-audio +status: insight +created: 2026-06-24 +updated: 2026-06-24 +category: audio +--- +# Direct sound playback for game-specific audio events +When adding game-specific sounds that don't need to be shared across games or don't require the declarative event-to-sound mapping system, playing them directly via `soundManager.play(SFX_KEY)` is simpler than adding new event types to the core engine's `GameEventMap`. This avoids polluting the core engine with game-specific concerns while still benefiting from the SoundManager's namespacing, volume, mute, and synth integration. The Key pattern: define the SFX key constant in the game's constants file, add it to the synth/tf mapping if needed, preload the audio asset, and call `soundManager.play()` directly. +*Category: audio* +--- +*Captured: 2026-06-24* +## Related +_Add links to related pages._ \ No newline at end of file diff --git a/.llm-wiki/wiki/sources/obs-2026-06-24-activity-log-windowed-rendering-fix.md b/.llm-wiki/wiki/sources/obs-2026-06-24-activity-log-windowed-rendering-fix.md new file mode 100644 index 00000000..8c623bce --- /dev/null +++ b/.llm-wiki/wiki/sources/obs-2026-06-24-activity-log-windowed-rendering-fix.md @@ -0,0 +1,21 @@ +--- +type: source +title: "Observation: Activity Log windowed rendering fix" +slug: obs-2026-06-24-activity-log-windowed-rendering-fix +status: observation +created: 2026-06-24 +updated: 2026-06-24 +relevance: medium +observed_at: 2026-06-24T12:10:43.831Z +tags: ["main-street", "ui", "activity-log", "scrolling", "bugfix"] +source_context: "Implementing CG-0MQK4H9180049050: Activity Log scrolls off top of container" +--- +# 🔍 Observation: Activity Log windowed rendering fix +Fixed Activity Log content overflowing its container by implementing windowed rendering. Previously all log entries were rendered in the logContentContainer with a geometry mask clipping the overflow. Now only entries within the visible panel area are rendered, determined by calculating maxDisplayEntries from the panel height divided by LOG_LINE_H. The scroll offset (logScrollOffset) is converted to a start index, and only that window of entries is rendered. The container stays at its initial Y position instead of being moved. The handleLogWheel handler triggers refreshLog after each scroll change. Commit 52715d7c pushed to dev. +*Relevance: medium* + +*Context: Implementing CG-0MQK4H9180049050: Activity Log scrolls off top of container* + +*Tags: main-street ui activity-log scrolling bugfix* +--- +*Observed: 2026-06-24T12:10:43.831Z* \ No newline at end of file diff --git a/.llm-wiki/wiki/sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound.md b/.llm-wiki/wiki/sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound.md new file mode 100644 index 00000000..ffd11462 --- /dev/null +++ b/.llm-wiki/wiki/sources/obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound.md @@ -0,0 +1,21 @@ +--- +type: source +title: "Observation: Intake completed: Main Street challenge celebration VFX/sound" +slug: obs-2026-06-24-intake-completed-main-street-challenge-celebration-vfx-sound +status: observation +created: 2026-06-24 +updated: 2026-06-24 +relevance: medium +observed_at: 2026-06-24T13:36:23.421Z +tags: ["main-street", "intake", "challenge", "VFX", "audio"] +source_context: "Intake command for Main Street challenge celebration feature" +--- +# 🔍 Observation: Intake completed: Main Street challenge celebration VFX/sound +Completed intake for a new feature (CG-0MQS46G5U001CYUG) to add celebration VFX (particle burst) and sound effects when challenges are completed in Main Street. The `processEndOfTurn()` function currently discards the return value of `evaluateChallenges()`, so the first implementation step is to capture newly completed challenge IDs, surface them to the UI, and trigger particle VFX (following the GymAudioFeedbackScene pattern) plus a new `sfx-challenge-complete` sound via the existing SoundManager/SFX_KEYS/tf-mapping infrastructure. Estimated Small effort (8.67h expected), Low risk. +*Relevance: medium* + +*Context: Intake command for Main Street challenge celebration feature* + +*Tags: main-street intake challenge VFX audio* +--- +*Observed: 2026-06-24T13:36:23.421Z* \ No newline at end of file diff --git a/.llm-wiki/wiki/sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns.md b/.llm-wiki/wiki/sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns.md new file mode 100644 index 00000000..8575453b --- /dev/null +++ b/.llm-wiki/wiki/sources/obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns.md @@ -0,0 +1,21 @@ +--- +type: source +title: "Observation: Neutral income sound added for zero-income turns" +slug: obs-2026-06-24-neutral-income-sound-added-for-zero-income-turns +status: observation +created: 2026-06-24 +updated: 2026-06-24 +relevance: medium +observed_at: 2026-06-24T11:39:05.027Z +tags: ["main-street", "audio", "sound"] +source_context: "Implementing CG-0MQK4EGJ0001JN6N: No sound on some turns" +--- +# 🔍 Observation: Neutral income sound added for zero-income turns +Added a neutral sound effect (sfx-income-neutral) that plays when turn-end net income is 0 in Main Street. Previously only positive income (delta > 0) triggered the coin-pop sound. The neutral sound is played directly via soundManager.play() in MainStreetAnimator.animateHudValueChanges when coin delta equals 0. The tf factory key is 'income-neutral-chime'. Preload reuses click.wav as the fallback WAV asset. No core engine changes were needed — the sound is played directly rather than through a new game event. Commit a5ea5229 pushed to dev. +*Relevance: medium* + +*Context: Implementing CG-0MQK4EGJ0001JN6N: No sound on some turns* + +*Tags: main-street audio sound* +--- +*Observed: 2026-06-24T11:39:05.027Z* \ No newline at end of file diff --git a/.llm-wiki/wiki/sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over.md b/.llm-wiki/wiki/sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over.md new file mode 100644 index 00000000..a6200409 --- /dev/null +++ b/.llm-wiki/wiki/sources/obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over.md @@ -0,0 +1,15 @@ +--- +type: source +title: "Observation: Stats button moved to lower-left to fix Settings button overlap" +slug: obs-2026-06-24-stats-button-moved-to-lower-left-to-fix-settings-button-over +status: observation +created: 2026-06-24 +updated: 2026-06-24 +relevance: medium +observed_at: 2026-06-24T15:36:12.954Z +--- +# 🔍 Observation: Stats button moved to lower-left to fix Settings button overlap +The StatsButton in StatsOverlay.ts was positioned at (canvasWidth - 80, 32), exactly overlapping the SettingsButton (also at canvasWidth - 80, 32). Fixed by moving it to the lower-left corner at (MARGIN + radius, canvasHeight - MARGIN - radius) = (32, canvasHeight - 32). Also standardized the visual style (fill 0x333355, border 0xf0c040, text #f0c040, hover 0x4444aa) to match HelpButton and SettingsButton exactly. +*Relevance: medium* +--- +*Observed: 2026-06-24T15:36:12.954Z* \ No newline at end of file diff --git a/.llm-wiki/wiki/sources/tutorial-i18n-already-implemented.md b/.llm-wiki/wiki/sources/tutorial-i18n-already-implemented.md new file mode 100644 index 00000000..10e57fea --- /dev/null +++ b/.llm-wiki/wiki/sources/tutorial-i18n-already-implemented.md @@ -0,0 +1,23 @@ +--- +type: source +title: "Main Street tutorial i18n externalization already implemented" +slug: tutorial-i18n-already-implemented +status: insight +created: 2026-06-24 +updated: 2026-06-24 +--- +# Main Street tutorial i18n externalization already implemented +Work item **CG-0MP415DUV0032SKK** (Main Street tutorial localization and i18n externalization) was already fully implemented in commit `76450222` which was already in the `dev` branch ancestry. Upon investigation: + +- The core engine's `I18n.ts` module provides `t()`, `registerLocale()`, `setLocale()`, and `resetI18n()` APIs +- Tutorial step definitions in `TutorialFlow.ts` use `titleKey`/`bodyKey` i18n key references instead of hardcoded strings +- `example-games/main-street/i18n/tutorial-en.ts` contains all 13 tutorial steps + modal + overlay button strings, registered at module load +- `tests/main-street/tutorial-i18n.test.ts` has 16 tests verifying all keys exist, locale switching works, and `resolveTutorialStepText()` works correctly +- `docs/main-street/tutorial-localization.md` documents how to edit copy and add translations + +The work item was stale (still `plan_complete` despite being implemented). Audit confirmed all 5 acceptance criteria met. Updated to `in_review` status. + +--- +*Captured: 2026-06-24* +## Related +_Add links to related pages._ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b48311..4f6706a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## v0.1.5 (2026-07-14) +### Features +- Golf AI should consider vertical columns (CG-0MQ7W12C3002HP7L) +- Golf AI: add skill rating (1-100) for strategic card memory (CG-0MQIOPP1L004OYYA) +- Use Euro not dollar (CG-0MQK3PQFG001JLYL) +- Ensure SVGs are regenerated when CSV changes on game start and load (CG-0MRF343AJ004N6ZW) +- Extract CardMemoryTracker into generic core AI memory system (CG-0MRH2FERF001OGWB) +- Illegal Move sound (CG-0MQK1GC79000CQIJ) +- Create a standard hint object for all games. (CG-0MQK3M237009GR6E) +- discard shrinks the card - not a good UX (CG-0MQK5X1ZK0050TLZ) +- Save Load scene needs randomize hand (CG-0MQK6SKLT0033TIJ) +- Ensure card data is loaded from CSV when a new game starts (CG-0MRH36Z6800065JC) +- Screen layout for Feudalism (CG-0MQK0OM6I00168HD) +- AI in Lost Cities is dumb (CG-0MQK1C4UA0059VWB) +- Settings should only show tooltips switch if they exist (CG-0MQK1UDDC0027MKQ) +- Discards face up or down (CG-0MQK5T8AT009E1EN) +- Scrollable overlay is not demonstrated well (CG-0MQK657MO002X269) +- Audio and Feedback Config gym improvement (CG-0MQK6ZSTP0091CA3) +- Transcript recording needs more detail (CG-0MQK6GBYD0078HSA) +### Bug Fixes +- Sushi Go played cards not rendered (CG-0MRDU64K4000APKM) +- Fix regression: income/reputation overlay text positioned at card right edge instead of center (CG-0MRFMNXDM0091CBG) +- Fix 12 pre-existing test failures from card-data.csv balance changes (CG-0MRIHJSE20075TNI) +- Fix 3 pre-existing browser test failures (HUD layer contract + Golf overlay) (CG-0MRIYGE410044PZI) +- [test-failure] tutorial-text-updates > T3 body text (AC2) > should mention the cost of the card to buy — failing test (CG-0MRFP08A40088F5L) +- Fix toggle label format inconsistency in GymLayoutOwnershipScene (CG-0MRE3ZKF10074GER) +- Fix removeUngrouped to only target ungrouped items (CG-0MRE3ZKFW002S3ND) +### Other +- CardMemoryTracker: core memory model (CG-0MRCLYBUR002GFCZ) +- Augment countVisibleRanks with memory support (CG-0MRCLYBVH0011SA4) +- Integrate memory into chooseDrawSource (CG-0MRCLYBVM005XRXA) +- GolfScene wires skill rating through to AI (CG-0MRCLYBWM001FA8H) +- AiPlayer integration: skill rating and memory tracking (CG-0MRCLYBYW003HMPT) +- Documentation and test coverage (CG-0MRCMVBJM001MJ45) +- Replace $ with € symbol across all games (CG-0MQQHESZW002QCS6) +- Integrate currency symbol with localization system (CG-0MQQHETXO0064TMZ) +- Update all thumbnails on the menu screen (CG-0MQK3CJUX0043I3U) +- Create shared HintBar component in core engine UI (CG-0MQQHESOG002VOBK) +- Migrate Main Street and other games to use shared HintBar (CG-0MQQHETIF002MZZD) +- Add Face Up/Down toggle button for discard pile (CG-0MQQIMO18001NZNE) + ## v0.1.4 (2026-07-10) ### Features - Migrate Gym scenes to Screen Layout Language (SLL) (CG-0MPF8WT920076RQ5) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 1c501aa0..abf6ae45 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -263,7 +263,8 @@ example-games/ │ ├── GolfRules.ts Turn legality, move application, round-end detection │ ├── GolfScoring.ts Card point values, grid scoring, column matching │ ├── GolfGame.ts Game orchestration (session setup, turn execution) -│ ├── AiStrategy.ts AI strategies (RandomStrategy, GreedyStrategy) +│ ├── AiStrategy.ts AI players with configurable skillRating (default 80) and +│ │ CardMemoryTracker for discard-pile memory across turns │ ├── GameTranscript.ts Transcript recording (TranscriptRecorder) │ └── scenes/ │ └── GolfScene.ts Phaser scene (full visual interface) diff --git a/docs/SFX_CONVENTION.md b/docs/SFX_CONVENTION.md index d60f2bd8..33b505a9 100644 --- a/docs/SFX_CONVENTION.md +++ b/docs/SFX_CONVENTION.md @@ -1,7 +1,7 @@ # SFX Key Naming Convention -> **Last updated:** 2026-06-17 -> **Related work-item:** CG-0MM1OQN4E153GJY3 — SFX key naming inconsistency and potential collision +> **Last updated:** 2026-07-11 +> **Related work-items:** CG-0MM1OQN4E153GJY3 — SFX key naming inconsistency and potential collision; CG-0MQK1GC79000CQIJ — Illegal Move sound ## Overview @@ -36,6 +36,7 @@ const MY_SFX_KEYS = { | `COMMON_SFX_KEYS.TURN_CHANGE` | `sfx-turn-change` | Active player changes | | `COMMON_SFX_KEYS.ROUND_END` | `sfx-round-end` | A round has ended | | `COMMON_SFX_KEYS.SCORE_REVEAL` | `sfx-score-reveal` | Scores are being revealed | +| `COMMON_SFX_KEYS.ILLEGAL_MOVE` | `sfx-illegal-move` | Illegal / invalid action feedback (played automatically by {@link shakeIllegalMove}) | ## Audio Asset Organization @@ -46,6 +47,7 @@ public/assets/audio/ ├── default/ # Fallback sounds for common SFX keys │ ├── card-draw.wav │ ├── card-flip.wav +│ ├── illegal-move.wav │ ├── ui-click.wav │ └── ... ├── golf/ # Game-specific audio diff --git a/docs/main-street/monte-carlo-baseline.json b/docs/main-street/monte-carlo-baseline.json index adc7667e..0e47bbb3 100644 --- a/docs/main-street/monte-carlo-baseline.json +++ b/docs/main-street/monte-carlo-baseline.json @@ -1,12 +1,12 @@ { "source": "Generated from MainStreetMonteCarlo.runMonteCarlo", - "generatedAt": "2026-07-10T10:29:03.028Z", + "generatedAt": "2026-07-13T00:56:15.644Z", "seeds": 200, "maxTurns": 25, "strategy": "greedy", - "note": "Updated after CG-0MRER3RE300418SG: fractional income fix removed Math.floor from applyReputationMultiplier and Math.round from applyActiveEffectMultiplier. Win rate and coins-per-turn increased because fractional income values are no longer truncated.", + "note": "Updated after CG-0MRIHJSE20075TNI: reverted name changes, kept balance rebalance.", "metrics": { - "winRate": 0.65, - "averageCoinsPerTurn": 3.300755112659271 + "winRate": 0.705, + "averageCoinsPerTurn": 4.145124260675853 } -} +} \ No newline at end of file diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleConstants.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleConstants.ts index 75fc000d..831d6cf3 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleConstants.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleConstants.ts @@ -25,6 +25,7 @@ export const SFX_KEYS = { CARD_SELECT: 'sfx-card-select', CARD_DESELECT: 'sfx-card-deselect', UI_CLICK: COMMON_SFX_KEYS.UI_CLICK, + ILLEGAL_MOVE: COMMON_SFX_KEYS.ILLEGAL_MOVE, } as const; // ── Card dimensions ─────────────────────────────────────── diff --git a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts index a597e5da..9d4b902c 100644 --- a/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts +++ b/example-games/beleaguered-castle/scenes/BeleagueredCastleScene.ts @@ -35,10 +35,12 @@ import { RESUME_TITLE_FONT_SIZE, RESUME_TITLE_Y_OFFSET, RESUME_INFO_FONT_SIZE, RESUME_INFO_Y_OFFSET, RESUME_BUTTON_SPACING, RESUME_BUTTON_Y_OFFSET, + SNAP_BACK_DURATION, } from './BeleagueredCastleConstants'; import { BeleagueredCastleRenderer } from './BeleagueredCastleRenderer'; import { BeleagueredCastleTurnController } from './BeleagueredCastleTurnController'; import { moveGameObject, cardTextureKey } from '../../../src/ui'; +import { shakeIllegalMove } from '../../../src/ui/shakeIllegalMove'; import { GAME_W, GAME_H, FONT_FAMILY, createOverlayButton, @@ -98,6 +100,8 @@ export class BeleagueredCastleScene extends CardGameScene { this.load.audio(`${ns}:${SFX_KEYS.CARD_SELECT}`, audioPathWithFallback(audioDir, 'card-select.wav')); this.load.audio(`${ns}:${SFX_KEYS.CARD_DESELECT}`, audioPathWithFallback(audioDir, 'card-deselect.wav')); this.load.audio(`${ns}:${SFX_KEYS.UI_CLICK}`, audioPathWithFallback(audioDir, 'ui-click.wav')); + this.load.audio(`${ns}:${SFX_KEYS.ILLEGAL_MOVE}`, audioPathWithFallback(audioDir, 'illegal-move.wav')); + this.load.audio(SFX_KEYS.ILLEGAL_MOVE, 'assets/audio/default/illegal-move.wav'); } create(): void { @@ -153,6 +157,7 @@ export class BeleagueredCastleScene extends CardGameScene { this.bcRenderer.onCardClick = (col) => this.handleCardClick(col); if (!this.replayMode) { + this.initHUDContainer(); this.initHelpPanel(helpContent as HelpSection[]); const mapping: EventSoundMapping = { 'card-pickup': SFX_KEYS.CARD_PICKUP, @@ -170,7 +175,7 @@ export class BeleagueredCastleScene extends CardGameScene { 'ui-interaction': SFX_KEYS.UI_CLICK, }; this.initSoundSystem(Object.values(SFX_KEYS), mapping, { namespace: 'beleaguered-castle' }); - this.initSettingsPanel(); + this.initSettingsPanel(undefined, undefined, false); // Propagate reduced motion preference to the renderer if (this.settingsPanel) { this.bcRenderer.reducedMotion = this.settingsPanel.reducedMotion; @@ -222,7 +227,12 @@ export class BeleagueredCastleScene extends CardGameScene { this.input.on('dragend', (_pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Image, dropped: boolean) => { if (this.interactionBlocked) return; this.bcRenderer.clearDropHighlights(); - if (!dropped) this.bcRenderer.snapBack(gameObject); + if (!dropped) { + this.bcRenderer.snapBack(gameObject); + this.time.delayedCall(SNAP_BACK_DURATION, () => { + shakeIllegalMove({ scene: this, target: gameObject }); + }); + } }); this.input.on('drop', (_pointer: Phaser.Input.Pointer, gameObject: Phaser.GameObjects.Image, dropZone: Phaser.GameObjects.Zone) => { @@ -250,6 +260,9 @@ export class BeleagueredCastleScene extends CardGameScene { this.turnController.executePlayerMove(move); } else { this.bcRenderer.snapBack(sprite); + this.time.delayedCall(SNAP_BACK_DURATION, () => { + shakeIllegalMove({ scene: this, target: sprite }); + }); } } @@ -267,6 +280,11 @@ export class BeleagueredCastleScene extends CardGameScene { this.deselectColumn(); this.turnController.executePlayerMove(move); } else { + // Illegal move feedback + const sprs = this.bcRenderer.tableauSprs[this.selectedCol]; + if (sprs && sprs.length > 0) { + shakeIllegalMove({ scene: this, target: sprs[sprs.length - 1] }); + } this.deselectColumn(); } }); @@ -284,6 +302,11 @@ export class BeleagueredCastleScene extends CardGameScene { this.deselectColumn(); this.turnController.executePlayerMove(move); } else { + // Illegal move feedback + const sprs = this.bcRenderer.tableauSprs[this.selectedCol]; + if (sprs && sprs.length > 0) { + shakeIllegalMove({ scene: this, target: sprs[sprs.length - 1] }); + } this.deselectColumn(); } }); @@ -300,6 +323,11 @@ export class BeleagueredCastleScene extends CardGameScene { this.turnController.executePlayerMove(move); return; } + // Illegal move feedback + const sprs = this.bcRenderer.tableauSprs[this.selectedCol]; + if (sprs && sprs.length > 0) { + shakeIllegalMove({ scene: this, target: sprs[sprs.length - 1] }); + } this.deselectColumn(); } this.selectColumn(colIndex); @@ -710,6 +738,7 @@ export class BeleagueredCastleScene extends CardGameScene { playAgainLabel: 'Play Again', menuLabel: 'Menu', extraButtons: [{ label: 'Restart', onClick: () => this.onRestart?.() }], + background: { depth: OVERLAY_DEPTH, alpha: OVERLAY_BG_ALPHA }, }); this.overlayManager.add(...result.objects); } diff --git a/example-games/feudalism/scenes/FeudalismConstants.ts b/example-games/feudalism/scenes/FeudalismConstants.ts index d3c5eacb..f5dd3877 100644 --- a/example-games/feudalism/scenes/FeudalismConstants.ts +++ b/example-games/feudalism/scenes/FeudalismConstants.ts @@ -64,7 +64,7 @@ export const PATRON_X = 10; export const SUPPLY_TOKEN_R = 28; export const SUPPLY_GAP = 62; export const SUPPLY_TOTAL_H = 5 * SUPPLY_GAP; -export const SUPPLY_X = 1170; +export const SUPPLY_X = GAME_W - 50 - SUPPLY_TOKEN_R; export const SUPPLY_Y = UPPER_MID - SUPPLY_TOTAL_H / 2; // Card market @@ -95,7 +95,7 @@ export const AI_AREA_Y = LOWER_TOP; export const DIVIDER_X = 640; // Action buttons -export const ACTION_Y = 652; +export const ACTION_Y = 670; export const INSTRUCTION_Y = 708; // ── Audio asset keys ────────────────────────────────────── diff --git a/example-games/feudalism/scenes/FeudalismRenderer.ts b/example-games/feudalism/scenes/FeudalismRenderer.ts index eed1a973..77582095 100644 --- a/example-games/feudalism/scenes/FeudalismRenderer.ts +++ b/example-games/feudalism/scenes/FeudalismRenderer.ts @@ -7,9 +7,8 @@ import { RESOURCE_TYPES, tokenCount, resourceAbbrev, - resourceDisplayName, formatCost, - tierShortName, + tierDisplayName, } from '../FeudalismCards'; import type { FeudalismSession } from '../FeudalismGame'; import { getInfluence, getBonuses } from '../FeudalismGame'; @@ -165,34 +164,47 @@ export class FeudalismRenderer { this.sectionBoxContainer.removeAll(true); const p = SECTION_BOX_PAD; - this.drawSectionBox(PATRON_X - p, MARKET_Y - p - 16, PATRON_W + p * 2, MARKET_TOTAL_H + p * 2 + 16, 'Patrons'); - - const lastCardRight = MARKET_X + 4 * (MARKET_CARD_W + MARKET_CARD_GAP) - MARKET_CARD_GAP; - const marketBoxX = DECK_X - 90 - p; - this.drawSectionBox(marketBoxX, MARKET_Y - p - 16, lastCardRight - marketBoxX + p, MARKET_TOTAL_H + p * 2 + 16, 'Market'); - + // Patrons section — no label, top border adjusted down + this.drawSectionBox( + PATRON_X - p, + MARKET_Y - p, + PATRON_W + p * 2, + MARKET_TOTAL_H + p * 2, + ); + + // Market section — expanded to fill between Patrons and Supply with small equal margins + const patronsBoxRight = (PATRON_X - p) + (PATRON_W + p * 2); + const supplyBoxLeft = SUPPLY_X - SUPPLY_TOKEN_R - 70 - p; + const marketBoxX = patronsBoxRight + p; + const marketBoxW = supplyBoxLeft - p - marketBoxX; + this.drawSectionBox( + marketBoxX, + MARKET_Y - p, + marketBoxW, + MARKET_TOTAL_H + p * 2, + ); + + // Supply section — no label, top border adjusted down, right-aligned const supplyBoxX = SUPPLY_X - SUPPLY_TOKEN_R - 70 - p; - const supplyBoxY = SUPPLY_Y - SUPPLY_TOKEN_R - p - 16; - this.drawSectionBox(supplyBoxX, supplyBoxY, SUPPLY_TOKEN_R + 70 + SUPPLY_TOKEN_R + p * 2, SUPPLY_TOTAL_H + SUPPLY_TOKEN_R * 2 + p * 2 + 16, 'Supply'); + const supplyBoxY = SUPPLY_Y - SUPPLY_TOKEN_R - p; + this.drawSectionBox( + supplyBoxX, + supplyBoxY, + SUPPLY_TOKEN_R + 70 + SUPPLY_TOKEN_R + p * 2, + SUPPLY_TOTAL_H + SUPPLY_TOKEN_R * 2 + p * 2, + ); this.drawSectionBox(PLAYER_AREA_X - p, LOWER_TOP - p, DIVIDER_X - PLAYER_AREA_X, LOWER_BOX_H); this.drawSectionBox(DIVIDER_X + p, LOWER_TOP - p, AI_AREA_X - DIVIDER_X + p, LOWER_BOX_H); } - private drawSectionBox(x: number, y: number, w: number, h: number, label?: string): void { + private drawSectionBox(x: number, y: number, w: number, h: number): void { const gfx = this.scene.add.graphics(); gfx.fillStyle(SECTION_BOX_FILL, SECTION_BOX_FILL_ALPHA); gfx.fillRoundedRect(x, y, w, h, SECTION_BOX_RADIUS); gfx.lineStyle(1, SECTION_BOX_STROKE, SECTION_BOX_ALPHA); gfx.strokeRoundedRect(x, y, w, h, SECTION_BOX_RADIUS); this.sectionBoxContainer.add(gfx); - - if (label) { - const txt = this.scene.add.text(x + w / 2, y + 2, label, { - fontSize: '12px', fontStyle: 'bold', color: '#667766', fontFamily: FONT_FAMILY, - }).setOrigin(0.5, 0); - this.sectionBoxContainer.add(txt); - } } // ── Refresh all ───────────────────────────────────────── @@ -222,11 +234,6 @@ export class FeudalismRenderer { const y = MARKET_Y + row * (MARKET_CARD_H + MARKET_TIER_GAP); const market = this.session.market[tier]; - const tierLabel = this.scene.add.text(DECK_X - 40, y + MARKET_CARD_H / 2, `${tierShortName(tier)}`, { - fontSize: '18px', fontStyle: 'bold', color: '#888888', fontFamily: FONT_FAMILY, - }).setOrigin(0.5); - this.marketContainer.add(tierLabel); - const deckCount = market.deck.length; const deckW = 100; const deckH = MARKET_CARD_H - 16; @@ -235,11 +242,16 @@ export class FeudalismRenderer { this.marketContainer.add(deckBg); if (deckCount > 0) { - const deckText = this.scene.add.text(DECK_X, y + MARKET_CARD_H / 2, `${deckCount}`, { + const deckText = this.scene.add.text(DECK_X, y + MARKET_CARD_H / 2 - 8, `${deckCount}`, { fontSize: '20px', fontStyle: 'bold', color: '#aaddaa', fontFamily: FONT_FAMILY, }).setOrigin(0.5); this.marketContainer.add(deckText); + const tierName = this.scene.add.text(DECK_X, y + MARKET_CARD_H / 2 + 14, tierDisplayName(tier), { + fontSize: '12px', color: '#88aa88', fontFamily: FONT_FAMILY, + }).setOrigin(0.5); + this.marketContainer.add(tierName); + if (this.turnPhase === 'player-turn') { deckBg.setInteractive({ useHandCursor: true }); deckBg.on('pointerdown', () => callbacks.onReserveDeck(tier)); @@ -400,14 +412,8 @@ export class FeudalismRenderer { refreshSupply(callbacks: SupplyCallbacks): void { this.supplyContainer.removeAll(true); - const label = this.scene.add.text(SUPPLY_X, SUPPLY_Y - SUPPLY_TOKEN_R - 8, 'Supply', { - fontSize: '13px', fontStyle: 'bold', color: '#99bb99', fontFamily: FONT_FAMILY, - }).setOrigin(0.5, 1); - this.supplyContainer.add(label); - - const allColors: ResourceOrWild[] = [...RESOURCE_TYPES, 'mead']; - for (let i = 0; i < allColors.length; i++) { - const color = allColors[i]; + for (let i = 0; i < RESOURCE_TYPES.length; i++) { + const color = RESOURCE_TYPES[i]; const y = SUPPLY_Y + i * SUPPLY_GAP; const count = tokenCount(this.session.tokenSupply, color); @@ -425,19 +431,14 @@ export class FeudalismRenderer { }).setOrigin(0.5); this.supplyContainer.add(countText); - const abbr = this.scene.add.text(SUPPLY_X - SUPPLY_TOKEN_R - 8, y, resourceDisplayName(color), { - fontSize: '15px', color: '#aaaaaa', fontFamily: FONT_FAMILY, - }).setOrigin(1, 0.5); - this.supplyContainer.add(abbr); - - if (color !== 'mead' && count > 0 && this.turnPhase === 'selecting-tokens') { + if (count > 0 && this.turnPhase === 'selecting-tokens') { circle.setInteractive({ useHandCursor: true }); - circle.on('pointerdown', () => callbacks.onSupplyTokenClick(color as ResourceType)); + circle.on('pointerdown', () => callbacks.onSupplyTokenClick(color)); circle.on('pointerover', () => circle.setStrokeStyle(3, 0xffdd44)); circle.on('pointerout', () => circle.setStrokeStyle(2, 0xffffff)); } - if (this.selectedTokens.includes(color as ResourceType)) { + if (this.selectedTokens.includes(color)) { const check = this.scene.add.text(SUPPLY_X + SUPPLY_TOKEN_R + 10, y, '✓', { fontSize: '22px', fontStyle: 'bold', color: '#44ff44', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5); @@ -542,12 +543,7 @@ export class FeudalismRenderer { cursorX += delta; } - if (tokenEntries.length === 0) { - const noTok = this.scene.add.text(options.tokenStartX, options.y, '(none)', { - fontSize: '14px', color: '#666666', fontFamily: FONT_FAMILY, - }).setOrigin(options.emptyAlign === 'right' ? 1 : 0, 0.5); - container.add(noTok); - } + // (none) label removed per CG-0MQK0OM6I00168HD } private renderTokenBubble( diff --git a/example-games/feudalism/scenes/FeudalismScene.ts b/example-games/feudalism/scenes/FeudalismScene.ts index f305ae2b..8a90ae19 100644 --- a/example-games/feudalism/scenes/FeudalismScene.ts +++ b/example-games/feudalism/scenes/FeudalismScene.ts @@ -38,7 +38,6 @@ import { FeudalismTurnController } from './FeudalismTurnController'; import { FeudalismReplayController } from './FeudalismReplayController'; import { SECTION_BOX_PAD, PATRON_X, PATRON_W, MARKET_Y, MARKET_TOTAL_H, - DECK_X, MARKET_X, MARKET_CARD_W, MARKET_CARD_GAP, SUPPLY_X, SUPPLY_TOKEN_R, SUPPLY_Y, SUPPLY_TOTAL_H, LOWER_TOP, LOWER_BOX_H, PLAYER_AREA_X, DIVIDER_X, AI_AREA_X, ACTION_Y, INSTRUCTION_Y, @@ -82,6 +81,8 @@ export class FeudalismScene extends CardGameScene { super.create(); + this.replayController = new FeudalismReplayController(); + if (this.replayMode) { this.createHeader(); this.feudRenderer = new FeudalismRenderer(this, this.session); @@ -156,14 +157,13 @@ export class FeudalismScene extends CardGameScene { }); this.turnController.setRecorder(this.recorder); - this.replayController = new FeudalismReplayController(); this.createHeader(); this.feudRenderer.createContainers(); this.feudRenderer.createInstructions(); this.feudRenderer.createInfluenceDisplay(); this.initHelpPanel(helpContent as HelpSection[]); - this.initSettingsPanel(); + this.initSettingsPanel(undefined, undefined, false); // Propagate reduced motion preference to the animator if (this.settingsPanel) { this.animator.reducedMotion = this.settingsPanel.reducedMotion; @@ -181,7 +181,9 @@ export class FeudalismScene extends CardGameScene { } private refreshAll(): void { - this.feudRenderer.turnPhase = this.turnController.phase; + if (this.turnController) { + this.feudRenderer.turnPhase = this.turnController.phase; + } this.feudRenderer.selectedTokens = this.selectedTokens; this.feudRenderer.discardSelection = this.discardSelection; this.feudRenderer.discardNeeded = this.discardNeeded; @@ -364,11 +366,14 @@ export class FeudalismScene extends CardGameScene { // ── Test accessors ────────────────────────────────────── getSectionBoxRects() { const p = SECTION_BOX_PAD; - const lastCardRight = MARKET_X + 4 * (MARKET_CARD_W + MARKET_CARD_GAP) - MARKET_CARD_GAP; + const patronsBoxRight = (PATRON_X - p) + (PATRON_W + p * 2); + const supplyBoxLeft = SUPPLY_X - SUPPLY_TOKEN_R - 70 - p; + const marketBoxX = patronsBoxRight + p; + const marketBoxW = supplyBoxLeft - p - marketBoxX; return { - patrons: { x: PATRON_X - p, y: MARKET_Y - p - 16, w: PATRON_W + p * 2, h: MARKET_TOTAL_H + p * 2 + 16 }, - market: { x: DECK_X - 90 - p, y: MARKET_Y - p - 16, w: lastCardRight - (DECK_X - 90 - p) + p, h: MARKET_TOTAL_H + p * 2 + 16 }, - supply: { x: SUPPLY_X - SUPPLY_TOKEN_R - 70 - p, y: SUPPLY_Y - SUPPLY_TOKEN_R - p - 16, w: SUPPLY_TOKEN_R + 70 + SUPPLY_TOKEN_R + p * 2, h: SUPPLY_TOTAL_H + SUPPLY_TOKEN_R * 2 + p * 2 + 16 }, + patrons: { x: PATRON_X - p, y: MARKET_Y - p, w: PATRON_W + p * 2, h: MARKET_TOTAL_H + p * 2 }, + market: { x: marketBoxX, y: MARKET_Y - p, w: marketBoxW, h: MARKET_TOTAL_H + p * 2 }, + supply: { x: SUPPLY_X - SUPPLY_TOKEN_R - 70 - p, y: SUPPLY_Y - SUPPLY_TOKEN_R - p, w: SUPPLY_TOKEN_R + 70 + SUPPLY_TOKEN_R + p * 2, h: SUPPLY_TOTAL_H + SUPPLY_TOKEN_R * 2 + p * 2 }, player: { x: PLAYER_AREA_X - p, y: LOWER_TOP - p, w: DIVIDER_X - PLAYER_AREA_X, h: LOWER_BOX_H }, ai: { x: DIVIDER_X + p, y: LOWER_TOP - p, w: AI_AREA_X - DIVIDER_X + p, h: LOWER_BOX_H }, }; diff --git a/example-games/golf/AiStrategy.ts b/example-games/golf/AiStrategy.ts index 47540a49..9d5f7efb 100644 --- a/example-games/golf/AiStrategy.ts +++ b/example-games/golf/AiStrategy.ts @@ -27,7 +27,7 @@ import type { import { enumerateAiLegalMoves, enumerateAiDrawSources } from './GolfGame'; import { GRID_ROWS, GRID_COLS } from './GolfGrid'; import type { AiStrategyBase } from '../../src/ai'; -import { AiPlayer as AiPlayerBase, pickRandom, pickBest } from '../../src/ai'; +import { AiPlayer as AiPlayerBase, pickRandom, pickBest, CardMemoryTracker } from '../../src/ai'; // ── Strategy interface ────────────────────────────────────── @@ -42,15 +42,18 @@ export interface AiStrategy extends AiStrategyBase { /** * Choose an action (draw source + move) for the current player. * - * @param playerState The AI player's visible state (face-down cards hidden). - * @param shared Visible shared game state (no stock pile access). - * @param rng Random number generator (for tie-breaking or random choice). - * @returns The chosen action. + * @param playerState The AI player's visible state (face-down cards hidden). + * @param shared Visible shared game state (no stock pile access). + * @param rng Random number generator (for tie-breaking or random choice). + * @param memoryTracker Optional memory tracker for probabilistic recall + * of historical discard cards. + * @returns The chosen action. */ chooseAction( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, rng: () => number, + memoryTracker?: CardMemoryTracker, ): GolfAction; } @@ -122,12 +125,13 @@ export const GreedyStrategy: AiStrategy = { playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, rng: () => number, + memoryTracker?: CardMemoryTracker, ): GolfAction { - const drawSource = chooseDrawSource(playerState, shared, rng); + const drawSource = chooseDrawSource(playerState, shared, rng, undefined, memoryTracker); if (drawSource === 'discard' && shared.discardTop) { // Compute visible rank counts for column-feasibility weighting - const visibleRanks = countVisibleRanks(playerState, shared); + const visibleRanks = countVisibleRanks(playerState, shared, rng, memoryTracker); // We know the discard card — evaluate moves with it const move = chooseMoveForCard( playerState.grid, @@ -171,6 +175,7 @@ export function chooseDrawSource( shared: AiVisibleSharedState, _rng: () => number, config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, + memoryTracker?: CardMemoryTracker, ): DrawSource { const sources = enumerateAiDrawSources(shared); if (sources.length === 1) return sources[0]; @@ -208,7 +213,7 @@ export function chooseDrawSource( // Even if discard doesn't immediately improve the score, check if it // helps build a column match and unknown copies of that rank remain. - const visibleRanks = countVisibleRanks(playerState, shared); + const visibleRanks = countVisibleRanks(playerState, shared, _rng, memoryTracker); // Check if any legal swap move with the discard card would build toward // a column match (2 matching cards in column) with feasible potential @@ -269,6 +274,8 @@ const MAX_RANK_COPIES = 4; * Visible cards include: * - Face-up cards in the AI's own grid * - The discard pile top card (visible to all players) + * - (When a memory tracker is provided) Historical discard cards the AI + * has observed, with probabilistic recall based on skill rating * * Face-down cards are NOT counted (the AI doesn't know their ranks). * @@ -279,17 +286,26 @@ const MAX_RANK_COPIES = 4; * * Information boundary: uses only AI-visible state projections. * - * @param playerState AI-visible per-player state - * @param shared AI-visible shared state (discard top, stock flag) - * @returns Record mapping rank strings to their visible count + * @param playerState AI-visible per-player state + * @param shared AI-visible shared state (discard top, stock flag) + * @param rng Optional RNG for probabilistic memory recall. + * Required when {@link memoryTracker} is provided. + * @param memoryTracker Optional CardMemoryTracker providing probabilistic + * recall of historical discard cards. When provided, + * the current discard top card is counted perfectly + * (not subject to memory loss), and historical discard + * card counts are merged in. + * @returns Record mapping rank strings to their count estimate */ export function countVisibleRanks( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, + rng?: () => number, + memoryTracker?: CardMemoryTracker, ): Record { const counts: Record = {}; - // Count face-up cards in the AI's own grid + // Count face-up cards in the AI's own grid (always perfect) for (const slot of playerState.grid) { if (slot.faceUp && 'rank' in slot) { const rank = (slot as Card).rank; @@ -297,10 +313,27 @@ export function countVisibleRanks( } } - // Count the discard top card (visible to all players) - if (shared.discardTop && 'rank' in shared.discardTop) { - const rank = shared.discardTop.rank; - counts[rank] = (counts[rank] || 0) + 1; + // Count the discard top card (always perfect, visible to all players) + const discardTopRank = shared.discardTop?.rank; + if (discardTopRank) { + counts[discardTopRank] = (counts[discardTopRank] || 0) + 1; + } + + // Merge memory-augmented historical discard card counts + if (memoryTracker && rng) { + const memoryCounts = memoryTracker.getVisibleRanks(rng); + + for (const [rank, memoryCount] of Object.entries(memoryCounts)) { + if (rank === discardTopRank) { + // The tracker includes the current discard top in its count. + // We've already counted the current top perfectly, so only add + // the historical copies: memoryCount - 1 (clamped to 0). + const historicalCount = Math.max(0, memoryCount - 1); + counts[rank] = (counts[rank] || 0) + historicalCount; + } else { + counts[rank] = (counts[rank] || 0) + memoryCount; + } + } } return counts; @@ -455,39 +488,50 @@ export function chooseMoveForCard( * binding and the `strategyName` getter. */ export class AiPlayer extends AiPlayerBase { - /** - * Choose an action for the current game state. - * - * Accepts AI-visible state projections only — cannot access - * hidden game data. - */ + /** The memory tracker for probabilistic recall of discard cards. */ + readonly memoryTracker: CardMemoryTracker; + private readonly config: GreedyStrategyConfig; constructor( strategy: AiStrategy, rng?: () => number, config: GreedyStrategyConfig = DEFAULT_GREEDY_CONFIG, + skillRating?: number, ) { super(strategy, rng); this.config = config; + this.memoryTracker = new CardMemoryTracker(skillRating ?? 80); + } + + /** + * Record a card the AI has observed (e.g., drawn from the discard pile) + * in its memory tracker for later probabilistic recall. + */ + recordCard(card: Card): void { + this.memoryTracker.recordCard(card); } chooseAction( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, ): GolfAction { - return this.strategy.chooseAction(playerState, shared, this.rng); + return this.strategy.chooseAction( + playerState, shared, this.rng, this.memoryTracker, + ); } /** * Phase 1: Choose whether to draw from stock or discard. - * Uses the configured column-weight heuristic. + * Uses the configured column-weight heuristic and memory tracker. */ chooseDrawSource( playerState: AiVisiblePlayerState, shared: AiVisibleSharedState, ): DrawSource { - return chooseDrawSource(playerState, shared, this.rng, this.config); + return chooseDrawSource( + playerState, shared, this.rng, this.config, this.memoryTracker, + ); } /** diff --git a/example-games/golf/GolfGame.ts b/example-games/golf/GolfGame.ts index b84e977d..ace044e8 100644 --- a/example-games/golf/GolfGame.ts +++ b/example-games/golf/GolfGame.ts @@ -153,6 +153,16 @@ export type GolfSetupOptions = MultiplayerSetupOptions & { * If omitted, the first 3 grid positions are revealed by default. */ initialReveals?: Array>; + + /** + * Skill rating (1-100) for the AI player's probabilistic card memory. + * - 100: Perfect recall of all seen discard cards. + * - 80: Strong but slightly imperfect recall (default). + * - 50: Chance-level accuracy (50% correct). + * - 1: Nearly always misremembers. + * @default 80 + */ + skillRating?: number; }; /** diff --git a/example-games/golf/scenes/GolfAiController.ts b/example-games/golf/scenes/GolfAiController.ts index a5b904b0..c969028b 100644 --- a/example-games/golf/scenes/GolfAiController.ts +++ b/example-games/golf/scenes/GolfAiController.ts @@ -68,12 +68,17 @@ export class GolfAiController { drawnCard = this.session.shared.discardPile.popOrThrow(); } - // When AI draws from discard, refresh the pile display to show the new - // top card (or empty placeholder). The card has already been popped, so - // we use refreshPiles() — updateDiscardPileAfterDraw() is designed for - // the human peek-not-pop path and would incorrectly compute the display - // when the card is already removed from the pile. + // Record the drawn card in the AI's memory tracker (only for discard + // draws, since the discard top is visible information the AI should + // remember across turns). Stock draws are hidden information and not + // recorded. if (drawSource === 'discard') { + this.aiPlayer.recordCard(drawnCard); + // Refresh the pile display to show the new top card (or empty + // placeholder). The card has already been popped, so we use + // refreshPiles() — updateDiscardPileAfterDraw() is designed for + // the human peek-not-pop path and would incorrectly compute the + // display when the card is already removed from the pile. refreshPiles(); } diff --git a/example-games/golf/scenes/GolfScene.ts b/example-games/golf/scenes/GolfScene.ts index faee5557..7edd75e9 100644 --- a/example-games/golf/scenes/GolfScene.ts +++ b/example-games/golf/scenes/GolfScene.ts @@ -53,6 +53,16 @@ export class GolfScene extends CardGameScene { drawSource: import('../GolfRules').DrawSource | null = null; aiStrategyName: string = 'greedy'; + /** + * AI skill rating (1-100) for probabilistic card memory. + * - 100: Perfect recall + * - 80: Strong but slightly imperfect (default) + * - 50: Chance-level accuracy + * - 1: Nearly always misremembers + * Set before scene launch. + */ + aiSkillRating: number = 80; + /** Tracks whether loadBoardState() has been called (required before enableInteractiveMode). */ boardStateInjected: boolean = false; @@ -158,12 +168,13 @@ export class GolfScene extends CardGameScene { this.session = setupGolfGame({ playerNames: ['You', 'AI'], isAI: [false, true], + skillRating: this.aiSkillRating, }); this.recorder = new TranscriptRecorder(this.session, [ undefined, this.aiStrategyName, ]); - this.aiPlayer = new AiPlayer(strategy); + this.aiPlayer = new AiPlayer(strategy, undefined, undefined, this.aiSkillRating); // Create helpers this.golfRenderer = new GolfRenderer(this, this.session, this.replayMode); @@ -229,7 +240,15 @@ export class GolfScene extends CardGameScene { this.phaseManager.setTextObject(this.instructionText); if (!this.replayMode) { this.initHelpPanel(helpContent as HelpSection[]); - this.initSettingsPanel(); + this.initSettingsPanel(undefined, undefined, false, { + value: this.aiSkillRating, + min: 1, + max: 100, + onChange: (value) => { + this.aiSkillRating = value; + this.aiPlayer.memoryTracker.setSkill(value); + }, + }); // Propagate reduced motion preference to the animator if (this.settingsPanel) { this.animator.reducedMotion = this.settingsPanel.reducedMotion; diff --git a/example-games/gym/scenes/GymAudioFeedbackScene.ts b/example-games/gym/scenes/GymAudioFeedbackScene.ts index e1f3a077..3db96482 100644 --- a/example-games/gym/scenes/GymAudioFeedbackScene.ts +++ b/example-games/gym/scenes/GymAudioFeedbackScene.ts @@ -4,6 +4,8 @@ * using the core-engine SoundManager and UI helper APIs. * * Features: + * - Auto-discovers all default sound keys and generates buttons dynamically + * - Auto-discovers visual feedback types and generates trigger buttons * - Toggle mute and observe immediate effect * - Predefined event-to-sound mappings * - Invalid sound mapping handled safely @@ -63,17 +65,56 @@ class StubSoundPlayer implements SoundPlayer { setMute(_m: boolean): void { /* no-op for stub */ } } -/** A simple event map for this demo. */ -const DEMO_SFX_KEYS = ['sfx-test-ding', 'sfx-test-buzz'] as const; +// ── Auto-discovered defaults ────────────────────────────────── -const DEMO_EVENT_MAPPING: EventSoundMapping = { - 'card-drawn': 'sfx-test-ding', - 'card-discarded': 'sfx-test-buzz', - 'game-ended': 'sfx-test-ding', - 'undo': 'sfx-test-buzz', - 'redo': 'sfx-test-ding', +/** + * All default sound keys that the gym scene registers and demonstrates. + * These correspond to WAV files in `public/assets/audio/default/` and + * 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 for the gym demo. + * Each event triggers the corresponding sound when emitted. + */ +const DEFAULT_EVENT_MAPPING: 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', }; +/** + * Visual feedback types that can be triggered from the scene. + * Each entry describes a type of visual feedback with a label and + * a trigger function that will be called from the generated button. + */ +interface FeedbackTypeEntry { + label: string; + description: string; +} + +const FEEDBACK_TYPES: FeedbackTypeEntry[] = [ + { label: 'popTextOrIcon', description: 'Pop text or icon animation' }, + { label: 'Celebrate', description: 'Particle celebration burst' }, +]; + +// ── Scene class ─────────────────────────────────────────────── + export class GymAudioFeedbackScene extends GymSceneBase { private gameEvents = new GameEventEmitter(); private stubPlayer = new StubSoundPlayer(); @@ -83,6 +124,8 @@ export class GymAudioFeedbackScene extends GymSceneBase { private statusText!: Phaser.GameObjects.Text; private callLog: string[] = []; private eventLogResult!: EventLogResult; + // Track dynamically generated buttons for cleanup + private dynamicButtons: Phaser.GameObjects.Text[] = []; // Track pop text targets for cleanup private popTargets: Phaser.GameObjects.Text[] = []; @@ -91,9 +134,15 @@ export class GymAudioFeedbackScene extends GymSceneBase { } preload(): void { - // Load demo audio assets - this.load.audio('sfx-test-ding', 'assets/audio/card-draw.wav'); - this.load.audio('sfx-test-buzz', 'assets/audio/card-discard.wav'); + // Load default audio assets + this.load.audio('sfx-ui-click', 'assets/audio/default/ui-click.wav'); + this.load.audio('sfx-turn-change', 'assets/audio/default/turn-change.wav'); + this.load.audio('sfx-round-end', 'assets/audio/default/round-end.wav'); + this.load.audio('sfx-score-reveal', 'assets/audio/default/score-reveal.wav'); + this.load.audio('sfx-card-draw', 'assets/audio/default/card-draw.wav'); + this.load.audio('sfx-card-flip', 'assets/audio/default/card-flip.wav'); + this.load.audio('sfx-card-discard', 'assets/audio/default/card-discard.wav'); + this.load.audio('sfx-card-swap', 'assets/audio/default/card-swap.wav'); } create(): void { @@ -105,19 +154,15 @@ export class GymAudioFeedbackScene extends GymSceneBase { this.initHelp([ { heading: 'Features', - body: 'Demonstrates audio event mapping and feedback configuration using the SoundManager, along with pop text animations (popTextOrIcon) and particle celebration effects. The SoundManager connects game events to sound keys, providing volume control, mute toggling, and graceful handling of missing or unregistered sound keys. In a real card game, sounds play for card draws, discards, wins, and errors, while pop text provides visual feedback alongside audio.' + body: 'Demonstrates audio event mapping and feedback configuration using the SoundManager, along with pop text animations (popTextOrIcon) and particle celebration effects. The SoundManager connects game events to sound keys, providing volume control, mute toggling, and graceful handling of missing or unregistered sound keys. Sounds and feedback types are auto-discovered and dynamically generate buttons.' }, { heading: 'Controls', - body: '[ Toggle Mute ]: Toggle audio mute on/off. Status shows current mute state and call count.\n[ Volume - ] / [ Volume + ]: Decrease or increase global volume in 0.1 steps (range 0.0-1.0).\n[ Draw Card ]: Emit a "card-drawn" event, which triggers the "sfx-test-ding" sound and a pop text.\n[ Discard Card ]: Emit a "card-discarded" event, triggering "sfx-test-buzz" and pop text.\n[ Pop Text ]: Trigger a standalone pop text animation at a random position near centre.\n[ Pop Undo ]: Emit an "undo" event, triggering "sfx-test-buzz" and pop text.\n[ Pop Redo ]: Emit a "redo" event, triggering "sfx-test-ding" and pop text.\n[ Celebrate ]: Trigger a particle burst effect (or pop text fallback if particles unavailable, or if reduced-motion is on).\n[ Invalid Key ]: Attempt to play an unregistered sound key ("sfx-nonexistent-key") — safely ignored, demonstrating graceful error handling.' + body: '[ Toggle Mute ]: Toggle audio mute on/off.\n[ Volume - ] / [ Volume + ]: Adjust global volume in 0.1 steps.\n[ Invalid Key ]: Demonstrate safe handling of unregistered keys.\nSound event buttons: Auto-generated for each registered sound key.\nFeedback type buttons: Auto-generated for each visual feedback type.' }, { heading: 'Usage Example', - body: 'In a game of Golf, a satisfying "ding" plays when a card is drawn, and a different "buzz" plays when a card is discarded. The player can mute audio during meetings or adjust volume for different environments. After winning a round, a particle celebration bursts across the screen. If the player has reduced-motion enabled, the celebration shows a simple party emoji pop text instead.' - }, - { - heading: 'Test Plan', - body: '1. Press [ Draw Card ] → event log shows sound call "sfx-test-ding", pop text appears\n2. Press [ Discard Card ] → event log shows "sfx-test-buzz"\n3. Press [ Toggle Mute ] → status shows Muted: true, no sound on subsequent events\n4. Press [ Draw Card ] → event fires but no sound (muted) — only pop text appears\n5. Press [ Volume - ] twice → volume drops to 0.3\n6. Press [ Volume + ] three times → volume returns to 0.6\n7. Press [ Invalid Key ] → event log shows safe handling of unregistered key\n8. Press [ Pop Text ] → pop text animation appears\n9. Press [ Celebrate ] → particle burst or emoji fallback appears\n10. Press [ Toggle Mute ] to unmute, verify sound returns' + body: 'In a game of Golf, a "ding" plays when a card is drawn, and a "buzz" when discarded. The player can mute audio or adjust volume. After winning a round, a particle celebration bursts across the screen.' } ]); @@ -148,37 +193,25 @@ export class GymAudioFeedbackScene extends GymSceneBase { } this.soundManager = new SoundManager(player, { storage: null }); - for (const key of DEMO_SFX_KEYS) { + for (const key of DEFAULT_SFX_KEYS) { this.soundManager.register(key); } - this.soundManager.connectToEvents(this.gameEvents, DEMO_EVENT_MAPPING); + this.soundManager.connectToEvents(this.gameEvents, DEFAULT_EVENT_MAPPING); this.soundManager.setVolume(this.volume); + // ── Status and log ────────────────────────────────── const cx = GAME_W / 2; - const controlsAnchor = resolveAudioAnchor('controls', 'center'); - const controls2Anchor = resolveAudioAnchor('controls2', 'center'); const statusAnchor = resolveAudioAnchor('status', 'center'); const logAnchor = resolveAudioAnchor('log', 'center'); - const y = controlsAnchor.y; - this.addButton(cx - 450, y, '[ Toggle Mute ]', () => this.toggleMute()); - this.addButton(cx - 280, y, '[ Volume - ]', () => this.adjustVolume(-0.1)); - this.addButton(cx - 140, y, '[ Volume + ]', () => this.adjustVolume(0.1)); - this.addButton(cx + 10, y, '[ Draw Card ]', () => this.emitEvent('card-drawn')); - this.addButton(cx + 150, y, '[ Discard Card ]', () => this.emitEvent('card-discarded')); - this.addButton(cx + 330, y, '[ Invalid Key ]', () => this.playInvalid()); - - const y2 = controls2Anchor.y; - this.addButton(cx - 280, y2, '[ Pop Text ]', () => this.triggerPopText()); - this.addButton(cx - 100, y2, '[ Pop Undo ]', () => this.emitEvent('undo')); - this.addButton(cx + 60, y2, '[ Pop Redo ]', () => this.emitEvent('redo')); - this.addButton(cx + 220, y2, '[ Celebrate ]', () => this.triggerCelebration()); - - this.statusText = createHudText(this, cx, statusAnchor.y, this.statusString(), '#ffffff', { fontSize: '16px' }).setOrigin(0.5); + this.statusText = createHudText( + this, cx, statusAnchor.y, + this.statusString(), '#ffffff', { fontSize: '16px' }, + ).setOrigin(0.5); this.eventLogResult = createEventLog(this, logAnchor.y + 20, { - headerText: '── Sound Call Log ──', - maxLines: 14, + headerText: '── Activity Log ──', + maxLines: 16, lineHeight: 17, textColor: '#aaddaa', fontSize: '11px', @@ -186,6 +219,86 @@ export class GymAudioFeedbackScene extends GymSceneBase { headerColor: '#669966', lineX: 40, }); + + // ── Control buttons (row 1: core controls) ────────── + const controlsAnchor = resolveAudioAnchor('controls', 'center'); + const y = controlsAnchor.y; + + this.addButton(cx - 480, y, '[ Toggle Mute ]', () => this.toggleMute()); + this.addButton(cx - 310, y, '[ Volume - ]', () => this.adjustVolume(-0.1)); + this.addButton(cx - 170, y, '[ Volume + ]', () => this.adjustVolume(0.1)); + this.addButton(cx - 20, y, '[ Invalid Key ]', () => this.playInvalid()); + + // ── Dynamic sound event buttons ───────────────────── + // Auto-discover registered sound keys from SoundManager + // and generate a button for each one that emits the + // corresponding event. + const eventToButtonLabel: Record = { + 'card-drawn': 'Draw Card', + 'card-flipped': 'Flip Card', + 'card-discarded': 'Discard Card', + 'card-swapped': 'Swap Card', + 'ui-interaction': 'UI Click', + 'turn-started': 'Turn Change', + 'game-ended': 'Round End', + 'turn-completed': 'Score Reveal', + }; + + const controls2Anchor = resolveAudioAnchor('controls2', 'center'); + const y2 = controls2Anchor.y; + + // Spread sound event buttons across the full width starting from left margin + const LEFT_MARGIN = 40; + const soundEventCount = Object.entries(DEFAULT_EVENT_MAPPING).filter( + ([eventName, soundKey]) => { + const label = eventToButtonLabel[eventName]; + if (!label) return false; + return Array.from(this.soundManager.keys()).includes(soundKey); + }, + ).length; + const soundSpacing = (GAME_W - LEFT_MARGIN * 2) / Math.max(soundEventCount, 1); + let btnIndex = 0; + + // Generate buttons for each event in DEFAULT_EVENT_MAPPING + // that has a corresponding label in eventToButtonLabel + for (const [eventName, soundKey] of Object.entries(DEFAULT_EVENT_MAPPING)) { + const label = eventToButtonLabel[eventName]; + if (!label) continue; // Skip unmapped events + + const keysFromManager = Array.from(this.soundManager.keys()); + if (!keysFromManager.includes(soundKey)) continue; // Only if sound is registered + + const btnLabel = `[ ${label} ]`; + const xPos = LEFT_MARGIN + btnIndex * soundSpacing; + const btn = this.addButton(xPos, y2, btnLabel, () => this.emitEvent(eventName)); + this.dynamicButtons.push(btn); + btnIndex++; + } + + // ── Dynamic visual feedback buttons (row 3) ───────── + const feedbackY = y2 + 28; + const feedbackSpacing = (GAME_W - LEFT_MARGIN * 2) / Math.max(FEEDBACK_TYPES.length, 1); + let feedbackIndex = 0; + + for (const ft of FEEDBACK_TYPES) { + const btnLabel = `[ ${ft.label} ]`; + const xPos = LEFT_MARGIN + feedbackIndex * feedbackSpacing; + const btn = this.addButton(xPos, feedbackY, btnLabel, () => { + if (ft.label === 'Celebrate') { + this.triggerCelebration(); + } else { + this.triggerPopText(); + } + }); + this.dynamicButtons.push(btn); + feedbackIndex++; + } + + // Log the auto-discovery + this.logCall( + `Auto-discovered ${Array.from(this.soundManager.keys()).length} sound keys, ` + + `${FEEDBACK_TYPES.length} feedback types`, + ); } private statusString(): string { @@ -341,7 +454,7 @@ export class GymAudioFeedbackScene extends GymSceneBase { private logCall(msg: string): void { this.callLog.push(msg); - if (this.callLog.length > 14) this.callLog.shift(); + if (this.callLog.length > 16) this.callLog.shift(); this.eventLogResult.render(this.callLog); } @@ -352,5 +465,10 @@ export class GymAudioFeedbackScene extends GymSceneBase { try { t.destroy(); } catch (_) { /* ignore */ } } this.popTargets = []; + // Clean up dynamic buttons + for (const btn of this.dynamicButtons) { + try { btn.destroy(); } catch (_) { /* ignore */ } + } + this.dynamicButtons = []; } -} \ No newline at end of file +} diff --git a/example-games/gym/scenes/GymHandPileScene.ts b/example-games/gym/scenes/GymHandPileScene.ts index fd07ed27..aefa85e8 100644 --- a/example-games/gym/scenes/GymHandPileScene.ts +++ b/example-games/gym/scenes/GymHandPileScene.ts @@ -98,6 +98,13 @@ export class GymHandPileScene extends GymSceneBase { private spacingSlider!: Slider; private rotationSlider!: Slider; + // Discard animation mode + private discardMode: 'shrink' | 'animate' = 'animate'; + private discardModeLabel!: Phaser.GameObjects.Text; + + // Discard pile face-up display state + private faceUpLabel!: Phaser.GameObjects.Text; + // Drag-and-drop demo state private dragEnabled: boolean = false; private dragLabel!: Phaser.GameObjects.Text; @@ -109,6 +116,8 @@ export class GymHandPileScene extends GymSceneBase { preload(): void { preloadCardAssets(this); + // Load the illegal-move sound used by shakeIllegalMove + this.load.audio('sfx-illegal-move', 'assets/audio/default/illegal-move.wav'); } create(): void { @@ -201,7 +210,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 with a fade animation.\n[ Recall ]: Move the top card of the discard pile back to the hand.\n[ Flip ]: Flip the selected card (two-phase scale animation).\n[ Move ]: Tween the selected card to a display area. Demonstrates moveGameObject().\n[ Cancel Move ]: Cancel an active move tween and return the card to the hand.\n[ Show Valid ]: Highlight deck and discard zones as valid drop targets using HighlightManager.\n[ Show Illegal ]: Trigger an illegal-move shake animation on the selected card.\n[ Select Next ]: Cycle forward through cards in the hand.\n[ Sort Hand ]: Sort hand by suit then rank.\n[ Shuffle Hand ]: Randomly shuffle the hand.\n[ Reset ]: Shuffle a fresh deck and deal a new starting hand.\n[ Enable Drag ] / [ Disable Drag ]: Toggle drag-and-drop mode. When enabled, drag a card from hand to the discard pile.\nArc slider: Adjust hand curvature live (0 = straight, 200 = maximum arc).\nSpacing slider: Adjust gap between cards in the hand.\nRotation slider: Adjust maximum rotation angle for cards at the edges of an arc layout.\n[ Toggle Layout ]: Switch between horizontal row and vertical cascade layout.' + 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.' }, { heading: 'Usage Example', @@ -209,7 +218,7 @@ 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 fades to discard pile\n4. Press [ Recall ] → card returns from discard to hand\n5. Press [ Flip ] → selected card flips face-down then face-up\n6. Press [ Show Valid ] → green highlights appear on deck and discard zones\n7. Press [ Show Illegal ] → selected card shakes if one is selected\n8. Press [ Enable Drag ] → drag a card from hand to discard pile, verify log shows acceptance\n9. Adjust Arc slider → hand curvature changes live\n10. Press [ Toggle Layout ] → layout switches between horizontal and vertical cascade\n11. Press [ Reset ] → new hand dealt, all state cleared' + 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' } ]); @@ -234,9 +243,17 @@ export class GymHandPileScene extends GymSceneBase { this.addButton(cx + 340, y, '[ Reset ]', () => this.reset()); y += 26; - // Controls row 3 — Drag-and-drop demo - this.dragButton = this.addButton(cx - 280, y, '[ Enable Drag ]', () => this.toggleDrag()); - this.dragLabel = createHudText(this, cx - 120, y, 'Drag: off (click card, then drag to discard)', '#777777', { fontSize: '11px' }).setOrigin(0, 0.5); + // Controls row 3 — Drag-and-drop demo and discard mode toggle + this.dragButton = this.addButton(cx - 420, y, '[ Enable Drag ]', () => this.toggleDrag()); + this.dragLabel = createHudText(this, cx - 250, y, 'Drag: off (click card, then drag to discard)', '#777777', { fontSize: '11px' }).setOrigin(0, 0.5); + + // Discard mode toggle — animate (default) vs shrink + this.addButton(cx + 30, y, '[ Toggle Discard Mode ]', () => this.toggleDiscardMode()); + this.discardModeLabel = createHudText(this, cx + 190, y, 'Discard: animate', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); + + // Discard pile face-up toggle + this.addButton(cx + 300, y, '[ Toggle Face Up ]', () => this.toggleDiscardFaceUp()); + this.faceUpLabel = createHudText(this, cx + 470, y, 'Face: up', '#88ff88', { fontSize: '11px' }).setOrigin(0, 0.5); y += 35; createHudText(this, cx, y, '── Event Log ──', '#669966', { fontSize: '12px' }).setOrigin(0.5); @@ -445,7 +462,6 @@ export class GymHandPileScene extends GymSceneBase { const sprite = this.handView.getSpriteAt(spriteIdx); if (sprite && !this.reducedMotion) { - // Animated discard — data model already consistent, only UI cleanup needed const gameEvents = new GameEventEmitter(); gameEvents.on('card:discarded', () => { this.selectedIdx = -1; @@ -454,18 +470,36 @@ export class GymHandPileScene extends GymSceneBase { this.handView.setSelected(null); this.discardView.update(); gameEvents.removeAllListeners(); - this.logEvent(`Discarded ${card.rank}${card.suit} (animated)`); + const modeLabel = this.discardMode === 'animate' ? 'move+flip' : 'fade'; + this.logEvent(`Discarded ${card.rank}${card.suit} (${modeLabel})`); }); - discardCard({ - scene: this, - target: sprite as any, - offsetY: 30, - duration: 350, - destroyAfter: true, - gameEvents, - cardId: `${card.rank}${card.suit}`, - }); + if (this.discardMode === 'animate') { + // Animate card from its hand position to the discard pile, flipping on arrival + discardCard({ + scene: this, + target: sprite as any, + destX: this.DISCARD_X, + destY: this.PILE_Y, + flipOnArrivalTexture: getCardTexture(card), + duration: 400, + depth: 2, + destroyAfter: true, + gameEvents, + cardId: `${card.rank}${card.suit}`, + }); + } else { + // Shrink/fade in place (original behavior) + discardCard({ + scene: this, + target: sprite as any, + offsetY: 30, + duration: 350, + destroyAfter: true, + gameEvents, + cardId: `${card.rank}${card.suit}`, + }); + } } else { if (sprite) { // For reduced-motion, immediately clean up the sprite @@ -477,7 +511,8 @@ export class GymHandPileScene extends GymSceneBase { this.handView.setCards(this.hand); this.handView.setSelected(null); this.discardView.update(); - this.logEvent(`Discarded ${card.rank}${card.suit} (instant)`); + const modeLabel = this.discardMode === 'animate' ? 'move+flip' : 'fade'; + this.logEvent(`Discarded ${card.rank}${card.suit} (${modeLabel}, instant)`); } } @@ -767,6 +802,11 @@ export class GymHandPileScene extends GymSceneBase { this.rotationSlider.setValue(this.ROTATION_DEGREES_DEFAULT); this.handView.setMaxRotationDegrees(this.ROTATION_DEGREES_DEFAULT); + // Reset face-up state to face-up + this.discardView.setFaceUp(true); + this.faceUpLabel.setText('Face: up'); + this.faceUpLabel.setColor('#88ff88'); + // Sync UI components this.handView.setCards(this.hand); this.handView.setSelected(null); @@ -864,6 +904,37 @@ export class GymHandPileScene extends GymSceneBase { } } + /** Toggle discard pile display between face-up and face-down. */ + private toggleDiscardFaceUp(): void { + const isFaceUp = this.discardView.getFaceUp(); + this.discardView.setFaceUp(!isFaceUp); + + if (!isFaceUp) { + this.faceUpLabel.setText('Face: up'); + this.faceUpLabel.setColor('#88ff88'); + this.logEvent('Discard pile set to face-up'); + } else { + this.faceUpLabel.setText('Face: down'); + this.faceUpLabel.setColor('#ff8888'); + this.logEvent('Discard pile set to face-down (visual only, card order preserved)'); + } + } + + /** Toggle discard animation mode between animate and shrink. */ + private toggleDiscardMode(): void { + if (this.discardMode === 'animate') { + this.discardMode = 'shrink'; + this.discardModeLabel.setText('Discard: shrink'); + this.discardModeLabel.setColor('#ffaa44'); + this.logEvent('Discard mode switched to shrink (fade+shrink in place)'); + } else { + this.discardMode = 'animate'; + this.discardModeLabel.setText('Discard: animate'); + this.discardModeLabel.setColor('#88ff88'); + this.logEvent('Discard mode switched to animate (move to discard pile)'); + } + } + /** * Hit-test pointer position against the discard pile zone. * Returns target pile index (1, discard) or null if not over the discard pile. @@ -971,10 +1042,12 @@ export class GymHandPileScene extends GymSceneBase { try { this.deckView?.destroy(); } catch (_) { /* ignore */ } try { this.discardView?.destroy(); } catch (_) { /* ignore */ } - // Destroy layout and drag UI text labels + // Destroy layout, drag, and discard mode UI text labels try { this.layoutLabel?.destroy(); } catch (_) { /* ignore */ } try { this.dragLabel?.destroy(); } catch (_) { /* ignore */ } try { this.dragButton?.destroy(); } catch (_) { /* ignore */ } + try { this.discardModeLabel?.destroy(); } catch (_) { /* ignore */ } + try { this.faceUpLabel?.destroy(); } catch (_) { /* ignore */ } // Destroy all event log text objects for (const t of this.logTexts) { diff --git a/example-games/gym/scenes/GymOverlayUiScene.ts b/example-games/gym/scenes/GymOverlayUiScene.ts index ac89a9e7..84ce257e 100644 --- a/example-games/gym/scenes/GymOverlayUiScene.ts +++ b/example-games/gym/scenes/GymOverlayUiScene.ts @@ -24,6 +24,84 @@ import { anchorPoint } from '../../../src/ui/screen-layout'; import { parseScreenLayoutDocument } from '../../../src/ui/screen-layout-schema'; import gymOverlayUiLayoutJson from '../layouts/gym-overlay-ui.layout.json'; +// ── Scrollable content generation ──────────────────────────── + +// Line of text repeated to pad content to desired length. +// Use descriptive content about the overlay/mask rather than lorem ipsum. +const CONTENT_PAD = [ + 'The GeometryMask clips this', + 'content to a fixed rectangle.', + '', + 'Scroll to see more details', + 'about how the engine works.', + '', + 'Each line is added as a child', + 'of the masked container, which', + 'is positioned at the mask origin.', + '', + 'When the overlay closes, all', + 'mask resources are freed.', + '', + 'This creates a clean lifecycle', + 'with no memory leaks.', +]; + +/** + * Generate enough text lines to require scrolling. + * 14 header + 4 * 14 pad = 70 lines → 70 * 16 = 1120px >> 200px mask. + */ +function generateScrollableContent(): string[] { + const lines: string[] = []; + lines.push('Masked Content Area'); + lines.push('─────────────────────'); + lines.push(''); + lines.push('This overlay demonstrates a'); + lines.push('GeometryMask-clipped region'); + lines.push('that can be scrolled using'); + lines.push('the mouse wheel or touch drag.'); + lines.push(''); + lines.push('─ Mask Properties ─'); + lines.push('Size: 300 x 200 px'); + lines.push('Lines: uses 16px spacing'); + lines.push('Scroll: wheel + drag'); + lines.push(''); + lines.push('─ How Scrolling Works ─'); + lines.push('The masked container moves'); + lines.push('vertically inside the clip'); + lines.push('region, revealing different'); + lines.push('parts of the text content.'); + lines.push(''); + lines.push('The scrollbar on the right'); + lines.push('shows the current position'); + lines.push('relative to total content.'); + lines.push(''); + lines.push('─ Implementation ─'); + lines.push('1. A Graphics object defines'); + lines.push(' the clip region shape.'); + lines.push('2. A GeometryMask is created'); + lines.push(' from that graphics shape.'); + lines.push('3. A Container holds all the'); + lines.push(' scrollable child objects.'); + lines.push('4. The mask is applied to the'); + lines.push(' container via setMask().'); + lines.push('5. Adjusting the container Y'); + lines.push(' scrolls content within the'); + lines.push(' fixed clip region.'); + lines.push(''); + lines.push('─ Scroll Tips ─'); + lines.push('Use mouse wheel to scroll.'); + lines.push('Scrollbar shows position.'); + lines.push('Position resets on reopen.'); + lines.push(''); + // Add padding lines to ensure scrolling is necessary + for (let pass = 0; pass < 4; pass++) { + for (const pad of CONTENT_PAD) { + lines.push(pad); + } + } + return lines; +} + // Parse the shared Overlay UI scene layout once at module load. const OVERLAY_LAYOUT: import('../../../src/ui/screen-layout-schema').ScreenLayoutDocument | null = (() => { const parsed = parseScreenLayoutDocument(gymOverlayUiLayoutJson); @@ -60,6 +138,17 @@ export class GymOverlayUiScene extends GymSceneBase { private contentMask: Phaser.Display.Masks.GeometryMask | null = null; private maskedContainer: Phaser.GameObjects.Container | null = null; + // Scroll state for the masked content area + private _overlayScroller: { + scrollY: number; + maxScrollY: number; + lineCount: number; + } | null = null; + private _wheelHandlerRef: ((pointer: any, gameObjects: any[], deltaX: number, deltaY: number, deltaZ: number) => void) | null = null; + private _scrollAreaBaseY = 280; + private _maskW = 300; + private _maskH = 200; + // Guard to prevent background clicks from immediately closing the overlay private overlayInteractionGuard = false; private readonly OVERLAY_INTERACTION_GUARD_MS = 220; @@ -152,10 +241,17 @@ export class GymOverlayUiScene extends GymSceneBase { // ── GeometryMask demo: scrollable content ───────────── try { - // Create a shaped mask for clipping + const areaX = GAME_W / 2 - this._maskW / 2; + const areaY = this._scrollAreaBaseY; + const areaW = this._maskW; + const areaH = this._maskH; + const lineH = 16; + + // Create a shaped mask for clipping — positioned at the content area const maskShape = this.add.graphics(); + maskShape.setPosition(areaX, areaY); maskShape.fillStyle(0xffffff, 1); - maskShape.fillRect(0, 0, 300, 200); + maskShape.fillRect(0, 0, areaW, areaH); this.contentMask = new Phaser.Display.Masks.GeometryMask(this, maskShape); // Hide the mask shape — it provides geometry data for the mask but // should not be rendered as a visible white rectangle at (0,0) behind @@ -163,35 +259,88 @@ export class GymOverlayUiScene extends GymSceneBase { maskShape.setVisible(false); this.overlayObjects.push(maskShape); - // Create a container for content that will be clipped - this.maskedContainer = this.add.container(GAME_W / 2 - 150, 280); + // Create a container for content that will be clipped. + // Position at same origin as the mask shape so the clip region + // aligns with the container's local coordinate space. + this.maskedContainer = this.add.container(areaX, areaY); this.maskedContainer.setMask(this.contentMask); this.maskedContainer.setDepth(12); this.overlayObjects.push(this.maskedContainer); - // Add scrollable content inside the clipped area - const contentLines = [ - 'Masked Content Area', - '─────────────────────', - 'This text is inside a', - 'GeometryMask-clipped', - 'scrollable region.', - '', - 'The mask clips content', - 'to a 300×200 rectangle.', - '', - 'When the overlay closes,', - 'the mask is destroyed', - 'and resources freed.', - '', - 'Intensity setting affects', - 'overlay brightness.', - ]; + // Generate scrollable content lines (enough to overflow the mask) + const contentLines = generateScrollableContent(); + const contentH = contentLines.length * lineH; + const maxScrollY = Math.max(0, contentH - areaH); + for (let i = 0; i < contentLines.length; i++) { - const line = createHudText(this, 10, i * 16, contentLines[i], '#ccddcc', { fontSize: '12px' }); + const line = createHudText(this, 10, i * lineH, contentLines[i], '#ccddcc', { fontSize: '12px' }); this.maskedContainer.add(line); } - this.logEvent('Overlay opened with GeometryMask content area'); + + // Initialize scroll state (exposed for testing via public getter) + this._overlayScroller = { scrollY: 0, maxScrollY, lineCount: contentLines.length }; + + // ── Scrollbar ─────────────────────────────────────── + // Track: vertical bar at right edge of mask area + const scrollbarTrack = this.add.rectangle( + areaX + areaW - 8, + areaY + areaH / 2, + 4, + areaH, + 0x333333, + 0.5, + ); + scrollbarTrack.setDepth(13); + this.overlayObjects.push(scrollbarTrack); + + // Thumb: moves to indicate scroll position + const thumbHeight = Math.max(20, (areaH / contentH) * areaH); + const scrollbarThumb = this.add.rectangle( + areaX + areaW - 8, + areaY + thumbHeight / 2, + 4, + thumbHeight, + 0x88ff88, + 0.8, + ); + scrollbarThumb.setDepth(14); + this.overlayObjects.push(scrollbarThumb); + + // ── Wheel event handler ───────────────────────────── + const wheelHandler = ( + _pointer: any, + _gameObjects: any[], + _deltaX: number, + deltaY: number, + _deltaZ: number, + ) => { + const scroller = this._overlayScroller; + if (!scroller) return; + + // deltaY > 0 = scroll down + scroller.scrollY = Math.max( + 0, + Math.min(scroller.maxScrollY, scroller.scrollY + deltaY * 0.5), + ); + + // Move the container up (negative shift) to reveal later content + if (this.maskedContainer) { + this.maskedContainer.y = areaY - scroller.scrollY; + } + + // Update scrollbar thumb position + const scrollRatio = + scroller.maxScrollY > 0 ? scroller.scrollY / scroller.maxScrollY : 0; + const thumbRange = areaH - thumbHeight; + scrollbarThumb.y = areaY + thumbHeight / 2 + scrollRatio * thumbRange; + }; + + this.input.on('wheel', wheelHandler); + this._wheelHandlerRef = wheelHandler; + + this.logEvent( + `Overlay opened with GeometryMask scrollable area (${contentLines.length} lines, maxScroll=${maxScrollY})`, + ); } catch (e) { // GeometryMask may not be available in all environments (e.g., headless) this.logEvent('Overlay opened (GeometryMask unavailable, text fallback)'); @@ -247,6 +396,15 @@ export class GymOverlayUiScene extends GymSceneBase { return; } + // Remove wheel event handler before cleanup + if (this._wheelHandlerRef) { + try { this.input.off('wheel', this._wheelHandlerRef); } catch (_) { /* ignore */ } + this._wheelHandlerRef = null; + } + + // Reset scroll state + this._overlayScroller = null; + // Destroy GeometryMask references before dismissing overlay objects if (this.maskedContainer) { try { this.maskedContainer.clearMask(); } catch (_) { /* ignore */ } diff --git a/example-games/gym/scenes/GymSaveLoadScene.ts b/example-games/gym/scenes/GymSaveLoadScene.ts index 4b8387c6..d9587281 100644 --- a/example-games/gym/scenes/GymSaveLoadScene.ts +++ b/example-games/gym/scenes/GymSaveLoadScene.ts @@ -128,6 +128,27 @@ export class GymSaveLoadScene extends GymSceneBase { /** Whether a screenshot is currently displayed. Read-only for external checks. */ get screenshotAvailable(): boolean { return this._screenshotAvailable; } + /** + * Replace the current hand with a random hand of 5 cards. + * + * Creates a fresh deck, shuffles it, and deals 5 cards face-up. + * The existing hand is replaced (not appended to). + */ + private randomizeHand(): void { + // Create a fresh deck, shuffle, and draw 5 cards + const freshDeck = shuffleArray(createStandardDeck()); + const newHand: Card[] = []; + for (let i = 0; i < STARTING_HAND_SIZE; i++) { + const card = freshDeck.pop()!; + card.faceUp = true; + newHand.push(card); + } + this.state.hand = newHand; + this.handView.setCards(this.state.hand); + this.updateStateDisplay(); + this.logEvent('Hand randomized (5 new cards)'); + } + constructor() { super({ key: GYM_SAVE_LOAD_KEY }); } @@ -153,7 +174,7 @@ export class GymSaveLoadScene extends GymSceneBase { }, { heading: 'Controls', - body: '[ Add Card ]: Deal a random card from the source deck to the hand. Increases hand size and score.\n[ Save State ]: Persist the current hand of cards and screenshot (if taken) via the SaveLoadStore.\n[ Load State ]: Restore the last saved hand and screenshot. Cards are displayed face-up and any previous screenshot thumbnail is recreated.\n[ Load Malformed ]: Simulate a corrupted save payload to verify error handling and graceful fallback.\n[ Clear Save ]: Remove all persisted save data for this scene.\n[ Take Screenshot ]: Capture a full-screen RenderTexture screenshot displayed as a thumbnail below the controls.\n[ Clear Screenshot ]: Remove the screenshot thumbnail display.' + body: '[ Add Card ]: Deal a random card from the source deck to the hand. Increases hand size and score.\n[ Save State ]: Persist the current hand of cards and screenshot (if taken) via the SaveLoadStore.\n[ Load State ]: Restore the last saved hand and screenshot. Cards are displayed face-up and any previous screenshot thumbnail is recreated.\n[ Load Malformed ]: Simulate a corrupted save payload to verify error handling and graceful fallback.\n[ Clear Save ]: Remove all persisted save data for this scene.\n[ Take Screenshot ]: Capture a full-screen RenderTexture screenshot displayed as a thumbnail below the controls.\n[ Clear Screenshot ]: Remove the screenshot thumbnail display.\n[ Randomize Hand ]: Replace the current hand with 5 random cards from a fresh shuffled deck.' }, { heading: 'Usage Example', @@ -212,6 +233,7 @@ export class GymSaveLoadScene extends GymSceneBase { const y2 = controls2Anchor.y; this.addButton(cx - 300, y2, '[ Take Screenshot ]', () => this.takeScreenshot()); this.addButton(cx - 100, y2, '[ Clear Screenshot ]', () => this.clearScreenshot()); + this.addButton(cx + 100, y2, '[ Randomize Hand ]', () => this.randomizeHand()); // ── State text ──────────────────────────────────────────── try { diff --git a/example-games/gym/scenes/GymTranscriptScene.ts b/example-games/gym/scenes/GymTranscriptScene.ts index 70c4b6f0..dbb0a0c4 100644 --- a/example-games/gym/scenes/GymTranscriptScene.ts +++ b/example-games/gym/scenes/GymTranscriptScene.ts @@ -1,20 +1,31 @@ /** - * GymTranscriptScene -- Demonstrates transcript capture and deterministic - * event ordering using the core-engine TranscriptRecorderBase. + * GymTranscriptScene -- Demonstrates transcript capture with a simulated + * Blackjack game flow. + * + * The Blackjack simulation provides a realistic multi-event transcript: + * - First event: player draws two cards (deal) + * - Player can Hit (draw another card) or Stick + * - After player sticks, the dealer auto-plays their hand + * - When a winner is determined, the transcript is auto-saved + * - The "New Hand" button starts a new hand appended to the same transcript * * Features: - * - Record transcript events and completion metadata - * - Verify fixed-seed runs produce stable transcript structure - * - Inspect transcript content (version, gameType, events) + * - Simulate a simple game of Blackjack with recorded transcript events + * - Auto-save transcript on hand end + * - Multiple hands within a single session + * - Playback and inspection of recorded transcript events * * @module example-games/gym/scenes/GymTranscriptScene */ import { GymSceneBase } from './GymSceneBase'; +import Phaser from 'phaser'; import { GYM_TRANSCRIPT_KEY } from '../GymRegistry'; import { TranscriptRecorderBase, createSeededRng, + TranscriptStore, + autoSaveTranscript, } from '../../../src/core-engine'; import type { BaseTranscript } from '../../../src/core-engine'; import { popTextOrIcon } from '../../../src/ui/popTextOrIcon'; @@ -44,40 +55,71 @@ function resolveTranscriptAnchor( return anchorPoint(TRANSCRIPT_LAYOUT, zone, anchor, viewport, 1); } -/** Simple event shape for this demo. */ -interface DemoTranscriptEvent { - type: string; +// ── Blackjack-specific types ─────────────────────────────── + +interface BlackjackEvent { + type: 'deal' | 'hit' | 'stick' | 'dealer_hit' | 'dealer_stick' | 'bust' | 'result'; turn: number; + player: 'player' | 'dealer' | 'game'; + cardValue?: number; + handTotal?: number; detail: string; } -/** Transcript shape for this demo. */ -interface DemoTranscript extends BaseTranscript { +interface HandResult { + handNumber: number; + playerTotal: number; + dealerTotal: number; + playerCards: number[]; + dealerCards: number[]; + winner: 'player' | 'dealer' | 'push'; +} + +interface DemoTranscript extends BaseTranscript { seed: number; + hands: HandResult[]; } -class DemoTranscriptRecorder extends TranscriptRecorderBase { +// ── Blackjack Recorder ───────────────────────────────────── + +class BlackjackRecorder extends TranscriptRecorderBase { private nextTurn = 0; constructor(seed: number) { super({ version: 1, - gameType: 'gym-transcript-demo', + gameType: 'gym-transcript-blackjack', startedAt: new Date().toISOString(), endedAt: '', initialState: null, events: [], results: null, seed, + hands: [], }); } - recordEvent(type: string, detail: string): void { - this.transcript.events.push({ + recordEvent( + type: BlackjackEvent['type'], + player: BlackjackEvent['player'], + detail: string, + cardValue?: number, + handTotal?: number, + ): void { + const evt: BlackjackEvent = { type, turn: this.nextTurn++, + player, detail, - }); + }; + if (cardValue !== undefined) evt.cardValue = cardValue; + if (handTotal !== undefined) evt.handTotal = handTotal; + this.transcript.events.push(evt); + } + + recordHandResult(result: HandResult): void { + this.transcript.hands.push(result); + this.transcript.results = result; } finalize(): DemoTranscript { @@ -86,13 +128,67 @@ class DemoTranscriptRecorder extends TranscriptRecorderBase { } } +// ── Blackjack game helpers ───────────────────────────────── + +function cardValue(raw: number): number { + if (raw >= 12) return 10; + if (raw === 11) return 11; + return raw; +} + +function cardLabel(raw: number): string { + const labels: Record = { + 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', + 9: '9', 10: '10', 11: 'Ace', 12: 'Jack', 13: 'Queen', 14: 'King', + }; + return labels[raw] ?? String(raw); +} + +function handTotal(cards: number[]): number { + let total = 0; + let aces = 0; + for (const value of cards) { + const cv = cardValue(value); + if (cv === 11) { + aces++; + total += 11; + } else { + total += cv; + } + } + while (total > 21 && aces > 0) { + total -= 10; + aces--; + } + return total; +} + +function drawCard(rng: () => number): number { + return Math.floor(rng() * 13) + 2; +} + +// ── Scene state machine ──────────────────────────────────── + +type GamePhase = 'idle' | 'player_turn' | 'dealer_turn' | 'hand_over'; + +// ── Scene class ──────────────────────────────────────────── + export class GymTranscriptScene extends GymSceneBase { - private recorder: DemoTranscriptRecorder | null = null; + private recorder: BlackjackRecorder | null = null; private rng: (() => number) | null = null; private seed = 42; - private eventCount = 0; + private phase: GamePhase = 'idle'; + private handNumber = 0; + private playerCards: number[] = []; + private dealerCards: number[] = []; + private statusText!: Phaser.GameObjects.Text; + private handInfoText!: Phaser.GameObjects.Text; + private btnHit!: Phaser.GameObjects.Text; + private btnStick!: Phaser.GameObjects.Text; + private btnNewHand!: Phaser.GameObjects.Text; private eventLog: string[] = []; private eventLogResult!: EventLogResult; + private store: TranscriptStore | null = null; constructor() { super({ key: GYM_TRANSCRIPT_KEY }); @@ -100,90 +196,288 @@ export class GymTranscriptScene extends GymSceneBase { create(): void { this.cameras.main.setBackgroundColor('#1a2a1a'); - this.initHeader('Transcript Recording'); + this.initHeader('Blackjack Transcript'); this.addDivider(); this.initReducedMotion(); this.initHelp([ { heading: 'Features', - body: 'Demonstrates transcript recording using TranscriptRecorderBase for capturing and replaying game events in sequence. Transcripts provide a deterministic, replayable record of game actions — essential for debugging, automated testing, spectator replays, and fair-play auditing. In a real card game, a transcript records every draw, discard, shuffle, and score change so the full game session can be replayed step by step.' + body: 'Demonstrates transcript recording by simulating a simple Blackjack game. Each hand generates a structured transcript with deal, hit, stick, dealer moves, and result events. Transcripts are auto-saved when a hand ends, providing a complete record for replay and analysis.' + }, + { + heading: 'Blackjack Rules', + body: 'You and the dealer each draw cards. Number cards (2-10) are worth their value. Face cards (Jack, Queen, King) are worth 10. Aces are worth 11 or 1 (whichever avoids a bust). Try to get closer to 21 than the dealer without going over!' }, { heading: 'Controls', - body: '[ New Session ]: Start a fresh transcript session with a fixed seed (42) for deterministic event ordering.\n[ Record Event ]: Append a randomly chosen event type (draw, discard, shuffle, score) to the transcript with an incrementing turn number.\n[ Finalize ]: Mark the transcript as complete and record the end timestamp.\n[ Playback ]: Sequentially replay all recorded events with a 600ms delay between each.\n[ Show Transcript ]: Log the full transcript metadata (version, game type, event count) and all recorded events with turn numbers.' + body: '[ New Session ]: Start a fresh Blackjack session with a fixed seed (42) for deterministic card draws.\n[ Hit ]: Draw another card during your turn.\n[ Stick ]: End your turn and let the dealer play.\n[ New Hand ]: Deal a new hand (appended to the same transcript) after the previous hand ends.\n[ Playback ]: Sequentially replay all recorded events with a 600ms delay between each.\n[ Show Transcript ]: Log the full transcript metadata and all recorded events with turn numbers.' }, { heading: 'Usage Example', - body: 'A developer is debugging a scoring bug in a Golf game. They record a transcript of a complete round, then replay it step by step to verify that each score change corresponds to the correct game action. The deterministic seed ensures the same card draw order can be reproduced for consistent debugging.' + body: 'A developer testing a card game engine uses the Blackjack simulation to verify that transcript events are recorded in the correct order and that auto-save persists the full session. Multiple hands demonstrate that transcripts can span multiple game rounds.' }, { heading: 'Test Plan', - body: '1. Press [ New Session ] → new transcript created, log confirms seed\n2. Press [ Record Event ] five times → five events recorded with various types\n3. Press [ Show Transcript ] → log shows version, game type, and all five events with turn numbers\n4. Press [ Finalize ] → transcript finalized with end timestamp\n5. Press [ Playback ] → events replay sequentially with 600ms delays\n6. Press [ New Session ] again → fresh session started, old transcript discarded\n7. Verify logs accumulate without exceeding the visible limit' + body: '1. Press [ New Session ] to start a Blackjack session.\n2. Two cards are dealt to both you and the dealer.\n3. Press [ Hit ] to draw another card (or [ Stick ] to hold).\n4. If you stick, the dealer auto-plays their hand.\n5. When the hand ends, the result is shown and the transcript auto-saves.\n6. Press [ New Hand ] to play another hand in the same session.\ +7. Press [ Show Transcript ] to inspect all recorded events.\ +8. Press [ Playback ] to replay all events sequentially.\ +9. Verify logs show correct turn ordering and event types.' } ]); + this.createUI(); + this.store = new TranscriptStore(); + this.setPhase('idle'); + } + + private createUI(): void { const cx = GAME_W / 2; const controlsAnchor = resolveTranscriptAnchor('controls', 'center'); const logAnchor = resolveTranscriptAnchor('log', 'center'); const y = controlsAnchor.y; + this.statusText = this.addLabel(cx, y - 30, '', { fontSize: '13px', color: '#ffff88' }); + this.statusText.setOrigin(0.5, 0.5).setVisible(false); + + this.handInfoText = this.addLabel(cx, y + 40, '', { fontSize: '12px', color: '#ccffcc' }); + this.handInfoText.setOrigin(0.5, 0.5).setVisible(false); + this.addButton(cx - 400, y, '[ New Session ]', () => this.newSession()); - this.addButton(cx - 240, y, '[ Record Event ]', () => this.recordEvent()); - this.addButton(cx - 70, y, '[ Finalize ]', () => this.finalizeSession()); + this.btnHit = this.addButton(cx - 240, y, '[ Hit ]', () => this.playerHit()); + this.btnStick = this.addButton(cx - 70, y, '[ Stick ]', () => this.playerStick()); + this.btnNewHand = this.addButton(cx - 70, y, '[ New Hand ]', () => this.newHand()); this.addButton(cx + 80, y, '[ Playback ]', () => this.playTranscript()); this.addButton(cx + 200, y, '[ Show Transcript ]', () => this.showTranscript()); this.eventLogResult = createEventLog(this, logAnchor.y + 20, { - headerText: '── Event Log ──', - maxLines: 16, - lineHeight: 16, + headerText: '\u2500\u2500 Event Log \u2500\u2500', + maxLines: 18, + lineHeight: 15, textColor: '#aaddaa', fontSize: '11px', headerFontSize: '12px', headerColor: '#669966', lineX: 40, }); + } + + private setPhase(phase: GamePhase): void { + this.phase = phase; + this.updateUI(); + } + + private updateUI(): void { + this.btnHit.setVisible(this.phase === 'player_turn'); + this.btnStick.setVisible(this.phase === 'player_turn'); + this.btnNewHand.setVisible(this.phase === 'hand_over'); + + const statusMsgs: Record = { + idle: 'Press [ New Session ] to start a Blackjack game', + player_turn: 'Your turn: Hit or Stick?', + dealer_turn: 'Dealer is playing...', + hand_over: 'Hand complete! Press [ New Hand ] to play again.' + }; + this.statusText.setText(statusMsgs[this.phase]).setVisible(true); + this.handInfoText.setVisible(this.phase !== 'idle'); + } + + private renderHandInfo(): void { + const pTotal = handTotal(this.playerCards); + const dTotal = handTotal(this.dealerCards); + const pCards = this.playerCards.map(cardLabel).join(', '); + const dCards = this.dealerCards.map(cardLabel).join(', '); - this.newSession(); + let text = 'Your hand: [' + pCards + '] = ' + pTotal; + text += ' | Dealer hand: [' + dCards + '] = ' + dTotal; + this.handInfoText.setText(text); } + // ── Session & Hand management ──────────────────────────── + private newSession(): void { + this.recorder = new BlackjackRecorder(this.seed); this.rng = createSeededRng(this.seed); - this.recorder = new DemoTranscriptRecorder(this.seed); - this.eventCount = 0; - this.logEvent(`New session (seed=${this.seed})`); - this.showPop('New Session'); - } + this.handNumber = 1; + this.eventLog = []; + this.logEvent('New Blackjack session (seed=' + this.seed + ')'); + this.logEvent('Hand #' + this.handNumber + ' starting...'); + + // Deal two cards to player and dealer + this.playerCards = [this.drawForPlayer('Dealt'), this.drawForPlayer('Dealt')]; + this.dealerCards = [this.drawForDealer('Dealt'), this.drawForDealer('Dealt')]; - private recordEvent(): void { - if (!this.recorder) { this.logEvent('No session; click New Session first'); return; } - const types = ['draw', 'discard', 'shuffle', 'score']; - const type = types[Math.floor(this.rng!() * types.length)]; - this.recorder.recordEvent(type, `event-${this.eventCount}`); - this.eventCount++; - this.logEvent(`Recorded ${type} event #${this.eventCount}`); - this.showPop(type); + this.renderHandInfo(); + this.setPhase('player_turn'); + this.showPop('Dealt!'); } - private finalizeSession(): void { - if (!this.recorder) { this.logEvent('No session to finalize'); return; } - const t = this.recorder.finalize(); - this.logEvent(`Finalized: v${t.version}, ${t.events.length} events, endedAt=${t.endedAt}`); - this.showPop('Finalized'); + private newHand(): void { + if (!this.recorder) return; + + this.handNumber++; + this.logEvent('--- Hand #' + this.handNumber + ' ---'); + + // Reset hand state + this.playerCards = []; + this.dealerCards = []; + + // Deal two cards to player and dealer + this.playerCards = [this.drawForPlayer('Dealt'), this.drawForPlayer('Dealt')]; + this.dealerCards = [this.drawForDealer('Dealt'), this.drawForDealer('Dealt')]; + + this.renderHandInfo(); + this.setPhase('player_turn'); + this.showPop('New Hand'); } - private showTranscript(): void { - if (!this.recorder) { this.logEvent('No session'); return; } - const t = this.recorder.getTranscript(); - this.logEvent(`Transcript: v${t.version}, type=${t.gameType}, events=${t.events.length}`); - // Show all recorded events (in order) - for (const evt of t.events) { - this.logEvent(` -> ${evt.type} (turn ${evt.turn}): ${evt.detail}`); + private playerHit(): void { + if (!this.recorder || this.phase !== 'player_turn') return; + + const card = this.drawForPlayer('Hit'); + this.playerCards.push(card); + + const total = handTotal(this.playerCards); + this.renderHandInfo(); + + if (total > 21) { + this.logEvent('Player busts! (' + total + ')'); + this.recorder.recordEvent('bust', 'player', 'Player busts at ' + total, card, total); + this.endHand('dealer'); } } + private playerStick(): void { + if (!this.recorder || this.phase !== 'player_turn') return; + + const total = handTotal(this.playerCards); + this.recorder.recordEvent('stick', 'player', 'Player sticks at ' + total, undefined, total); + this.logEvent('Player sticks at ' + total); + + this.setPhase('dealer_turn'); + this.dealerPlay(); + } + + private dealerPlay(): void { + if (!this.recorder) return; + const recorder = this.recorder; + + const playNextWithDelay = (delay: number) => { + if (this.phase !== 'dealer_turn') return; + + const dTotal = handTotal(this.dealerCards); + + if (dTotal > 21) { + // Dealer busts + recorder.recordEvent('bust', 'dealer', 'Dealer busts at ' + dTotal, undefined, dTotal); + this.logEvent('Dealer busts! (' + dTotal + ')'); + this.endHand('player'); + return; + } + + if (dTotal >= 17) { + // Dealer sticks + recorder.recordEvent('dealer_stick', 'dealer', 'Dealer sticks at ' + dTotal, undefined, dTotal); + this.logEvent('Dealer sticks at ' + dTotal); + this.endHandFromDealer(); + return; + } + + // Dealer hits (dTotal < 17) + const card = this.drawForDealer('Hit'); + this.dealerCards.push(card); + this.renderHandInfo(); + + // Schedule next dealer action with delay + this.time.delayedCall(delay, () => { + playNextWithDelay(delay); + }); + }; + + // Start dealer play with 800ms delay between draws + playNextWithDelay(800); + } + + private endHandFromDealer(): void { + const pTotal = handTotal(this.playerCards); + const dTotal = handTotal(this.dealerCards); + + let winner: 'player' | 'dealer' | 'push'; + if (pTotal > 21) winner = 'dealer'; + else if (dTotal > 21) winner = 'player'; + else if (pTotal > dTotal) winner = 'player'; + else if (dTotal > pTotal) winner = 'dealer'; + else winner = 'push'; + + this.endHand(winner); + } + + private endHand(winner: 'player' | 'dealer' | 'push'): void { + if (!this.recorder) return; + + const pTotal = handTotal(this.playerCards); + const dTotal = handTotal(this.dealerCards); + + const winnerLabels: Record = { + player: 'Player wins', + dealer: 'Dealer wins', + push: 'Push (tie)' + }; + + const resultMsg = winnerLabels[winner] + ' (' + pTotal + ' vs ' + dTotal + ')'; + this.recorder.recordEvent('result', 'game', resultMsg); + this.logEvent(resultMsg); + + const handResult: HandResult = { + handNumber: this.handNumber, + playerTotal: pTotal, + dealerTotal: dTotal, + playerCards: [...this.playerCards], + dealerCards: [...this.dealerCards], + winner, + }; + this.recorder.recordHandResult(handResult); + + this.setPhase('hand_over'); + this.showPop(winnerLabels[winner]); + + // Auto-save the transcript + this.autoSaveHand(); + } + + private autoSaveHand(): void { + if (!this.recorder || !this.store) return; + const transcript = this.recorder.getTranscript(); + autoSaveTranscript(this.store, 'gym-transcript-blackjack', transcript); + this.logEvent('Transcript auto-saved (' + transcript.events.length + ' events, ' + transcript.hands.length + ' hands)'); + } + + // ── Card drawing helpers ───────────────────────────────── + + private drawForPlayer(action: string): number { + const card = drawCard(this.rng!); + const label = cardLabel(card); + const val = cardValue(card); + this.playerCards = this.playerCards || []; + const total = handTotal([...this.playerCards, card]); + this.recorder!.recordEvent(action === 'Hit' ? 'hit' : 'deal', 'player', label + ' (total: ' + total + ')', val, total); + this.logEvent('Player draws ' + label + ' (total: ' + total + ')'); + return card; + } + + private drawForDealer(action: string): number { + const card = drawCard(this.rng!); + const label = cardLabel(card); + const val = cardValue(card); + this.dealerCards = this.dealerCards || []; + const total = handTotal([...this.dealerCards, card]); + const eventType = action === 'Hit' ? 'dealer_hit' : 'deal'; + this.recorder!.recordEvent(eventType, 'dealer', label + ' (total: ' + total + ')', val, total); + this.logEvent('Dealer draws ' + label + ' (total: ' + total + ')'); + return card; + } + // ── Playback and inspection ────────────────────────────── private playTranscript(): void { if (!this.recorder) { this.logEvent('No session'); return; } @@ -192,30 +486,51 @@ export class GymTranscriptScene extends GymSceneBase { this.logEvent('No events to play'); return; } - this.logEvent(`Playing ${t.events.length} events...`); - // Play events sequentially with a small delay + this.logEvent('Playing ' + t.events.length + ' events...'); const intervalMs = 600; for (let i = 0; i < t.events.length; i++) { const evt = t.events[i]; - // Use Phaser timer so playback is tied to scene lifecycle try { this.time.delayedCall(i * intervalMs, () => { - this.logEvent(`[PLAY] ${evt.type} (turn ${evt.turn}): ${evt.detail}`); + this.logEvent('[PLAY] turn ' + evt.turn + ': ' + evt.player + ' ' + evt.type + ' - ' + evt.detail); }); } catch (_e) { - // Fallback if time.delayedCall is unavailable - setTimeout(() => this.logEvent(`[PLAY] ${evt.type} (turn ${evt.turn}): ${evt.detail}`), i * intervalMs); + setTimeout( + () => this.logEvent('[PLAY] turn ' + evt.turn + ': ' + evt.player + ' ' + evt.type + ' - ' + evt.detail), + i * intervalMs + ); + } + } + } + + private showTranscript(): void { + if (!this.recorder) { this.logEvent('No session'); return; } + const t = this.recorder.getTranscript(); + this.logEvent('Transcript: v' + t.version + ', type=' + t.gameType + ', events=' + t.events.length + ', hands=' + t.hands.length); + + for (const evt of t.events) { + let detail = '[' + evt.turn + '] ' + evt.player + ' ' + evt.type + ': ' + evt.detail; + if (evt.cardValue !== undefined) detail += ' (card=' + evt.cardValue + ')'; + if (evt.handTotal !== undefined) detail += ' (total=' + evt.handTotal + ')'; + this.logEvent(' -> ' + detail); + } + + if (t.hands.length > 0) { + this.logEvent('-- Hands --'); + for (const h of t.hands) { + this.logEvent(' Hand #' + h.handNumber + ': ' + h.winner + ' (' + h.playerTotal + ' vs ' + h.dealerTotal + ')'); } } } + // ── Logging ────────────────────────────────────────────── + private logEvent(msg: string): void { this.eventLog.push(msg); - if (this.eventLog.length > 16) this.eventLog.shift(); + if (this.eventLog.length > 18) this.eventLog.shift(); this.eventLogResult.render(this.eventLog); } - /** Show a pop text near the event log area. */ private showPop(label: string): void { popTextOrIcon({ scene: this, @@ -227,4 +542,4 @@ export class GymTranscriptScene extends GymSceneBase { style: { fontSize: '14px', color: '#88ff88', fontFamily: 'monospace' }, }); } -} \ No newline at end of file +} diff --git a/example-games/lost-cities/AiStrategy.ts b/example-games/lost-cities/AiStrategy.ts index c364d617..02d96fc2 100644 --- a/example-games/lost-cities/AiStrategy.ts +++ b/example-games/lost-cities/AiStrategy.ts @@ -162,6 +162,54 @@ function opponentInterest( return Math.min(laneScore + drawScore, 1.0); } +/** + * Count numbered cards of a specific color in the AI's hand. + * Investment cards are excluded since they don't contribute to score value. + */ +function countNumberedCardsInHandOfColor( + hand: LostCitiesCard[], + color: ExpeditionColor, +): number { + return hand.filter( + c => c.color === color && c.type === 'numbered', + ).length; +} + +/** + * Check if there is a lower-value numbered card of the same color + * in hand that could legally be played to the expedition before + * the given action card. If so, playing the higher card first would + * waste the lower card. + */ +function hasLowerNumberedCardInHand( + hand: LostCitiesCard[], + color: ExpeditionColor, + actionCard: LostCitiesCard, + expedition: LostCitiesCard[], +): boolean { + if (actionCard.type !== 'numbered') return false; + + const actionValue = cardValue(actionCard); + const lastCard = expedition.length > 0 ? expedition[expedition.length - 1] : null; + + for (const card of hand) { + if (card.id === actionCard.id) continue; + if (card.color !== color) continue; + if (card.type !== 'numbered') continue; + + const cardValue_ = cardValue(card); + + // Skip if this lower card can't legally be played after the last card + // (it's already lower than or equal to the last card in the expedition) + if (lastCard && lastCard.type === 'numbered' && cardValue_ <= cardValue(lastCard)) continue; + + // Found a lower card of the same color that could be played first + if (cardValue_ < actionValue) return true; + } + + return false; +} + /** * Score a Phase 1 action for the greedy strategy. * Higher score = more preferred. @@ -182,9 +230,16 @@ function scorePhase1Action( if (laneSize > 0) { score += 50 + laneSize * 10; } else { - // Starting a new expedition: investments are cheaper, but risky + // Starting a new expedition: only invest if we have enough cards of this color if (action.card.type === 'investment') { - score += 20; // Investment early is okay + // Count numbered cards of this color in hand + already in expedition + const numberedInHand = countNumberedCardsInHandOfColor(state.hand, action.color); + const totalColorCards = numberedInHand + laneSize; + if (totalColorCards >= 3) { + score += 20; // Investment is reasonable with enough support + } else { + score -= 100; // Penalize investment without enough support + } } else { // Starting with a low number is safer score += 10 + (10 - cardValue(action.card)); @@ -196,6 +251,21 @@ function scorePhase1Action( score += cardValue(action.card); } + // When starting a new expedition, prefer colors with more cards in hand + if (laneSize === 0 && action.card.type !== 'investment') { + const numberedInHand = countNumberedCardsInHandOfColor(state.hand, action.color); + score += numberedInHand * 5; + } + + // Penalize playing a higher card when a lower one of the same color + // exists in hand (would waste the lower card since it must be played + // in ascending order) + if (hasLowerNumberedCardInHand( + state.hand, action.color, action.card, lane ?? [], + )) { + score -= 80; + } + return score; } diff --git a/example-games/lost-cities/scenes/LostCitiesScene.ts b/example-games/lost-cities/scenes/LostCitiesScene.ts index 268a7e3a..9c32f6ae 100644 --- a/example-games/lost-cities/scenes/LostCitiesScene.ts +++ b/example-games/lost-cities/scenes/LostCitiesScene.ts @@ -101,6 +101,8 @@ export class LostCitiesScene extends CardGameScene { this.load.audio(`${ns}:${SFX_KEYS.CARD_DISCARD}`, audioPathWithFallback(audioDir, 'card-discard.wav')); this.load.audio(`${ns}:${SFX_KEYS.CARD_DRAW}`, audioPathWithFallback(audioDir, 'card-draw.wav')); this.load.audio(`${ns}:${SFX_KEYS.ILLEGAL_MOVE}`, audioPathWithFallback(audioDir, 'illegal-move.wav')); + // Also load with the un-namespaced key so shakeIllegalMove can play it via safePlaySound + this.load.audio(SFX_KEYS.ILLEGAL_MOVE, 'assets/audio/default/illegal-move.wav'); this.load.audio(`${ns}:${SFX_KEYS.TURN_CHANGE}`, audioPathWithFallback(audioDir, 'turn-change.wav')); this.load.audio(`${ns}:${SFX_KEYS.ROUND_END}`, audioPathWithFallback(audioDir, 'round-end.wav')); this.load.audio(`${ns}:${SFX_KEYS.MATCH_WIN}`, audioPathWithFallback(audioDir, 'match-win.wav')); diff --git a/example-games/main-street/CsvChecksum.ts b/example-games/main-street/CsvChecksum.ts new file mode 100644 index 00000000..a9ce5d0a --- /dev/null +++ b/example-games/main-street/CsvChecksum.ts @@ -0,0 +1,34 @@ +/** + * CSV Checksum Utility + * + * Provides a deterministic hash function for computing checksums of CSV text. + * This is used to detect when the card-data.csv has changed, so that SVG + * card assets can be regenerated to match. + * + * The checksum is suitable for change detection, NOT cryptographic security. + * + * @module + */ + +/** + * Compute a deterministic checksum (DJB2 hash, hex-encoded) from CSV text. + * + * The same CSV content always produces the same checksum. Any change to the + * CSV content (even whitespace) produces a different checksum. + * + * @param csvText - The raw CSV text (header + data rows). + * @returns An 8-character hex string. + * + * @example + * ```ts + * computeCsvChecksum('family,id,name\nbusiness,biz-a,Alpha\n'); + * // => 'a1b2c3d4' + * ``` + */ +export function computeCsvChecksum(csvText: string): string { + let hash = 5381; + for (let i = 0; i < csvText.length; i++) { + hash = ((hash << 5) + hash + csvText.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} diff --git a/example-games/main-street/MainStreetCards.ts b/example-games/main-street/MainStreetCards.ts index 13b31f52..b240871b 100644 --- a/example-games/main-street/MainStreetCards.ts +++ b/example-games/main-street/MainStreetCards.ts @@ -17,8 +17,16 @@ import cardDataRaw from './card-data.csv?raw'; import { parseCsv } from '@core-engine/CsvLoader'; +import { computeCsvChecksum } from './CsvChecksum'; const csvRows = parseCsv(cardDataRaw); +/** + * Deterministic checksum of the current card-data.csv content. + * Computed once at module load time from the Vite-imported raw CSV. + * Used to detect when the CSV has changed between saves/loads. + */ +export const CSV_CHECKSUM: string = computeCsvChecksum(cardDataRaw); + // ── Synergy & Phase Enums ─────────────────────────────────── /** Synergy types used by Business cards for adjacency bonuses. */ @@ -392,6 +400,12 @@ const COMMUNITY_SPACE_TEMPLATES: Omit[] = csvRows; + /** All Event card templates parsed from the CSV. */ const EVENT_TEMPLATES: EventCard[] = csvRows @@ -655,11 +669,11 @@ export function synergyColor(type: SynergyType): number { */ export function cardLabel(card: AnyCard): string { switch (card.family) { - case 'business': return `${card.name} ($${card.cost})`; - case 'community-space': return `${card.name} ($${card.cost})`; - case 'event': return card.cost > 0 ? `${card.name} ($${card.cost})` : card.name; - case 'upgrade': return `${card.name} ($${card.cost})`; - case 'staff': return `${card.name} ($${card.cost})`; + case 'business': return `${card.name} (€${card.cost})`; + case 'community-space': return `${card.name} (€${card.cost})`; + case 'event': return card.cost > 0 ? `${card.name} (€${card.cost})` : card.name; + case 'upgrade': return `${card.name} (€${card.cost})`; + case 'staff': return `${card.name} (€${card.cost})`; } } diff --git a/example-games/main-street/MainStreetEngine.ts b/example-games/main-street/MainStreetEngine.ts index 7402acef..e6d202d5 100644 --- a/example-games/main-street/MainStreetEngine.ts +++ b/example-games/main-street/MainStreetEngine.ts @@ -722,7 +722,7 @@ export function placeFromHand( hand.splice(handIndex, 1); state.streetGrid[slotIndex] = card; - addLog(state, `Placed ${card.name} from hand in slot ${slotIndex} (-$${placementCost})`, 'loss'); + addLog(state, `Placed ${card.name} from hand in slot ${slotIndex} (-€${placementCost})`, 'loss'); } /** diff --git a/example-games/main-street/MainStreetMarket.ts b/example-games/main-street/MainStreetMarket.ts index b02aa867..907593ff 100644 --- a/example-games/main-street/MainStreetMarket.ts +++ b/example-games/main-street/MainStreetMarket.ts @@ -374,7 +374,7 @@ export function refreshInvestments(state: MainStreetState): RefreshResult { const name = (c as any).name ?? c.id; return `${c.id}${name ? ` (${name})` : ''}`; }); - addLog(state, `Refreshed investments (-$${REFRESH_INVESTMENTS_COST}): replaced ${replacedStrings.join(', ')}`, 'loss'); + addLog(state, `Refreshed investments (-€${REFRESH_INVESTMENTS_COST}): replaced ${replacedStrings.join(', ')}`, 'loss'); return { replaced: removed, cost: REFRESH_INVESTMENTS_COST }; } @@ -423,7 +423,7 @@ export function refreshDevelopment(state: MainStreetState): RefreshResult { const name = (c as any).name ?? c.id; return `${c.id}${name ? ` (${name})` : ''}`; }); - addLog(state, `Refreshed development (-$${REFRESH_DEVELOPMENT_COST}): replaced ${replacedStrings.join(', ')}`, 'loss'); + addLog(state, `Refreshed development (-€${REFRESH_DEVELOPMENT_COST}): replaced ${replacedStrings.join(', ')}`, 'loss'); return { replaced: removed, cost: REFRESH_DEVELOPMENT_COST }; } @@ -542,7 +542,7 @@ export function purchaseBusiness( // Note: market is not refilled immediately. Replenishment occurs at start of next turn. const refilled = false; - addLog(state, `Placed ${card.name} in slot ${slotIndex} (-$${card.cost})`, 'loss'); + addLog(state, `Placed ${card.name} in slot ${slotIndex} (-€${card.cost})`, 'loss'); return { card, cost: card.cost, refilled }; } @@ -608,7 +608,7 @@ export function purchaseBusinessToHand( // Note: market is not refilled immediately. const refilled = false; - addLog(state, `Added ${card.name} to hand (-$${card.cost})`, 'loss'); + addLog(state, `Added ${card.name} to hand (-€${card.cost})`, 'loss'); return { card, cost: card.cost, refilled }; } @@ -678,7 +678,7 @@ export function purchaseUpgrade( // Note: market is not refilled immediately. Replenishment occurs at start of next turn. const refilled = false; - addLog(state, `Upgraded ${business.name} with ${card.name} (-$${card.cost})`, 'loss'); + addLog(state, `Upgraded ${business.name} with ${card.name} (-€${card.cost})`, 'loss'); return { card, cost: card.cost, refilled }; } @@ -718,7 +718,7 @@ export function purchaseEvent( // Note: market is not refilled immediately. Replenishment occurs at start of next turn. const refilled = false; - const costLabel = card.cost > 0 ? ` (-$${card.cost})` : ''; + const costLabel = card.cost > 0 ? ` (-€${card.cost})` : ''; addLog(state, `Bought event: ${card.name}${costLabel} (held)`, 'neutral'); return { card, cost: card.cost, refilled }; @@ -848,5 +848,5 @@ export function purchaseStaffCard( // Increase max hand size state.maxHandSize += card.handSlotsAdded; - addLog(state, `Hired ${card.name} (+${card.handSlotsAdded} hand slots, -$${card.cost}, ongoing $${card.ongoingCost}/turn)`, 'loss'); + addLog(state, `Hired ${card.name} (+${card.handSlotsAdded} hand slots, -€${card.cost}, ongoing €${card.ongoingCost}/turn)`, 'loss'); } diff --git a/example-games/main-street/MainStreetState.ts b/example-games/main-street/MainStreetState.ts index 7879ce70..8a84aef6 100644 --- a/example-games/main-street/MainStreetState.ts +++ b/example-games/main-street/MainStreetState.ts @@ -22,6 +22,7 @@ import { createEventDeck, createUpgradeDeck, createStaffDeck, + CSV_CHECKSUM, GRID_SIZE, MARKET_BUSINESS_SLOTS, MARKET_INVESTMENT_UPGRADE_COUNT, @@ -281,6 +282,12 @@ export interface MainStreetSerializedState { staffCardMarket: StaffCard[]; /** Whether market cycling should be skipped on next end-of-turn. */ skipMarketCycleOnEndTurn: boolean; + /** + * Checksum of the card-data.csv at the time this save was created. + * Used to detect CSV changes between saves, triggering SVG regeneration. + * Empty string indicates a legacy save before this field was added. + */ + csvChecksum: string; } /** Record of a single milestone (tier unlock) achievement. */ @@ -555,6 +562,7 @@ export function serializeMainStreetState(state: MainStreetState): MainStreetSeri staffCards: structuredClone(state.staffCards), staffCardMarket: structuredClone(state.staffCardMarket), skipMarketCycleOnEndTurn: state.skipMarketCycleOnEndTurn, + csvChecksum: CSV_CHECKSUM, }; } @@ -653,6 +661,11 @@ function migrateSerializedState(saved: Record): void { if (!('skipMarketCycleOnEndTurn' in saved)) { (saved as Record).skipMarketCycleOnEndTurn = false; } + + // ── csvChecksum: add missing field (defaults to '' for legacy saves) ─ + if (!('csvChecksum' in saved)) { + (saved as Record).csvChecksum = ''; + } } /** diff --git a/example-games/main-street/card-data.csv b/example-games/main-street/card-data.csv index ea7926b4..1499b63b 100644 --- a/example-games/main-street/card-data.csv +++ b/example-games/main-street/card-data.csv @@ -1,33 +1,33 @@ family,id,name,cost,baseIncome,synergyTypes,upgradePath,maxLevel,reputationPerTurn,synergyCoinBonus,synergyRepBonus,description,tier,trigger,effect,target,targetSynergy,coinDelta,reputationDelta,duration,effectType,multiplier,targetBusiness,incomeBonus,synergyRangeBonus,requiredLevel,reputationBonus,ongoingCost,handSlotsAdded -business,biz-bakery,Bakery,6,1,Food,Bakery,2,,,,Provides warm pastries. Gains +1 coin for each adjacent Food business.,1,,,,,,,,,,,,,,,, -business,biz-diner,Diner,8,1,Food,Diner,2,,,,Serves quick meals. Gains +1 coin per adjacent Food business.,1,,,,,,,,,,,,,,,, -business,biz-bookshop,Bookshop,8,1,Culture,Bookshop,1,,,,Sells books. Gains +1 coin per adjacent Culture business.,1,,,,,,,,,,,,,,,, -business,biz-hardware,Hardware Store,10,1,Commerce,Hardware Store,1,,,,Supplies tools. Gains +1 coin per adjacent Commerce business.,1,,,,,,,,,,,,,,,, -business,biz-pawnshop,Pawn Shop,6,1,Commerce,Pawn Shop,1,,0,0,Trades second-hand goods. Does not provide or receive synergy bonuses.,1,,,,,,,,,,,,,,,, -business,biz-boutique,Boutique,8,1,Commerce,Boutique,1,,,,Sells curated fashion. Gains +1 coin per adjacent Commerce business.,2,,,,,,,,,,,,,,,, -business,biz-laundromat,Laundromat,3,0.5,Service,Laundromat,1,,,,Provides self-serve laundry. Gains +1 coin per adjacent Service business.,1,,,,,,,,,,,,,,,, -business,biz-barbershop,Barbershop,6,1,Service,Barbershop,1,,,,Classic cuts and conversation. Gains +1 coin per adjacent Service business.,3,,,,,,,,,,,,,,,, -business,biz-arcade,Arcade,8,1,Entertainment,Arcade,1,,,,Retro fun for all ages. Gains +1 coin per adjacent Entertainment business.,2,,,,,,,,,,,,,,,, -business,biz-cinema,Cinema,10,1,Entertainment,Cinema,2,,,,Shows the latest films. Gains +1 coin per adjacent Entertainment business.,3,,,,,,,,,,,,,,,, -business,biz-cafe,Cafe,6,1,Food|Culture,Cafe,1,,,,Coffee and conversation. Bridges Food and Culture synergies.,2,,,,,,,,,,,,,,,, +business,biz-bakery,Bakery,3,0.5,Food,Bakery,2,,,,Provides warm pastries. Gains +1 coin for each adjacent Food business.,1,,,,,,,,,,,,,,,, +business,biz-diner,Diner,3,0.5,Food,Diner,2,,,,Serves quick meals. Gains +1 coin per adjacent Food business.,1,,,,,,,,,,,,,,,, +business,biz-bookshop,Bookshop,3,0.5,Culture,Bookshop,1,,,,Sells books. Gains +1 coin per adjacent Culture business.,1,,,,,,,,,,,,,,,, +business,biz-hardware,Hardware Store,3,0.5,Commerce,Hardware Store,1,,,,Supplies tools. Gains +1 coin per adjacent Commerce business.,1,,,,,,,,,,,,,,,, +business,biz-pawnshop,Pawn Shop,3,0.5,Commerce,Pawn Shop,1,,0,0,Trades second-hand goods. Does not provide or receive synergy bonuses.,1,,,,,,,,,,,,,,,, +business,biz-boutique,Boutique,3,0.5,Commerce,Boutique,1,,,,Sells curated fashion. Gains +1 coin per adjacent Commerce business.,2,,,,,,,,,,,,,,,, +business,biz-laundromat,Laundromat,4,0.5,Service,Laundromat,1,,,,Provides self-serve laundry. Gains +1 coin per adjacent Service business.,1,,,,,,,,,,,,,,,, +business,biz-barbershop,Barbershop,5,1,Service,Barbershop,1,,,,Classic cuts and conversation. Gains +1 coin per adjacent Service business.,3,,,,,,,,,,,,,,,, +business,biz-arcade,Arcade,4,0.5,Entertainment,Arcade,1,,,,Retro fun for all ages. Gains +1 coin per adjacent Entertainment business.,2,,,,,,,,,,,,,,,, +business,biz-cinema,Cinema,5,0.5,Entertainment,Cinema,2,,,,Shows the latest films. Gains +1 coin per adjacent Entertainment business.,3,,,,,,,,,,,,,,,, +business,biz-cafe,Cafe,7,1,Food|Culture,Cafe,1,,,,Coffee and conversation. Bridges Food and Culture synergies.,2,,,,,,,,,,,,,,,, business,biz-food-truck,Food Truck,4,0,Food|Entertainment,Food Truck,1,,,,Street eats with flair. Bridges Food and Entertainment synergies.,3,,,,,,,,,,,,,,,, -business,biz-gallery,Art Gallery,8,1,Culture|Entertainment,Art Gallery,1,,,,Showcases local artists. Bridges Culture and Entertainment synergies.,4,,,,,,,,,,,,,,,, -business,biz-spa,Day Spa,10,1,Service|Entertainment,Day Spa,2,,,,Relaxation and pampering. Bridges Service and Entertainment synergies.,5,,,,,,,,,,,,,,,, -business,biz-florist,Florist,4,0,Commerce|Culture,Florist,1,,,,Beautiful arrangements for every occasion. Bridges Commerce and Culture synergies.,4,,,,,,,,,,,,,,,, -business,biz-clinic,Clinic,10,0,Health,Clinic,1,0.2,,,Walk-in medical care for the community. Provides +0.2 reputation per turn. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, -business,biz-private-clinic,Private Clinic,8,2,Health,Private Clinic,1,,,,A private medical practice focused on profitability. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, -business,biz-pharmacy,Pharmacy,6,1,Health,,0,,,,Provides essential medications. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, -community-space,cs-park,Park,4,0,Culture,Park,1,,,,Offers leisure space. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, -community-space,cs-library,Library,6,1,Culture,Library,1,,,,A quiet community space for reading and learning. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, +business,biz-gallery,Art Gallery,14,1,Culture|Entertainment,Art Gallery,1,,1,0.1,Showcases local artists. Bridges Culture and Entertainment synergies.,4,,,,,,,,,,,,,,,, +business,biz-spa,Day Spa,14,1,Service|Entertainment,Day Spa,2,,1,0.1,Relaxation and pampering. Bridges Service and Entertainment synergies.,5,,,,,,,,,,,,,,,, +business,biz-florist,Florist,5,0,Commerce|Culture,Florist,1,,,,Beautiful arrangements for every occasion. Bridges Commerce and Culture synergies.,4,,,,,,,,,,,,,,,, +business,biz-clinic,Clinic,9,0,Health,Clinic,1,0.2,1,0.1,Walk-in medical care for the community. Provides +0.2 reputation per turn. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +business,biz-private-clinic,Private Clinic,14,2.5,Health,Private Clinic,1,,1,0.1,A private medical practice focused on profitability. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +business,biz-pharmacy,Pharmacy,7,1,Health,,0,,,,Provides essential medications. Gains +1 coin per adjacent Health business.,4,,,,,,,,,,,,,,,, +community-space,cs-park,Park,3,0,Culture,Park,1,,,,Offers leisure space. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, +community-space,cs-library,Library,14,1.5,Culture,Library,1,,1,0.1,A quiet community space for reading and learning. Gains +1 coin per adjacent Culture business or community space.,1,,,,,,,,,,,,,,,, event,evt-festival,Local Festival,3,,,,,,,,,1,Investment,+2 coins to all Culture businesses and +1 reputation.,SpecificSynergy,Culture,2,1,,,,,,,,,, event,evt-rainy,Rainy Day,0,,,,,,,,,1,Incident,-1 coin to all Food businesses this turn.,SpecificSynergy,Food,-1,0,,,,,,,,,, event,evt-tax,Tax Audit,0,,,,,,,,,1,Incident,Lose 3 coins.,All,,-3,0,,,,,,,,,, event,evt-award,Community Award,0,,,,,,,,,1,Incident,Gain 2 reputation from community recognition.,All,,0,2,,,,,,,,,, event,evt-inspection,Health Inspection,0,,,,,,,,,1,Incident,-2 coins per Food business and -1 reputation.,SpecificSynergy,Food,-2,-1,,,,,,,,,, -event,evt-grand-opening,Grand Opening Sale,2,,,,,,,,,1,Investment,+3 coins from a Commerce promotion.,SpecificSynergy,Commerce,3,0,,,,,,,,,, -event,evt-wellness-fair,Wellness Fair,3,,,,,,,,,2,Investment,+2 coins per Service business and +1 reputation.,SpecificSynergy,Service,2,1,,,,,,,,,, -event,evt-block-party,Block Party,4,,,,,,,,,2,Investment,+2 coins per Entertainment business and +2 reputation.,SpecificSynergy,Entertainment,2,2,,,,,,,,,, -event,evt-charity-drive,Charity Drive,2,,,,,,,,,3,Investment,+3 reputation from generous donations.,All,,0,3,,,,,,,,,, +event,evt-grand-opening,Grand Opening Sale,3,,,,,,,,,1,Investment,+3 coins from a Commerce promotion.,SpecificSynergy,Commerce,4.5,0,,,,,,,,,, +event,evt-wellness-fair,Wellness Fair,3,,,,,,,,,2,Investment,+2 coins per Service business and +1 reputation.,SpecificSynergy,Service,2,1.5,,,,,,,,,, +event,evt-block-party,Block Party,5,,,,,,,,,2,Investment,+2 coins per Entertainment business and +2 reputation.,SpecificSynergy,Entertainment,2.5,3.5,,,,,,,,,, +event,evt-charity-drive,Charity Drive,9,,,,,,,,,3,Investment,+3 reputation from generous donations.,All,,0,13.5,,,,,,,,,, event,evt-power-outage,Power Outage,0,,,,,,,,,4,Incident,-2 coins from lost business during the outage.,All,,-2,0,,,,,,,,,, event,evt-shoplifting,Shoplifting Spree,0,,,,,,,,,5,Incident,-2 coins per Commerce business from theft losses.,SpecificSynergy,Commerce,-2,0,,,,,,,,,, event,evt-noise-complaint,Noise Complaint,0,,,,,,,,,4,Incident,-1 coin per Entertainment business and -1 reputation.,SpecificSynergy,Entertainment,-1,-1,,,,,,,,,, @@ -36,52 +36,52 @@ event,evt-food-critic,Food Critic Visit,0,,,,,,,,,3,Incident,+1 coin per Food bu event,evt-construction,Road Construction,0,,,,,,,,,3,Incident,-1 coin to all businesses from reduced foot traffic.,All,,-1,0,,,,,,,,,, event,evt-viral-review,Viral Review,0,,,,,,,,,5,Incident,+2 coins and +1 reputation from sudden online fame.,All,,2,1,,,,,,,,,, event,evt-vandalism,Vandalism,0,,,,,,,,,5,Incident,-1 coin to all businesses and -1 reputation.,All,,-1,-1,,,,,,,,,, -event,evt-flu-outbreak,Flu Outbreak,0,,,,,,,,,4,Incident,All businesses generate 80% income for 5 turns. Duration reduced by Clinic/Medical Center.,All,,0,0,5,income-multiplier,0.8,,,,,,, -event,evt-harvest-festival,Harvest Festival,3,,,,,,,,,5,Investment,+2 coins to each Food business and +1 reputation from a bountiful harvest celebration.,SpecificSynergy,Food,2,1,,,,,,,,,, -event,evt-health-campaign,Health Campaign,3,,,,,,,,,5,Investment,+1 coin to each Health business and +1 reputation from a wellness initiative.,SpecificSynergy,Health,1,1,,,,,,,,,, -event,evt-street-performer,Street Performer,2,,,,,,,,,5,Investment,+2 coins to each Entertainment business from a popular busker drawing crowds.,SpecificSynergy,Entertainment,2,0,,,,,,,,,, -event,evt-bulk-purchase,Bulk Purchase,3,,,,,,,,,5,Investment,+1 coin to each Commerce business and +2 reputation from collective buying power.,SpecificSynergy,Commerce,1,2,,,,,,,,,, -event,evt-book-fair,Book Fair,3,,,,,,,,,5,Investment,+1 coin to each Culture business and +2 reputation from literary events.,SpecificSynergy,Culture,1,2,,,,,,,,,, -event,evt-volunteer-day,Volunteer Day,2,,,,,,,,,5,Investment,+1 coin to each Service business and +2 reputation from community volunteering.,SpecificSynergy,Service,1,2,,,,,,,,,, -event,evt-community-garden,Community Garden,2,,,,,,,,,5,Investment,+1 coin and +1 reputation from a new community garden project.,All,,1,1,,,,,,,,,, -event,evt-festival-season,Festival Season,4,,,,,,,,,5,Investment,+3 coins from increased tourist spending during festival season.,All,,3,0,,,,,,,,,, -event,evt-protest,Protest,0,,,,,,,,,5,Incident,-2 coins from reduced foot traffic and -1 reputation from negative publicity.,All,,-2,-1,,,,,,,,,, -event,evt-supply-chain,Supply Chain Delay,0,,,,,,,,,5,Incident,-2 coins per Commerce business from delayed inventory.,SpecificSynergy,Commerce,-2,0,,,,,,,,,, -event,evt-power-surge,Power Surge,0,,,,,,,,,5,Incident,-2 coins per Service business from equipment damage.,SpecificSynergy,Service,-2,0,,,,,,,,,, -event,evt-strike,Strike,0,,,,,,,,,5,Incident,-2 coins from work stoppages affecting the street.,All,,-2,0,,,,,,,,,, -event,evt-heatwave,Heatwave,0,,,,,,,,,5,Incident,-1 coin per Food business from spoiled goods and -1 reputation.,SpecificSynergy,Food,-1,-1,,,,,,,,,, -event,evt-pest-infestation,Pest Infestation,0,,,,,,,,,5,Incident,-2 coins per Food business from health-related closures.,SpecificSynergy,Food,-2,0,,,,,,,,,, -event,evt-slow-season,Slow Season,0,,,,,,,,,5,Incident,-1 coin to all businesses from reduced customer traffic.,All,,-1,0,,,,,,,,,, -event,evt-good-press,Good Press,0,,,,,,,,,5,Incident,+1 reputation from a favourable news article about Main Street.,All,,0,1,,,,,,,,,, -event,evt-tourist-bus,Tourist Bus,0,,,,,,,,,5,Incident,+2 coins per Entertainment business from a tour bus dropping visitors.,SpecificSynergy,Entertainment,2,0,,,,,,,,,, -event,evt-cultural-grant,Cultural Grant,0,,,,,,,,,5,Incident,+1 coin per Culture business and +1 reputation from a government arts grant.,SpecificSynergy,Culture,1,1,,,,,,,,,, -upgrade,upg-patisserie,Upgrade to Patisserie,4,,,,,,,,"Turns a Bakery into a Patisserie, increasing income and synergy range.",1,,,,,,,,,,Bakery,1,1,0,,, -upgrade,upg-bistro,Upgrade to Bistro,4,,,,,,,,Turns a Diner into a Bistro with higher foot-traffic.,1,,,,,,,,,,Diner,1,1,0,,, +event,evt-flu-outbreak,Flu Outbreak,1,,,,,,,,,4,Incident,All businesses generate 80% income for 5 turns. Duration reduced by Clinic/Medical Center.,All,,0,0,5,income-multiplier,0.8,,,,,,, +event,evt-harvest-festival,Harvest Festival,9,,,,,,,,,5,Investment,+2 coins to each Food business and +1 reputation from a bountiful harvest celebration.,SpecificSynergy,Food,6,4.5,,,,,,,,,, +event,evt-health-campaign,Health Campaign,5,,,,,,,,,5,Investment,+1 coin to each Health business and +1 reputation from a wellness initiative.,SpecificSynergy,Health,1.5,3,,,,,,,,,, +event,evt-street-performer,Street Performer,5,,,,,,,,,5,Investment,+2 coins to each Entertainment business from a popular busker drawing crowds.,SpecificSynergy,Entertainment,5,0,,,,,,,,,, +event,evt-bulk-purchase,Bulk Purchase,9,,,,,,,,,5,Investment,+1 coin to each Commerce business and +2 reputation from collective buying power.,SpecificSynergy,Commerce,3,9.5,,,,,,,,,, +event,evt-book-fair,Book Fair,9,,,,,,,,,5,Investment,+1 coin to each Culture business and +2 reputation from literary events.,SpecificSynergy,Culture,3,9.5,,,,,,,,,, +event,evt-volunteer-day,Volunteer Day,14,,,,,,,,,5,Investment,+1 coin to each Service business and +2 reputation from community volunteering.,SpecificSynergy,Service,7,14,,,,,,,,,, +event,evt-community-garden,Community Garden,7,,,,,,,,,5,Investment,+1 coin and +1 reputation from a new community garden project.,All,,3.5,4.5,,,,,,,,,, +event,evt-festival-season,Festival Season,7,,,,,,,,,5,Investment,+3 coins from increased tourist spending during festival season.,All,,5.5,0,,,,,,,,,, +event,evt-protest,Protest,1,,,,,,,,,5,Incident,-2 coins from reduced foot traffic and -1 reputation from negative publicity.,All,,-2,-1,,,,,,,,,, +event,evt-supply-chain,Supply Chain Delay,1,,,,,,,,,5,Incident,-2 coins per Commerce business from delayed inventory.,SpecificSynergy,Commerce,-2,0,,,,,,,,,, +event,evt-power-surge,Power Surge,1,,,,,,,,,5,Incident,-2 coins per Service business from equipment damage.,SpecificSynergy,Service,-2,0,,,,,,,,,, +event,evt-strike,Strike,1,,,,,,,,,5,Incident,-2 coins from work stoppages affecting the street.,All,,-2,0,,,,,,,,,, +event,evt-heatwave,Heatwave,1,,,,,,,,,5,Incident,-1 coin per Food business from spoiled goods and -1 reputation.,SpecificSynergy,Food,-1,-1,,,,,,,,,, +event,evt-pest-infestation,Pest Infestation,1,,,,,,,,,5,Incident,-2 coins per Food business from health-related closures.,SpecificSynergy,Food,-2,0,,,,,,,,,, +event,evt-slow-season,Slow Season,1,,,,,,,,,5,Incident,-1 coin to all businesses from reduced customer traffic.,All,,-1,0,,,,,,,,,, +event,evt-good-press,Good Press,1,,,,,,,,,5,Incident,+1 reputation from a favourable news article about Main Street.,All,,0,1,,,,,,,,,, +event,evt-tourist-bus,Tourist Bus,1,,,,,,,,,5,Incident,+2 coins per Entertainment business from a tour bus dropping visitors.,SpecificSynergy,Entertainment,2,0,,,,,,,,,, +event,evt-cultural-grant,Cultural Grant,1,,,,,,,,,5,Incident,+1 coin per Culture business and +1 reputation from a government arts grant.,SpecificSynergy,Culture,1,1,,,,,,,,,, +upgrade,upg-patisserie,Upgrade to Patisserie,3,,,,,,,,"Turns a Bakery into a Patisserie, increasing income and synergy range.",1,,,,,,,,,,Bakery,1,1,0,,, +upgrade,upg-bistro,Upgrade to Bistro,3,,,,,,,,Turns a Diner into a Bistro with higher foot-traffic.,1,,,,,,,,,,Diner,1,1,0,,, upgrade,upg-readers-cafe,Upgrade to Reader's Café,3,,,,,,,,"Transforms the Bookshop into a Reader's Café, blending books with café culture for a reputation boost.",1,,,,,,,,,,Bookshop,1,0,0,0.1,, upgrade,upg-garden,Upgrade to Garden,3,,,,,,,,Expands the Park into a Garden with extended cultural reach.,1,,,,,,,,,,Park,1,1,0,,, -upgrade,upg-home-improvement,Upgrade to Home Improvement,4,,,,,,,,Transforms the Hardware Store into a Home Improvement center.,4,,,,,,,,,,Hardware Store,1,1,0,,, +upgrade,upg-home-improvement,Upgrade to Home Improvement,7,,,,,,,,Transforms the Hardware Store into a Home Improvement center.,4,,,,,,,,,,Hardware Store,2,1,0,1,, upgrade,upg-vintage-shop,Upgrade to Vintage Shop,3,,,,,,,,Rebrands the Pawn Shop as a trendy Vintage Shop.,1,,,,,,,,,,Pawn Shop,1,0,0,,, -upgrade,upg-designer-store,Upgrade to Designer Store,4,,,,,,,,Elevates the Boutique to a Designer Store with premium clientele.,2,,,,,,,,,,Boutique,1,1,0,,, +upgrade,upg-designer-store,Upgrade to Designer Store,7,,,,,,,,Elevates the Boutique to a Designer Store with premium clientele.,2,,,,,,,,,,Boutique,2,1,0,1,, upgrade,upg-dry-cleaners,Upgrade to Dry Cleaners,3,,,,,,,,Upgrades the Laundromat to a full-service Dry Cleaners.,2,,,,,,,,,,Laundromat,1,0,0,,, -upgrade,upg-salon,Upgrade to Salon,3,,,,,,,,Expands the Barbershop into a modern Salon.,5,,,,,,,,,,Barbershop,1,1,0,,, -upgrade,upg-gaming-lounge,Upgrade to Gaming Lounge,4,,,,,,,,Transforms the Arcade into a state-of-the-art Gaming Lounge.,3,,,,,,,,,,Arcade,1,1,0,,, -upgrade,upg-imax,Upgrade to IMAX Theater,5,,,,,,,,Upgrades the Cinema to an IMAX Theater with premium experience.,3,,,,,,,,,,Cinema,2,1,0,,, -upgrade,upg-roastery,Upgrade to Roastery,3,,,,,,,,Turns the Cafe into a specialty Roastery with artisan appeal.,5,,,,,,,,,,Cafe,1,1,0,,, -upgrade,upg-gourmet-truck,Upgrade to Gourmet Truck,2,,,,,,,,Elevates the Food Truck with gourmet offerings.,3,,,,,,,,,,Food Truck,1,0,0,,, -upgrade,upg-museum,Upgrade to Museum,4,,,,,,,,Expands the Art Gallery into a full Museum.,4,,,,,,,,,,Art Gallery,1,1,0,,, -upgrade,upg-resort-spa,Upgrade to Resort Spa,5,,,,,,,,Transforms the Day Spa into a luxurious Resort Spa.,5,,,,,,,,,,Day Spa,2,1,0,,, -upgrade,upg-garden-center,Upgrade to Garden Center,3,,,,,,,,Expands the Florist into a full Garden Center.,3,,,,,,,,,,Florist,1,1,0,,, -upgrade,upg-medical-center,Upgrade to Medical Center,5,,,,,,,,Upgrades the Clinic to a comprehensive Medical Center. Provides +0.1 reputation per turn.,4,,,,,,,,,,Clinic,0,1,0,0.1,, -upgrade,upg-private-medical-center,Upgrade to Private Medical Center,4,,,,,,,,Expands the Private Clinic into a high-revenue Private Medical Center.,4,,,,,,,,,,Private Clinic,2,0,0,,, -upgrade,upg-bread-factory,Upgrade to Bread Factory,3,,,,,,,,"Scales the Bakery into a high-volume Bread Factory. More income, no range boost.",2,,,,,,,,,,Bakery,2,0,0,,, -upgrade,upg-fast-food,Upgrade to Fast Food,3,,,,,,,,"Converts the Diner to a Fast Food outlet. Higher income, smaller synergy radius.",2,,,,,,,,,,Diner,2,0,0,,, -upgrade,upg-drive-in,Upgrade to Drive-In Theater,4,,,,,,,,Turns the Cinema into a Drive-In Theater with a much wider community reach.,2,,,,,,,,,,Cinema,1,2,0,,, -upgrade,upg-wellness-center,Upgrade to Wellness Center,4,,,,,,,,Expands the Day Spa into a Wellness Center with a broader service footprint.,5,,,,,,,,,,Day Spa,1,2,0,,, -upgrade,upg-grand-bakehouse,Upgrade to Grand Bakehouse,5,,,,,,,,The pinnacle of baking craft — a Grand Bakehouse drawing visitors from afar.,4,,,,,,,,,,Bakery,2,1,1,,, -upgrade,upg-restaurant,Upgrade to Restaurant,5,,,,,,,,Elevates the Diner all the way to a full-service Restaurant.,5,,,,,,,,,,Diner,2,1,1,,, -upgrade,upg-multiplex,Upgrade to Multiplex,6,,,,,,,,A massive Multiplex complex — the entertainment heart of Main Street.,5,,,,,,,,,,Cinema,3,1,1,,, -upgrade,upg-luxury-retreat,Upgrade to Luxury Retreat,6,,,,,,,,A destination Luxury Retreat — the most prestigious business on the street.,5,,,,,,,,,,Day Spa,3,1,1,,, -upgrade,upg-community-hub,Upgrade to Community Hub,4,,,,,,,,Expands the Library into a Community Hub with extended cultural reach.,1,,,,,,,,,,Library,1,1,0,,, +upgrade,upg-salon,Upgrade to Salon,9,,,,,,,,Expands the Barbershop into a modern Salon.,5,,,,,,,,,,Barbershop,3,1,0,1,, +upgrade,upg-gaming-lounge,Upgrade to Gaming Lounge,7,,,,,,,,Transforms the Arcade into a state-of-the-art Gaming Lounge.,3,,,,,,,,,,Arcade,2,1,0,1,, +upgrade,upg-imax,Upgrade to IMAX Theater,7,,,,,,,,Upgrades the Cinema to an IMAX Theater with premium experience.,3,,,,,,,,,,Cinema,3,1,0,1,, +upgrade,upg-roastery,Upgrade to Roastery,9,,,,,,,,Turns the Cafe into a specialty Roastery with artisan appeal.,5,,,,,,,,,,Cafe,3,1,0,1,, +upgrade,upg-gourmet-truck,Upgrade to Gourmet Truck,3,,,,,,,,Elevates the Food Truck with gourmet offerings.,3,,,,,,,,,,Food Truck,1.5,0,0,,, +upgrade,upg-museum,Upgrade to Museum,7,,,,,,,,Expands the Art Gallery into a full Museum.,4,,,,,,,,,,Art Gallery,2,1,0,1,, +upgrade,upg-resort-spa,Upgrade to Resort Spa,9,,,,,,,,Transforms the Day Spa into a luxurious Resort Spa.,5,,,,,,,,,,Day Spa,3.5,1,0,1,, +upgrade,upg-garden-center,Upgrade to Garden Center,7,,,,,,,,Expands the Florist into a full Garden Center.,3,,,,,,,,,,Florist,2,1,0,1,, +upgrade,upg-medical-center,Upgrade to Medical Center,3,,,,,,,,Upgrades the Clinic to a comprehensive Medical Center. Provides +0.1 reputation per turn.,4,,,,,,,,,,Clinic,0,1,0,0.1,, +upgrade,upg-private-medical-center,Upgrade to Private Medical Center,9,,,,,,,,Expands the Private Clinic into a high-revenue Private Medical Center.,4,,,,,,,,,,Private Clinic,4.5,1,0,1,, +upgrade,upg-bread-factory,Upgrade to Bread Factory,7,,,,,,,,"Scales the Bakery into a high-volume Bread Factory. More income, no range boost.",2,,,,,,,,,,Bakery,5,1,0,1,, +upgrade,upg-fast-food,Upgrade to Fast Food,7,,,,,,,,"Converts the Diner to a Fast Food outlet. Higher income, smaller synergy radius.",2,,,,,,,,,,Diner,5,1,0,1,, +upgrade,upg-drive-in,Upgrade to Drive-In Theater,7,,,,,,,,Turns the Cinema into a Drive-In Theater with a much wider community reach.,2,,,,,,,,,,Cinema,2,2,0,1,, +upgrade,upg-wellness-center,Upgrade to Wellness Center,9,,,,,,,,Expands the Day Spa into a Wellness Center with a broader service footprint.,5,,,,,,,,,,Day Spa,2.5,2,0,1,, +upgrade,upg-grand-bakehouse,Upgrade to Grand Bakehouse,9,,,,,,,,The pinnacle of baking craft — a Grand Bakehouse drawing visitors from afar.,4,,,,,,,,,,Bakery,3.5,1,1,1,, +upgrade,upg-restaurant,Upgrade to Restaurant,14,,,,,,,,Elevates the Diner all the way to a full-service Restaurant.,5,,,,,,,,,,Diner,5.5,1,1,1,, +upgrade,upg-multiplex,Upgrade to Multiplex,14,,,,,,,,A massive Multiplex complex — the entertainment heart of Main Street.,5,,,,,,,,,,Cinema,7,1,1,1,, +upgrade,upg-luxury-retreat,Upgrade to Luxury Retreat,14,,,,,,,,A destination Luxury Retreat — the most prestigious business on the street.,5,,,,,,,,,,Day Spa,7,1,1,1,, +upgrade,upg-community-hub,Upgrade to Community Hub,3,,,,,,,,Expands the Library into a Community Hub with extended cultural reach.,1,,,,,,,,,,Library,1,1,0,,, staff,staff-assistant,Assistant,3,,,,,,,,Hire an assistant to help manage your hand. Adds +1 hand slot with a small ongoing cost.,,,,,,,,,,,,,,,,1,1 -staff,staff-manager,Manager,6,,,,,,,,A skilled manager keeps things organised. Adds +2 hand slots with a moderate ongoing cost.,,,,,,,,,,,,,,,,2,2 -staff,staff-director,Director,10,,,,,,,,An experienced director oversees your operations. Adds +3 hand slots with a high ongoing cost.,,,,,,,,,,,,,,,,3,3 +staff,staff-manager,Manager,7,,,,,,,,A skilled manager keeps things organised. Adds +2 hand slots with a moderate ongoing cost.,,,,,,,,,,,,,,,,2.5,2 +staff,staff-director,Director,14,,,,,,,,An experienced director oversees your operations. Adds +3 hand slots with a high ongoing cost.,,,,,,,,,,,,,,,,4,3 diff --git a/example-games/main-street/i18n/tutorial-en.ts b/example-games/main-street/i18n/tutorial-en.ts index 27694b30..e89d7504 100644 --- a/example-games/main-street/i18n/tutorial-en.ts +++ b/example-games/main-street/i18n/tutorial-en.ts @@ -105,7 +105,7 @@ export const TUTORIAL_EN_BUNDLE: Record = { [tutorialKey('T3', 'title')]: 'Development Row', [tutorialKey('T3', 'body')]: - 'Buy the **Laundromat** card from the Development row for $6. It is the cheapest card and earns money each turn. Place cards on your street to earn income.', + 'Buy the **Laundromat** card from the Development row for €6. It is the cheapest card and earns money each turn. Place cards on your street to earn income.', // ── T4: Place a Business ──────────────────────────────────── [tutorialKey('T4', 'title')]: diff --git a/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts b/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts index 885cf44f..c61b9d21 100644 --- a/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts +++ b/example-games/main-street/scenes/MainStreetCardSvgGenerator.ts @@ -1,19 +1,25 @@ /** * Main Street SVG Card Generator * - * Generates SVG strings dynamically for BusinessCard and CommunitySpaceCard - * based on their current state (income, reputation, name/level). This replaces - * the static-SVG + Phaser-overlay approach with a single rendering path: - * card state → SVG string → rasterised texture. + * Generates SVG strings dynamically for all card families (Business, + * Community Space, Event, Upgrade, Staff) based on card state or + * from CSV template data. * - * Event and Upgrade cards remain static SVGs — they have no dynamic visual - * state. Only Business and Community Space cards change appearance based on - * game state (upgrades, synergy bonuses, etc.). + * Business and Community Space cards include dynamic state (income, + * reputation, level). Event, Upgrade, and Staff cards are generated + * from template data only (no dynamic visual state). * * @module MainStreetCardSvgGenerator */ -import type { BusinessCard, CommunitySpaceCard, SynergyType } from '../MainStreetCards'; +import type { + BusinessCard, + CommunitySpaceCard, + EventCard, + UpgradeCard, + StaffCard, + SynergyType, +} from '../MainStreetCards'; import { synergyColor } from '../MainStreetCards'; // --------------------------------------------------------------------------- @@ -169,3 +175,167 @@ function fmtRep(v: number): string { if (Number.isInteger(v)) return String(v); return v.toFixed(1); } + +/** + * Generate an SVG string for an EventCard from template data. + * Shows card name, trigger type, and cost. + */ +export function generateEventCardSvg( + card: EventCard, + width: number = CARD_W, + height: number = CARD_H, +): string { + const bgFill = card.trigger === 'Incident' ? '#2B3A67' : '#8B4513'; + const inner: string[] = []; + + inner.push(' '); + inner.push(' ' + esc(card.name) + ''); + inner.push(' [' + esc(card.trigger) + ']'); + inner.push(' ' + costBadgeSvg(card.cost, width, height)); + + return svgShell(card.id, card.name, bgFill, inner, width, height); +} + +/** + * Generate an SVG string for an UpgradeCard from template data. + * Shows upgrade name, target business, and cost. + */ +export function generateUpgradeCardSvg( + card: UpgradeCard, + width: number = CARD_W, + height: number = CARD_H, +): string { + const inner: string[] = []; + inner.push(' '); + inner.push(' ' + esc(card.name) + ''); + inner.push(' for ' + esc(card.targetBusiness) + ''); + inner.push(' ' + costBadgeSvg(card.cost, width, height)); + + return svgShell(card.id, card.name, '#6B4C9A', inner, width, height); +} + +/** + * Generate an SVG string for a StaffCard from template data. + * Shows staff name, ongoing cost, hand slots, and purchase cost. + */ +export function generateStaffCardSvg( + card: StaffCard, + width: number = CARD_W, + height: number = CARD_H, +): string { + const inner: string[] = []; + inner.push(' '); + inner.push(' ' + esc(card.name) + ''); + inner.push(' -' + card.ongoingCost + '/turn'); + if (card.handSlotsAdded > 0) { + inner.push(' +' + card.handSlotsAdded + ' slots'); + } + inner.push(' ' + costBadgeSvg(card.cost, width, height)); + + return svgShell(card.id, card.name, '#555555', inner, width, height); +} + +/** + * Determine background/accent color scheme for CSV-row-based SVG generation. + */ +function cardColorScheme(family: string, trigger?: string): { bg: string; accent: string } { + if (family === 'event') { + if (trigger === 'Incident') return { bg: '#2B3A67', accent: '#3D5A80' }; + return { bg: '#8B4513', accent: '#A0522D' }; + } + if (family === 'upgrade') return { bg: '#6B4C9A', accent: '#9B59B6' }; + if (family === 'staff') return { bg: '#555555', accent: '#888888' }; + if (family === 'community-space') return { bg: '#2f2f2f', accent: '#cccccc' }; + return { bg: '#2f2f2f', accent: '#cccccc' }; +} + +/** Build the cost badge SVG (shared helper). */ +function costBadgeSvg(cost: number, width: number, height: number): string { + const cx = width - 16; + const cy = height - 16; + return ( + '\n' + + ' ' + cost + '' + ); +} + +/** Build the common SVG shell with defs, background, and gradient. */ +function svgShell( + cardId: string, + name: string, + bgFill: string, + innerElements: string[], + width: number, + height: number, +): string { + const lines: string[] = [ + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ]; + for (const elem of innerElements) { + lines.push(elem); + } + lines.push(''); + return lines.join('\n'); +} + +/** + * Generate an SVG string for a card from its parsed CSV row data. + * + * Produces a card image matching the format of the static SVGs generated + * by scripts/generate-main-street-card-svgs.mjs. Used as a runtime fallback + * when the static SVGs are out of date with the CSV. + * + * @param row - A single row from the parsed card-data.csv. + * @param width - SVG width (default 140). + * @param height - SVG height (default 80). + * @returns A complete SVG string. + */ +export function generateCardSvgFromCsvRow( + row: Record, + width: number = CARD_W, + height: number = CARD_H, +): string { + const id = row.id || 'unknown'; + const name = row.name || 'Unknown'; + const family = row.family || 'business'; + const cost = row.cost ? Number(row.cost) : null; + const trigger = row.trigger || undefined; + + const scheme = cardColorScheme(family, trigger); + const inner: string[] = []; + + // Header bar + inner.push(' '); + inner.push(' ' + esc(name) + ''); + + // Trigger label for event cards + if (family === 'event' && trigger) { + inner.push(' [' + esc(trigger) + ']'); + } + + // Staff card details + if (family === 'staff') { + if (row.ongoingCost && Number(row.ongoingCost) > 0) { + inner.push(' -' + row.ongoingCost + '/turn'); + } + if (row.handSlotsAdded && Number(row.handSlotsAdded) > 0) { + inner.push(' +' + row.handSlotsAdded + ' slots'); + } + } + + // Cost badge + if (cost !== null) { + inner.push(' ' + costBadgeSvg(cost, width, height)); + } + + return svgShell(id, name, scheme.bg, inner, width, height); +} diff --git a/example-games/main-street/scenes/MainStreetInputManager.ts b/example-games/main-street/scenes/MainStreetInputManager.ts index 32b8d5a0..6efb1862 100644 --- a/example-games/main-street/scenes/MainStreetInputManager.ts +++ b/example-games/main-street/scenes/MainStreetInputManager.ts @@ -61,7 +61,7 @@ export class MainStreetInputManager { const hint: HintResult | null = generateHint(s.state); if (!hint) { - s.instructionText.setText('Hint not available right now.'); + s.hintBar?.setText('Hint not available right now.'); return; } @@ -84,7 +84,7 @@ export class MainStreetInputManager { s.hintedSlotIndex = null; } - s.instructionText.setText(`Hint: ${hint.rationale}`); + s.hintBar?.setText(`Hint: ${hint.rationale}`); addLog(s.state, `Hint: ${hint.rationale}`, 'neutral'); try { recordMainStreetEvent({ type: 'hint', turn: s.state.turn, recommendedAction: hint.action, rationale: hint.rationale }); } catch (_) {} diff --git a/example-games/main-street/scenes/MainStreetLifecycleManager.ts b/example-games/main-street/scenes/MainStreetLifecycleManager.ts index 8a07473e..0bdeb01c 100644 --- a/example-games/main-street/scenes/MainStreetLifecycleManager.ts +++ b/example-games/main-street/scenes/MainStreetLifecycleManager.ts @@ -1,3 +1,4 @@ +import { CSV_CHECKSUM } from '../MainStreetCards'; import { setupMainStreetGame, deserializeMainStreetState } from '../MainStreetState'; import { createDefaultCampaignProgress, loadCampaignProgress, updateCampaignAfterRun, saveCampaignProgress, createMainStreetCheckpointManager } from '../MainStreetSaveLoad'; import { DIFFICULTY_NAMES } from '../MainStreetDifficulty'; @@ -704,6 +705,33 @@ export class MainStreetLifecycleManager { unlockedCardIds: s.campaign.unlockedCardIds, }); + // Early regeneration: ensure card SVG sources are fresh from the parsed CSV + // data before any async SVG prewarming occurs. This eliminates the race + // condition where texture prewarming rasterizes stale static SVGs before + // the CSV mismatch check has a chance to run (see CG-0MRH36Z6800065JC). + // Texture cache invalidation is handled atomically inside + // prewarmVisibleCardTextures() — per-key remove-and-rasterize. + try { + s.msSvgTextureManager?.regenerateSvgSourcesFromCsv(); + } catch (_) { + // Non-fatal: scene continues with fetched SVGs if regeneration fails + } + + // Re-apply regenerated SVGs after all SVG fetches complete. Individual + // fetch() callbacks from loadCardSvgSources() may overwrite the freshly + // regenerated SVGs in cardSvgSources if they resolve after the synchronous + // regeneration above. By chaining onto cardSvgLoadPromise, we ensure fresh + // CSV-based SVGs are present before prewarmVisibleCardTextures() runs. + if (s.cardSvgLoadPromise) { + s.cardSvgLoadPromise = s.cardSvgLoadPromise.then(() => { + try { + s.msSvgTextureManager?.regenerateSvgSourcesFromCsv(); + } catch (_) { + // Non-fatal: scene continues with fetched SVGs if re-generation fails + } + }); + } + // Determine tutorial visibility options from scene state const tutorialOpts: TutorialVisibilityOptions = { replayMode: s.replayMode === true, @@ -851,6 +879,64 @@ export class MainStreetLifecycleManager { * * @param tutorialOpts Options for the tutorial offer modal (shown if no checkpoint). */ + /** + * Checks whether the static SVGs match the current CSV and regenerates them + * in-memory if needed. + * + * **New game scenario**: Fetches `csv-checksum.json` from the SVG output + * directory (written by the build-time Node.js script). If the file doesn't + * exist or its checksum differs from `CSV_CHECKSUM`, SVG sources are + * regenerated from the parsed CSV data. + * + * **Load game scenario**: When a non-empty `savedChecksum` is provided, + * compares it directly against `CSV_CHECKSUM`. + * + * This is an async fire-and-forget operation. It resolves after SVG + * sources have been regenerated (if needed), so the caller should `await` + * if they need SVGs to be fresh before proceeding. + * + * @param savedChecksum - Optional checksum from a loaded checkpoint. + */ + public async checkForCsvMismatchAndRegenerate(savedChecksum?: string): Promise { + const s = this.scene; + let mismatch = false; + let source = 'none'; + + if (savedChecksum && savedChecksum.length > 0) { + // Load game scenario: compare against saved state's csvChecksum + if (savedChecksum !== CSV_CHECKSUM) { + mismatch = true; + source = 'load'; + } + } else { + // New game scenario: fetch checksum file from SVG output directory + try { + const resp = await fetch('assets/games/main-street/svg/cards/csv-checksum.json'); + if (resp.ok) { + const data = await resp.json(); + if (data.checksum !== CSV_CHECKSUM) { + mismatch = true; + source = 'new-game'; + } + } else { + // No checksum file — assume SVGs need regeneration + mismatch = true; + source = 'no-checksum-file'; + } + } catch { + // Fetch failed — assume SVGs are up-to-date (fallback to static SVGs) + console.warn('[MainStreetLifecycleManager] Could not fetch csv-checksum.json'); + } + } + + if (mismatch) { + console.log(`[MainStreetLifecycleManager] CSV mismatch detected (${source}), regenerating SVGs in-memory`); + // Only update SVG sources — no texture clearing. Texture invalidation + // is handled atomically by prewarmVisibleCardTextures() per-key. + s.msSvgTextureManager.regenerateSvgSourcesFromCsv(); + } + } + public checkForSavedCheckpoint(tutorialOpts: TutorialVisibilityOptions): void { const s = this.scene; if (!s.checkpointManager) return; @@ -862,9 +948,13 @@ export class MainStreetLifecycleManager { const legacySeen = s.campaign ? (s.campaign as any).tutorialSeen : undefined; (s as any).tutorialOfferModal?.showIfEligible(tutorialOpts, legacySeen); } catch (_) { /* ignore */ } + // New game: check if static SVGs match current CSV + this.checkForCsvMismatchAndRegenerate().catch(() => {}); }, // Resume from checkpoint — replace state and rebuild UI (savedState: any) => { + const savedChecksum = savedState?.csvChecksum || ''; + s.state = savedState; // Mark tutorial as seen (resumed game means player already played) if (s.campaign) { @@ -873,6 +963,9 @@ export class MainStreetLifecycleManager { // Rebuild renderer and start day phase from checkpoint state try { s.refreshAll(); } catch (_) { /* ignore */ } try { s.startDayPhase(); } catch (_) { /* ignore */ } + + // Load game: compare saved checksum against current CSV + this.checkForCsvMismatchAndRegenerate(savedChecksum).catch(() => {}); }, // Resume overlay callback — use built-in default overlay (state: any, onResume: () => void, onNewGame: () => void) => { diff --git a/example-games/main-street/scenes/MainStreetRenderer.ts b/example-games/main-street/scenes/MainStreetRenderer.ts index 8f716eca..f0c4fbca 100644 --- a/example-games/main-street/scenes/MainStreetRenderer.ts +++ b/example-games/main-street/scenes/MainStreetRenderer.ts @@ -33,6 +33,7 @@ import { import { FONT_FAMILY, HandView, + HintBar, attachSelection, markHudTransient, clearTransientHud, @@ -220,14 +221,12 @@ export class MainStreetRenderer { public createInstructions(): void { const s = this.scene; - // Centered at bottom - s.instructionText = s.add - .text(s.layout.gameW / 2, s.layout.gameH - 20, '', { - fontSize: '14px', - color: '#ccaa77', - fontFamily: FONT_FAMILY, - }) - .setOrigin(0.5, 1); + // Shared HintBar for hint/instruction display at bottom-center + s.hintBar = new HintBar(s); + + // Keep legacy instructionText for backward compatibility (tests), + // referencing the HintBar's underlying text object. + s.instructionText = s.hintBar.textObject; } public refreshAll(): void { @@ -773,7 +772,7 @@ export class MainStreetRenderer { // Tooltip for the Discover button const reasonSuffix = !canRefresh && refreshDevResult.reason ? `\n\n${refreshDevResult.reason}` : ''; - const info = `Pay $${REFRESH_DEVELOPMENT_COST} to discover new development opportunities and replace the visible development row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; + const info = `Pay €${REFRESH_DEVELOPMENT_COST} to discover new development opportunities and replace the visible development row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; try { bg.on('pointerover', (pointer: any) => { if (s.tooltipManager) { @@ -849,7 +848,7 @@ export class MainStreetRenderer { // Tooltip for the Research button (attach to bg so it receives pointer events) const reasonSuffix = !canRefresh && refreshInvResult.reason ? `\n\n${refreshInvResult.reason}` : ''; - const info = `Pay $${REFRESH_INVESTMENTS_COST} to research new investment opportunities and replace the visible investments row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; + const info = `Pay €${REFRESH_INVESTMENTS_COST} to research new investment opportunities and replace the visible investments row. Removed cards go to their discard piles. Available only during Market phase.${reasonSuffix}`; try { bg.on('pointerover', (pointer: any) => { if (s.tooltipManager) { diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index f18ce27e..de8cdaaa 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -3,6 +3,7 @@ import type { DifficultyName } from '../MainStreetDifficulty'; import type { BusinessCard } from '../MainStreetCards'; import { CardGameScene, + HintBar, TooltipManager, } from '../../../src/ui'; import type { SelectionController, SingleSelectionManager } from '../../../src/ui'; @@ -88,8 +89,10 @@ export class MainStreetScene extends CardGameScene { // Challenge Tracker panel public challengeContainer!: Phaser.GameObjects.Container; - // Instruction text + // Instruction text (managed by HintBar; kept as public property for backward compat) public instructionText!: Phaser.GameObjects.Text; + /** Shared HintBar instance for standardised hint/instruction display at bottom-centre. */ + public hintBar!: HintBar; // Overlay objects public overlayObjects: Phaser.GameObjects.GameObject[] = []; diff --git a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts index 89cf54c3..176d3d7d 100644 --- a/example-games/main-street/scenes/MainStreetSvgTextureManager.ts +++ b/example-games/main-street/scenes/MainStreetSvgTextureManager.ts @@ -1,5 +1,6 @@ -import { CARD_TEMPLATE_NAMES } from '../MainStreetCards'; +import { CARD_TEMPLATE_NAMES, CSV_ROWS } from '../MainStreetCards'; import { rasteriseSvgToTexture, makeTextureKey } from '../../../src/core-engine'; +import { generateCardSvgFromCsvRow } from './MainStreetCardSvgGenerator'; export class MainStreetSvgTextureManager { private lastDevicePixelRatio: number; @@ -55,6 +56,50 @@ export class MainStreetSvgTextureManager { s.cardSvgLoadPromise = Promise.all(fetches).then(() => {}); } + /** + * Regenerates SVG sources from the current CSV data. + * + * When the card-data.csv has changed since the static SVGs were generated, + * this method produces fresh SVG strings in-memory for all card templates + * and stores them in `cardSvgSources`, overriding any stale fetched SVGs. + * + * Texture cache invalidation is NOT performed here — SVG source updates are + * separated from texture lifecycle. Textures created by prewarm use the + * correct CSV-fresh SVGs because this method runs before any prewarm call + * (both the synchronous early call in loadCampaignAndSetup() and the re-apply + * chain on cardSvgLoadPromise). + * + * If regeneration fails (e.g., CSV rows are not yet loaded), a warning is + * logged and the scene continues with existing (possibly stale) SVGs. + * + * @returns The number of SVG sources regenerated, or 0 if regeneration was skipped. + */ + public regenerateSvgSourcesFromCsv(): number { + const s = this.scene; + let count = 0; + + try { + // Generate fresh SVGs from the parsed CSV rows + for (const row of CSV_ROWS) { + const templateId = row.id; + if (!templateId) continue; + + const svg = generateCardSvgFromCsvRow(row); + s.cardSvgSources.set(templateId, svg); + count++; + } + } catch (err) { + console.warn('[MainStreetSvgTextureManager] Failed to regenerate SVGs from CSV:', err); + return 0; + } + + if (count > 0) { + console.log('[MainStreetSvgTextureManager] Regenerated ' + count + ' card SVGs from CSV data'); + } + + return count; + } + public async prewarmVisibleCardTextures(): Promise { const s = this.scene; const visibleTemplates = new Set(); @@ -95,6 +140,13 @@ export class MainStreetSvgTextureManager { for (const size of sizes) { const key = makeTextureKey(templateId, size.w, size.h, dpr); + + // If a texture already exists for this key, keep it — it was created + // from the correct CSV-based SVG sources by a prior prewarm call + // (regenerateSvgSourcesFromCsv() runs before any prewarm). Skipping + // existing textures avoids a race where removing+recreating triggers + // rasteriseSvgToTexture's internal textureCache, which awaits pending + // promises and yields to a render frame with a missing texture. if (s.textures.exists(key)) continue; const p = rasteriseSvgToTexture(s, key, svgText, size.w, size.h, dpr) diff --git a/example-games/main-street/scenes/UpgradeOverlaySpec.ts b/example-games/main-street/scenes/UpgradeOverlaySpec.ts index 740a688f..b6a93b62 100644 --- a/example-games/main-street/scenes/UpgradeOverlaySpec.ts +++ b/example-games/main-street/scenes/UpgradeOverlaySpec.ts @@ -106,13 +106,15 @@ export function buildUpgradeOverlaySpec( } : null; - // Income text: centred horizontally in the middle band of the card, shown for any card with income > 0 + // Income text: centred on the card, shown for any card with income > 0 // Uses "Income: +X/turn" format for clarity + // Container origin is at card centre, so x=0 is horizontal centre + // and a small negative y offset centres the label slightly above middle. const incomeText: OverlayTextSpec | null = totalIncome > 0 ? { text: `Income: +${totalIncome}/turn`, - x: Math.round(width / 2), - y: Math.round(height * 0.38), + x: 0, + y: Math.round(-height * 0.06), fontSize: '11px', color: '#44ff44', fontStyle: 'bold', @@ -122,6 +124,8 @@ export function buildUpgradeOverlaySpec( : null; // Reputation text: centred below income, shown for any card with reputation > 0 + // Container origin is at card centre, so x=0 is horizontal centre + // and a small positive y offset places it below the income label. // Format to at most 1 decimal place, stripping trailing zeros (e.g. 0.2, 0.3, 1.0 -> 1) const repFormatted = totalReputation > 0 @@ -130,8 +134,8 @@ export function buildUpgradeOverlaySpec( const reputationText: OverlayTextSpec | null = totalReputation > 0 ? { text: `+${repFormatted}/turn`, - x: Math.round(width / 2), - y: Math.round(height * 0.5), + x: 0, + y: Math.round(height * 0.1), fontSize: '11px', color: '#88bbff', fontStyle: 'bold', diff --git a/example-games/sushi-go/scenes/SushiGoScene.ts b/example-games/sushi-go/scenes/SushiGoScene.ts index 22356093..dc100365 100644 --- a/example-games/sushi-go/scenes/SushiGoScene.ts +++ b/example-games/sushi-go/scenes/SushiGoScene.ts @@ -210,6 +210,8 @@ export class SushiGoScene extends CardGameScene { super.create(); + this.replayController = new SushiGoReplayController(this, { value: this.replayMode }); + if (this.replayMode) { this.createHeader(); this.createLabels(); @@ -242,7 +244,6 @@ export class SushiGoScene extends CardGameScene { this.goRenderer = new SushiGoRenderer(this, this.session); this.overlayManager = new SushiGoOverlayContent(this, this.session, this.gameEvents, this.soundManager); this.tooltipManager = this.createTooltipManager(); - this.replayController = new SushiGoReplayController(this, { value: this.replayMode }); this.cardFactory = new SushiGoCardFactory(this); this.tableauRenderer = new SushiGoTableauRenderer(this, this.session, this.cardFactory, this.goRenderer, this.tooltipManager); @@ -804,7 +805,7 @@ export class SushiGoScene extends CardGameScene { ); if (!isChopsticks) continue; - // Add highlight rectangle + // Add highlight rectangle (tagged for selective cleanup) const highlight = this.add.rectangle( 0, 0, TABLEAU_CARD_W + CHOPSTICKS_TABLEAU_HIGHLIGHT_PADDING * 2, @@ -815,6 +816,7 @@ export class SushiGoScene extends CardGameScene { highlightColor, ); highlight.setFillStyle(highlightColor, CHOPSTICKS_TABLEAU_HIGHLIGHT_ALPHA); + highlight.setData('chopsticksHighlight', true); child.addAt(highlight, 0); } } @@ -824,13 +826,16 @@ export class SushiGoScene extends CardGameScene { const children = this.playerTableauContainer.getAll(); for (const child of children) { if (!(child instanceof Phaser.GameObjects.Container)) continue; - // Remove any highlight rectangles (identified by stroke style + fill) - // Phaser rectangles don't have a custom tag, so we search by type and - // look for rectangles that were added at index 0 (our highlights) + // Remove only the chopsticks highlight rectangles (tagged with + // 'chopsticksHighlight' data), leaving other Rectangles (like the + // card background bg) intact. See createCardRect. const toRemove: Phaser.GameObjects.Rectangle[] = []; const grandChildren = child.getAll(); for (const gc of grandChildren) { - if (gc instanceof Phaser.GameObjects.Rectangle) { + if ( + gc instanceof Phaser.GameObjects.Rectangle && + gc.getData('chopsticksHighlight') + ) { toRemove.push(gc); } } diff --git a/main.ts b/main.ts index d71e6e46..545a0212 100644 --- a/main.ts +++ b/main.ts @@ -10,6 +10,7 @@ * game scenes can return to the selector without needing to * know the catalogue themselves. */ +import Phaser from 'phaser'; import { createCardGame } from './src/ui/createCardGame'; import { GameSelectorScene, REGISTRY_KEY_GAMES } from './src/ui/GameSelectorScene'; import type { GameEntry } from './src/ui/GameSelectorScene'; @@ -124,6 +125,7 @@ createCardGame({ GymHudComponentsScene, GymLayoutOwnershipScene, ], + type: Phaser.CANVAS, render: isReplayMode ? { preserveDrawingBuffer: true } : undefined, callbacks: { preBoot: (game: Phaser.Game) => { diff --git a/package-lock.json b/package-lock.json index be2a8d7d..837768aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tableau-card-engine", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tableau-card-engine", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "dependencies": { "phaser": "4.0.0-rc.7" diff --git a/package.json b/package.json index 95514a1e..401f5b51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tableau-card-engine", - "version": "0.1.4", + "version": "0.1.5", "description": "Tableau Card Engine (TCE) -- a modular game engine for building single-player tableau card games using Phaser 4 RC and TypeScript", "private": true, "type": "module", @@ -8,7 +8,7 @@ "dev": "vite --host", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "balance-cards": "tsc --noEmit && tsx scripts/run-balance-cards.ts", + "balance-cards": "tsx scripts/run-balance-cards.ts", "test": "vitest run --project unit && vitest run --project browser && bash scripts/run-tutorial-tests.sh", "monte-carlo": "tsx scripts/monte-carlo.ts --seeds 200 --seed-prefix mc-balance --maxTurns 25 --strategy greedy --out results/main-street-monte-carlo.json --csv-out results/main-street-monte-carlo.csv", "replay": "tsx scripts/replay.ts", diff --git a/public/assets/audio/default/illegal-move.wav b/public/assets/audio/default/illegal-move.wav new file mode 100644 index 00000000..7bc8a537 Binary files /dev/null and b/public/assets/audio/default/illegal-move.wav differ diff --git a/public/assets/cards/lost-cities/lc-blue-inv1-sm.svg b/public/assets/cards/lost-cities/lc-blue-inv1-sm.svg index b3c3ccaf..e051a2c6 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv1-sm.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv1-sm.svg @@ -1,5 +1,5 @@ - $1 + €1 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-blue-inv1.svg b/public/assets/cards/lost-cities/lc-blue-inv1.svg index 0cd5efdd..07d84086 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv1.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv1.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a3a6b">€ Blue diff --git a/public/assets/cards/lost-cities/lc-blue-inv2-sm.svg b/public/assets/cards/lost-cities/lc-blue-inv2-sm.svg index 5e11dd1b..162bface 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv2-sm.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv2-sm.svg @@ -1,5 +1,5 @@ - $2 + €2 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-blue-inv2.svg b/public/assets/cards/lost-cities/lc-blue-inv2.svg index b85ba420..1d2e9695 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv2.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv2.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a3a6b">€ diff --git a/public/assets/cards/lost-cities/lc-blue-inv3-sm.svg b/public/assets/cards/lost-cities/lc-blue-inv3-sm.svg index c01b5808..d66fafef 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv3-sm.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv3-sm.svg @@ -1,5 +1,5 @@ - $3 + €3 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-blue-inv3.svg b/public/assets/cards/lost-cities/lc-blue-inv3.svg index 0b96d008..e3f3dbef 100644 --- a/public/assets/cards/lost-cities/lc-blue-inv3.svg +++ b/public/assets/cards/lost-cities/lc-blue-inv3.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a3a6b">€ diff --git a/public/assets/cards/lost-cities/lc-green-inv1-sm.svg b/public/assets/cards/lost-cities/lc-green-inv1-sm.svg index 1d637784..0928f44b 100644 --- a/public/assets/cards/lost-cities/lc-green-inv1-sm.svg +++ b/public/assets/cards/lost-cities/lc-green-inv1-sm.svg @@ -1,5 +1,5 @@ - $1 + €1 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-green-inv1.svg b/public/assets/cards/lost-cities/lc-green-inv1.svg index 65e2f026..b5b0baf6 100644 --- a/public/assets/cards/lost-cities/lc-green-inv1.svg +++ b/public/assets/cards/lost-cities/lc-green-inv1.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a5c3a">€ Green diff --git a/public/assets/cards/lost-cities/lc-green-inv2-sm.svg b/public/assets/cards/lost-cities/lc-green-inv2-sm.svg index b4c85334..8bc829e9 100644 --- a/public/assets/cards/lost-cities/lc-green-inv2-sm.svg +++ b/public/assets/cards/lost-cities/lc-green-inv2-sm.svg @@ -1,5 +1,5 @@ - $2 + €2 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-green-inv2.svg b/public/assets/cards/lost-cities/lc-green-inv2.svg index 7726d053..00f1fd5a 100644 --- a/public/assets/cards/lost-cities/lc-green-inv2.svg +++ b/public/assets/cards/lost-cities/lc-green-inv2.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a5c3a">€ diff --git a/public/assets/cards/lost-cities/lc-green-inv3-sm.svg b/public/assets/cards/lost-cities/lc-green-inv3-sm.svg index 19bd5649..f5ccbf39 100644 --- a/public/assets/cards/lost-cities/lc-green-inv3-sm.svg +++ b/public/assets/cards/lost-cities/lc-green-inv3-sm.svg @@ -1,5 +1,5 @@ - $3 + €3 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-green-inv3.svg b/public/assets/cards/lost-cities/lc-green-inv3.svg index 44c5cff2..2e187217 100644 --- a/public/assets/cards/lost-cities/lc-green-inv3.svg +++ b/public/assets/cards/lost-cities/lc-green-inv3.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#1a5c3a">€ diff --git a/public/assets/cards/lost-cities/lc-red-inv1-sm.svg b/public/assets/cards/lost-cities/lc-red-inv1-sm.svg index c4e3374a..c65cfa13 100644 --- a/public/assets/cards/lost-cities/lc-red-inv1-sm.svg +++ b/public/assets/cards/lost-cities/lc-red-inv1-sm.svg @@ -1,5 +1,5 @@ - $1 + €1 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-red-inv1.svg b/public/assets/cards/lost-cities/lc-red-inv1.svg index 5b379036..c1ba9c1d 100644 --- a/public/assets/cards/lost-cities/lc-red-inv1.svg +++ b/public/assets/cards/lost-cities/lc-red-inv1.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#6b1a1a">€ Red diff --git a/public/assets/cards/lost-cities/lc-red-inv2-sm.svg b/public/assets/cards/lost-cities/lc-red-inv2-sm.svg index 4fcd3440..116e330c 100644 --- a/public/assets/cards/lost-cities/lc-red-inv2-sm.svg +++ b/public/assets/cards/lost-cities/lc-red-inv2-sm.svg @@ -1,5 +1,5 @@ - $2 + €2 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-red-inv2.svg b/public/assets/cards/lost-cities/lc-red-inv2.svg index e5b90f26..f0fd294a 100644 --- a/public/assets/cards/lost-cities/lc-red-inv2.svg +++ b/public/assets/cards/lost-cities/lc-red-inv2.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#6b1a1a">€ diff --git a/public/assets/cards/lost-cities/lc-red-inv3-sm.svg b/public/assets/cards/lost-cities/lc-red-inv3-sm.svg index 5fa4f35e..6d83bdc8 100644 --- a/public/assets/cards/lost-cities/lc-red-inv3-sm.svg +++ b/public/assets/cards/lost-cities/lc-red-inv3-sm.svg @@ -1,5 +1,5 @@ - $3 + €3 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-red-inv3.svg b/public/assets/cards/lost-cities/lc-red-inv3.svg index 80048e00..60083eed 100644 --- a/public/assets/cards/lost-cities/lc-red-inv3.svg +++ b/public/assets/cards/lost-cities/lc-red-inv3.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#6b1a1a">€ diff --git a/public/assets/cards/lost-cities/lc-white-inv1-sm.svg b/public/assets/cards/lost-cities/lc-white-inv1-sm.svg index c3f98d78..6422ed37 100644 --- a/public/assets/cards/lost-cities/lc-white-inv1-sm.svg +++ b/public/assets/cards/lost-cities/lc-white-inv1-sm.svg @@ -1,5 +1,5 @@ - $1 + €1 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-white-inv1.svg b/public/assets/cards/lost-cities/lc-white-inv1.svg index e7c2ae89..3ec691be 100644 --- a/public/assets/cards/lost-cities/lc-white-inv1.svg +++ b/public/assets/cards/lost-cities/lc-white-inv1.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#555555">€ White diff --git a/public/assets/cards/lost-cities/lc-white-inv2-sm.svg b/public/assets/cards/lost-cities/lc-white-inv2-sm.svg index 773a5f9a..e7634207 100644 --- a/public/assets/cards/lost-cities/lc-white-inv2-sm.svg +++ b/public/assets/cards/lost-cities/lc-white-inv2-sm.svg @@ -1,5 +1,5 @@ - $2 + €2 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-white-inv2.svg b/public/assets/cards/lost-cities/lc-white-inv2.svg index d9188e12..10c308be 100644 --- a/public/assets/cards/lost-cities/lc-white-inv2.svg +++ b/public/assets/cards/lost-cities/lc-white-inv2.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#555555">€ diff --git a/public/assets/cards/lost-cities/lc-white-inv3-sm.svg b/public/assets/cards/lost-cities/lc-white-inv3-sm.svg index 6fc4a461..b13369cc 100644 --- a/public/assets/cards/lost-cities/lc-white-inv3-sm.svg +++ b/public/assets/cards/lost-cities/lc-white-inv3-sm.svg @@ -1,5 +1,5 @@ - $3 + €3 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-white-inv3.svg b/public/assets/cards/lost-cities/lc-white-inv3.svg index 4f2f0a6a..a48fc236 100644 --- a/public/assets/cards/lost-cities/lc-white-inv3.svg +++ b/public/assets/cards/lost-cities/lc-white-inv3.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#555555">€ diff --git a/public/assets/cards/lost-cities/lc-yellow-inv1-sm.svg b/public/assets/cards/lost-cities/lc-yellow-inv1-sm.svg index 58b7819b..1a3adf9a 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv1-sm.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv1-sm.svg @@ -1,5 +1,5 @@ - $1 + €1 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-yellow-inv1.svg b/public/assets/cards/lost-cities/lc-yellow-inv1.svg index 6676f0d8..8b57747a 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv1.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv1.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#8b6914">€ Yellow diff --git a/public/assets/cards/lost-cities/lc-yellow-inv2-sm.svg b/public/assets/cards/lost-cities/lc-yellow-inv2-sm.svg index f07104a4..a7aa4772 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv2-sm.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv2-sm.svg @@ -1,5 +1,5 @@ - $2 + €2 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-yellow-inv2.svg b/public/assets/cards/lost-cities/lc-yellow-inv2.svg index 5c51de32..8858d4ed 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv2.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv2.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#8b6914">€ diff --git a/public/assets/cards/lost-cities/lc-yellow-inv3-sm.svg b/public/assets/cards/lost-cities/lc-yellow-inv3-sm.svg index 7bfa879b..67243e4a 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv3-sm.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv3-sm.svg @@ -1,5 +1,5 @@ - $3 + €3 \ No newline at end of file diff --git a/public/assets/cards/lost-cities/lc-yellow-inv3.svg b/public/assets/cards/lost-cities/lc-yellow-inv3.svg index d54481cc..512370c4 100644 --- a/public/assets/cards/lost-cities/lc-yellow-inv3.svg +++ b/public/assets/cards/lost-cities/lc-yellow-inv3.svg @@ -7,7 +7,7 @@ $ + text-anchor="middle" fill="#8b6914">€ diff --git a/public/assets/games/beleaguered-castle/thumbnail.png b/public/assets/games/beleaguered-castle/thumbnail.png index bee1ea22..a7761448 100644 Binary files a/public/assets/games/beleaguered-castle/thumbnail.png and b/public/assets/games/beleaguered-castle/thumbnail.png differ diff --git a/public/assets/games/feudalism/thumbnail.png b/public/assets/games/feudalism/thumbnail.png index ed440bce..f26fd0c8 100644 Binary files a/public/assets/games/feudalism/thumbnail.png and b/public/assets/games/feudalism/thumbnail.png differ diff --git a/public/assets/games/golf/thumbnail.png b/public/assets/games/golf/thumbnail.png index 7c7fb54b..05775ad6 100644 Binary files a/public/assets/games/golf/thumbnail.png and b/public/assets/games/golf/thumbnail.png differ diff --git a/public/assets/games/lost-cities/thumbnail.png b/public/assets/games/lost-cities/thumbnail.png index 62510563..79f3b7b0 100644 Binary files a/public/assets/games/lost-cities/thumbnail.png and b/public/assets/games/lost-cities/thumbnail.png differ diff --git a/public/assets/games/main-street/svg/cards/biz-arcade.svg b/public/assets/games/main-street/svg/cards/biz-arcade.svg index bcd5256d..5cd47e3e 100644 --- a/public/assets/games/main-street/svg/cards/biz-arcade.svg +++ b/public/assets/games/main-street/svg/cards/biz-arcade.svg @@ -11,7 +11,7 @@ Arcade -8 +4 Entertainment icon diff --git a/public/assets/games/main-street/svg/cards/biz-bakery.svg b/public/assets/games/main-street/svg/cards/biz-bakery.svg index de9a8326..520b1823 100644 --- a/public/assets/games/main-street/svg/cards/biz-bakery.svg +++ b/public/assets/games/main-street/svg/cards/biz-bakery.svg @@ -11,7 +11,7 @@ Bakery -6 +3 Food icon diff --git a/public/assets/games/main-street/svg/cards/biz-barbershop.svg b/public/assets/games/main-street/svg/cards/biz-barbershop.svg index 79eb3ca8..0da589b3 100644 --- a/public/assets/games/main-street/svg/cards/biz-barbershop.svg +++ b/public/assets/games/main-street/svg/cards/biz-barbershop.svg @@ -11,7 +11,7 @@ Barbershop -6 +5 Service icon diff --git a/public/assets/games/main-street/svg/cards/biz-bookshop.svg b/public/assets/games/main-street/svg/cards/biz-bookshop.svg index 999f740f..1e299fca 100644 --- a/public/assets/games/main-street/svg/cards/biz-bookshop.svg +++ b/public/assets/games/main-street/svg/cards/biz-bookshop.svg @@ -11,7 +11,7 @@ Bookshop -8 +3 Culture icon diff --git a/public/assets/games/main-street/svg/cards/biz-boutique.svg b/public/assets/games/main-street/svg/cards/biz-boutique.svg index f4e5ce91..59b01d5c 100644 --- a/public/assets/games/main-street/svg/cards/biz-boutique.svg +++ b/public/assets/games/main-street/svg/cards/biz-boutique.svg @@ -11,7 +11,7 @@ Boutique -8 +3 Commerce icon diff --git a/public/assets/games/main-street/svg/cards/biz-cafe.svg b/public/assets/games/main-street/svg/cards/biz-cafe.svg index 528f9ab7..ca32a898 100644 --- a/public/assets/games/main-street/svg/cards/biz-cafe.svg +++ b/public/assets/games/main-street/svg/cards/biz-cafe.svg @@ -11,7 +11,7 @@ Cafe -6 +7 Food icon diff --git a/public/assets/games/main-street/svg/cards/biz-cinema.svg b/public/assets/games/main-street/svg/cards/biz-cinema.svg index 40c6392f..7b08eae3 100644 --- a/public/assets/games/main-street/svg/cards/biz-cinema.svg +++ b/public/assets/games/main-street/svg/cards/biz-cinema.svg @@ -11,7 +11,7 @@ Cinema -10 +5 Entertainment icon diff --git a/public/assets/games/main-street/svg/cards/biz-clinic.svg b/public/assets/games/main-street/svg/cards/biz-clinic.svg index 319a4a43..78d60197 100644 --- a/public/assets/games/main-street/svg/cards/biz-clinic.svg +++ b/public/assets/games/main-street/svg/cards/biz-clinic.svg @@ -11,7 +11,7 @@ Clinic -10 +9 diff --git a/public/assets/games/main-street/svg/cards/biz-diner.svg b/public/assets/games/main-street/svg/cards/biz-diner.svg index 00be16ad..7b18f248 100644 --- a/public/assets/games/main-street/svg/cards/biz-diner.svg +++ b/public/assets/games/main-street/svg/cards/biz-diner.svg @@ -11,7 +11,7 @@ Diner -8 +3 Food icon diff --git a/public/assets/games/main-street/svg/cards/biz-florist.svg b/public/assets/games/main-street/svg/cards/biz-florist.svg index 83c4db1c..933032d8 100644 --- a/public/assets/games/main-street/svg/cards/biz-florist.svg +++ b/public/assets/games/main-street/svg/cards/biz-florist.svg @@ -11,7 +11,7 @@ Florist -4 +5 Commerce icon diff --git a/public/assets/games/main-street/svg/cards/biz-gallery.svg b/public/assets/games/main-street/svg/cards/biz-gallery.svg index e4860553..869cc64a 100644 --- a/public/assets/games/main-street/svg/cards/biz-gallery.svg +++ b/public/assets/games/main-street/svg/cards/biz-gallery.svg @@ -11,7 +11,7 @@ Art Gallery -8 +14 Culture icon diff --git a/public/assets/games/main-street/svg/cards/biz-hardware.svg b/public/assets/games/main-street/svg/cards/biz-hardware.svg index 1bdcf04a..515e8415 100644 --- a/public/assets/games/main-street/svg/cards/biz-hardware.svg +++ b/public/assets/games/main-street/svg/cards/biz-hardware.svg @@ -11,7 +11,7 @@ Hardware Store -10 +3 Commerce icon diff --git a/public/assets/games/main-street/svg/cards/biz-laundromat.svg b/public/assets/games/main-street/svg/cards/biz-laundromat.svg index 216fdc08..0ed1c730 100644 --- a/public/assets/games/main-street/svg/cards/biz-laundromat.svg +++ b/public/assets/games/main-street/svg/cards/biz-laundromat.svg @@ -11,7 +11,7 @@ Laundromat -3 +4 Service icon diff --git a/public/assets/games/main-street/svg/cards/biz-pawnshop.svg b/public/assets/games/main-street/svg/cards/biz-pawnshop.svg index c0cf0d1b..24e436bd 100644 --- a/public/assets/games/main-street/svg/cards/biz-pawnshop.svg +++ b/public/assets/games/main-street/svg/cards/biz-pawnshop.svg @@ -11,7 +11,7 @@ Pawn Shop -6 +3 Commerce icon diff --git a/public/assets/games/main-street/svg/cards/biz-pharmacy.svg b/public/assets/games/main-street/svg/cards/biz-pharmacy.svg index 6d622408..daf74b6c 100644 --- a/public/assets/games/main-street/svg/cards/biz-pharmacy.svg +++ b/public/assets/games/main-street/svg/cards/biz-pharmacy.svg @@ -11,7 +11,7 @@ Pharmacy -6 +7 diff --git a/public/assets/games/main-street/svg/cards/biz-private-clinic.svg b/public/assets/games/main-street/svg/cards/biz-private-clinic.svg index 261c450e..ebbbcca7 100644 --- a/public/assets/games/main-street/svg/cards/biz-private-clinic.svg +++ b/public/assets/games/main-street/svg/cards/biz-private-clinic.svg @@ -11,7 +11,7 @@ Private Clinic -8 +14 diff --git a/public/assets/games/main-street/svg/cards/biz-spa.svg b/public/assets/games/main-street/svg/cards/biz-spa.svg index 61a3caa4..fe2f1844 100644 --- a/public/assets/games/main-street/svg/cards/biz-spa.svg +++ b/public/assets/games/main-street/svg/cards/biz-spa.svg @@ -11,7 +11,7 @@ Day Spa -10 +14 Service icon diff --git a/public/assets/games/main-street/svg/cards/cs-library.svg b/public/assets/games/main-street/svg/cards/cs-library.svg index e37898ef..1a431c77 100644 --- a/public/assets/games/main-street/svg/cards/cs-library.svg +++ b/public/assets/games/main-street/svg/cards/cs-library.svg @@ -11,7 +11,7 @@ Library -6 +14 Culture icon diff --git a/public/assets/games/main-street/svg/cards/cs-park.svg b/public/assets/games/main-street/svg/cards/cs-park.svg index 9b524d6a..3086a87f 100644 --- a/public/assets/games/main-street/svg/cards/cs-park.svg +++ b/public/assets/games/main-street/svg/cards/cs-park.svg @@ -11,7 +11,7 @@ Park -4 +3 Culture icon diff --git a/public/assets/games/main-street/svg/cards/csv-checksum.json b/public/assets/games/main-street/svg/cards/csv-checksum.json new file mode 100644 index 00000000..d29e7ef7 --- /dev/null +++ b/public/assets/games/main-street/svg/cards/csv-checksum.json @@ -0,0 +1 @@ +{"checksum":"081ec00b"} \ No newline at end of file diff --git a/public/assets/games/main-street/svg/cards/evt-block-party.svg b/public/assets/games/main-street/svg/cards/evt-block-party.svg index 23f61611..6717ec4a 100644 --- a/public/assets/games/main-street/svg/cards/evt-block-party.svg +++ b/public/assets/games/main-street/svg/cards/evt-block-party.svg @@ -11,7 +11,7 @@ Block Party -4 +5 diff --git a/public/assets/games/main-street/svg/cards/evt-book-fair.svg b/public/assets/games/main-street/svg/cards/evt-book-fair.svg index f937bc0a..2b22f83a 100644 --- a/public/assets/games/main-street/svg/cards/evt-book-fair.svg +++ b/public/assets/games/main-street/svg/cards/evt-book-fair.svg @@ -11,7 +11,7 @@ Book Fair -3 +9 diff --git a/public/assets/games/main-street/svg/cards/evt-bulk-purchase.svg b/public/assets/games/main-street/svg/cards/evt-bulk-purchase.svg index 31b51531..05768e72 100644 --- a/public/assets/games/main-street/svg/cards/evt-bulk-purchase.svg +++ b/public/assets/games/main-street/svg/cards/evt-bulk-purchase.svg @@ -11,7 +11,7 @@ Bulk Purchase -3 +9 diff --git a/public/assets/games/main-street/svg/cards/evt-charity-drive.svg b/public/assets/games/main-street/svg/cards/evt-charity-drive.svg index d35ccaaf..1cbaf65a 100644 --- a/public/assets/games/main-street/svg/cards/evt-charity-drive.svg +++ b/public/assets/games/main-street/svg/cards/evt-charity-drive.svg @@ -11,7 +11,7 @@ Charity Drive -2 +9 diff --git a/public/assets/games/main-street/svg/cards/evt-community-garden.svg b/public/assets/games/main-street/svg/cards/evt-community-garden.svg index ff2e60a3..5aa093fe 100644 --- a/public/assets/games/main-street/svg/cards/evt-community-garden.svg +++ b/public/assets/games/main-street/svg/cards/evt-community-garden.svg @@ -11,7 +11,7 @@ Community Garden -2 +7 diff --git a/public/assets/games/main-street/svg/cards/evt-cultural-grant.svg b/public/assets/games/main-street/svg/cards/evt-cultural-grant.svg index 14f3d23e..cb0dcc96 100644 --- a/public/assets/games/main-street/svg/cards/evt-cultural-grant.svg +++ b/public/assets/games/main-street/svg/cards/evt-cultural-grant.svg @@ -11,7 +11,7 @@ Cultural Grant -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-festival-season.svg b/public/assets/games/main-street/svg/cards/evt-festival-season.svg index 55a4f230..ad38a995 100644 --- a/public/assets/games/main-street/svg/cards/evt-festival-season.svg +++ b/public/assets/games/main-street/svg/cards/evt-festival-season.svg @@ -11,7 +11,7 @@ Festival Season -4 +7 diff --git a/public/assets/games/main-street/svg/cards/evt-flu-outbreak.svg b/public/assets/games/main-street/svg/cards/evt-flu-outbreak.svg index 775b1ad4..efc1eb1c 100644 --- a/public/assets/games/main-street/svg/cards/evt-flu-outbreak.svg +++ b/public/assets/games/main-street/svg/cards/evt-flu-outbreak.svg @@ -11,7 +11,7 @@ Flu Outbreak -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-good-press.svg b/public/assets/games/main-street/svg/cards/evt-good-press.svg index 53750031..3e975886 100644 --- a/public/assets/games/main-street/svg/cards/evt-good-press.svg +++ b/public/assets/games/main-street/svg/cards/evt-good-press.svg @@ -11,7 +11,7 @@ Good Press -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-grand-opening.svg b/public/assets/games/main-street/svg/cards/evt-grand-opening.svg index b1f00285..6a57e396 100644 --- a/public/assets/games/main-street/svg/cards/evt-grand-opening.svg +++ b/public/assets/games/main-street/svg/cards/evt-grand-opening.svg @@ -11,7 +11,7 @@ Grand Opening Sale -2 +3 diff --git a/public/assets/games/main-street/svg/cards/evt-harvest-festival.svg b/public/assets/games/main-street/svg/cards/evt-harvest-festival.svg index f484e20f..c7403bad 100644 --- a/public/assets/games/main-street/svg/cards/evt-harvest-festival.svg +++ b/public/assets/games/main-street/svg/cards/evt-harvest-festival.svg @@ -11,7 +11,7 @@ Harvest Festival -3 +9 diff --git a/public/assets/games/main-street/svg/cards/evt-health-campaign.svg b/public/assets/games/main-street/svg/cards/evt-health-campaign.svg index a96f419f..74925cee 100644 --- a/public/assets/games/main-street/svg/cards/evt-health-campaign.svg +++ b/public/assets/games/main-street/svg/cards/evt-health-campaign.svg @@ -11,7 +11,7 @@ Health Campaign -3 +5 diff --git a/public/assets/games/main-street/svg/cards/evt-heatwave.svg b/public/assets/games/main-street/svg/cards/evt-heatwave.svg index c14f2800..a10bfe77 100644 --- a/public/assets/games/main-street/svg/cards/evt-heatwave.svg +++ b/public/assets/games/main-street/svg/cards/evt-heatwave.svg @@ -11,7 +11,7 @@ Heatwave -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-pest-infestation.svg b/public/assets/games/main-street/svg/cards/evt-pest-infestation.svg index bff5b8b1..867d5ba5 100644 --- a/public/assets/games/main-street/svg/cards/evt-pest-infestation.svg +++ b/public/assets/games/main-street/svg/cards/evt-pest-infestation.svg @@ -11,7 +11,7 @@ Pest Infestation -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-power-surge.svg b/public/assets/games/main-street/svg/cards/evt-power-surge.svg index f00c1256..c697241e 100644 --- a/public/assets/games/main-street/svg/cards/evt-power-surge.svg +++ b/public/assets/games/main-street/svg/cards/evt-power-surge.svg @@ -11,7 +11,7 @@ Power Surge -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-protest.svg b/public/assets/games/main-street/svg/cards/evt-protest.svg index 8a8a27cb..c19a0c0b 100644 --- a/public/assets/games/main-street/svg/cards/evt-protest.svg +++ b/public/assets/games/main-street/svg/cards/evt-protest.svg @@ -11,7 +11,7 @@ Protest -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-slow-season.svg b/public/assets/games/main-street/svg/cards/evt-slow-season.svg index c6b3c700..e07c4981 100644 --- a/public/assets/games/main-street/svg/cards/evt-slow-season.svg +++ b/public/assets/games/main-street/svg/cards/evt-slow-season.svg @@ -11,7 +11,7 @@ Slow Season -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-street-performer.svg b/public/assets/games/main-street/svg/cards/evt-street-performer.svg index bd5584be..88f835ac 100644 --- a/public/assets/games/main-street/svg/cards/evt-street-performer.svg +++ b/public/assets/games/main-street/svg/cards/evt-street-performer.svg @@ -11,7 +11,7 @@ Street Performer -2 +5 diff --git a/public/assets/games/main-street/svg/cards/evt-strike.svg b/public/assets/games/main-street/svg/cards/evt-strike.svg index 6409413f..ceff8997 100644 --- a/public/assets/games/main-street/svg/cards/evt-strike.svg +++ b/public/assets/games/main-street/svg/cards/evt-strike.svg @@ -11,7 +11,7 @@ Strike -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-supply-chain.svg b/public/assets/games/main-street/svg/cards/evt-supply-chain.svg index 985f07c9..6375feb1 100644 --- a/public/assets/games/main-street/svg/cards/evt-supply-chain.svg +++ b/public/assets/games/main-street/svg/cards/evt-supply-chain.svg @@ -11,7 +11,7 @@ Supply Chain Delay -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-tourist-bus.svg b/public/assets/games/main-street/svg/cards/evt-tourist-bus.svg index ccbdc34e..c1e06864 100644 --- a/public/assets/games/main-street/svg/cards/evt-tourist-bus.svg +++ b/public/assets/games/main-street/svg/cards/evt-tourist-bus.svg @@ -11,7 +11,7 @@ Tourist Bus -0 +1 diff --git a/public/assets/games/main-street/svg/cards/evt-volunteer-day.svg b/public/assets/games/main-street/svg/cards/evt-volunteer-day.svg index 5969cc7d..65ff73e9 100644 --- a/public/assets/games/main-street/svg/cards/evt-volunteer-day.svg +++ b/public/assets/games/main-street/svg/cards/evt-volunteer-day.svg @@ -11,7 +11,7 @@ Volunteer Day -2 +14 diff --git a/public/assets/games/main-street/svg/cards/staff-director.svg b/public/assets/games/main-street/svg/cards/staff-director.svg index 8aed0ae2..72040e4c 100644 --- a/public/assets/games/main-street/svg/cards/staff-director.svg +++ b/public/assets/games/main-street/svg/cards/staff-director.svg @@ -11,7 +11,7 @@ Director -10 +14 diff --git a/public/assets/games/main-street/svg/cards/staff-manager.svg b/public/assets/games/main-street/svg/cards/staff-manager.svg index 8ad571c4..6c23aea8 100644 --- a/public/assets/games/main-street/svg/cards/staff-manager.svg +++ b/public/assets/games/main-street/svg/cards/staff-manager.svg @@ -11,7 +11,7 @@ Manager -6 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-bistro.svg b/public/assets/games/main-street/svg/cards/upg-bistro.svg index 905463eb..11856530 100644 --- a/public/assets/games/main-street/svg/cards/upg-bistro.svg +++ b/public/assets/games/main-street/svg/cards/upg-bistro.svg @@ -11,7 +11,7 @@ Upgrade to Bistro -4 +3 diff --git a/public/assets/games/main-street/svg/cards/upg-bread-factory.svg b/public/assets/games/main-street/svg/cards/upg-bread-factory.svg index 267919d3..411b952c 100644 --- a/public/assets/games/main-street/svg/cards/upg-bread-factory.svg +++ b/public/assets/games/main-street/svg/cards/upg-bread-factory.svg @@ -11,7 +11,7 @@ Upgrade to Bread Factory -3 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-community-hub.svg b/public/assets/games/main-street/svg/cards/upg-community-hub.svg index b4c4c4cb..a1f2b48e 100644 --- a/public/assets/games/main-street/svg/cards/upg-community-hub.svg +++ b/public/assets/games/main-street/svg/cards/upg-community-hub.svg @@ -11,7 +11,7 @@ Upgrade to Community Hub -4 +3 diff --git a/public/assets/games/main-street/svg/cards/upg-designer-store.svg b/public/assets/games/main-street/svg/cards/upg-designer-store.svg index 679b62f8..facb132b 100644 --- a/public/assets/games/main-street/svg/cards/upg-designer-store.svg +++ b/public/assets/games/main-street/svg/cards/upg-designer-store.svg @@ -11,7 +11,7 @@ Upgrade to Designer Store -4 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-drive-in.svg b/public/assets/games/main-street/svg/cards/upg-drive-in.svg index 96a1bb72..feed3fb4 100644 --- a/public/assets/games/main-street/svg/cards/upg-drive-in.svg +++ b/public/assets/games/main-street/svg/cards/upg-drive-in.svg @@ -11,7 +11,7 @@ Upgrade to Drive-In Theater -4 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-fast-food.svg b/public/assets/games/main-street/svg/cards/upg-fast-food.svg index 53882163..b15c34b4 100644 --- a/public/assets/games/main-street/svg/cards/upg-fast-food.svg +++ b/public/assets/games/main-street/svg/cards/upg-fast-food.svg @@ -11,7 +11,7 @@ Upgrade to Fast Food -3 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-gaming-lounge.svg b/public/assets/games/main-street/svg/cards/upg-gaming-lounge.svg index 4f51f1a5..9a8eb094 100644 --- a/public/assets/games/main-street/svg/cards/upg-gaming-lounge.svg +++ b/public/assets/games/main-street/svg/cards/upg-gaming-lounge.svg @@ -11,7 +11,7 @@ Upgrade to Gaming Lounge -4 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-garden-center.svg b/public/assets/games/main-street/svg/cards/upg-garden-center.svg index dac7945c..51900d99 100644 --- a/public/assets/games/main-street/svg/cards/upg-garden-center.svg +++ b/public/assets/games/main-street/svg/cards/upg-garden-center.svg @@ -11,7 +11,7 @@ Upgrade to Garden Center -3 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-gourmet-truck.svg b/public/assets/games/main-street/svg/cards/upg-gourmet-truck.svg index 621607db..052579db 100644 --- a/public/assets/games/main-street/svg/cards/upg-gourmet-truck.svg +++ b/public/assets/games/main-street/svg/cards/upg-gourmet-truck.svg @@ -11,7 +11,7 @@ Upgrade to Gourmet Truck -2 +3 diff --git a/public/assets/games/main-street/svg/cards/upg-grand-bakehouse.svg b/public/assets/games/main-street/svg/cards/upg-grand-bakehouse.svg index bb16b258..8fa0d1d7 100644 --- a/public/assets/games/main-street/svg/cards/upg-grand-bakehouse.svg +++ b/public/assets/games/main-street/svg/cards/upg-grand-bakehouse.svg @@ -11,7 +11,7 @@ Upgrade to Grand Bakehouse -5 +9 diff --git a/public/assets/games/main-street/svg/cards/upg-home-improvement.svg b/public/assets/games/main-street/svg/cards/upg-home-improvement.svg index 1b4d54de..08eca1ca 100644 --- a/public/assets/games/main-street/svg/cards/upg-home-improvement.svg +++ b/public/assets/games/main-street/svg/cards/upg-home-improvement.svg @@ -11,7 +11,7 @@ Upgrade to Home Improvement -4 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-imax.svg b/public/assets/games/main-street/svg/cards/upg-imax.svg index 0d599709..a8087123 100644 --- a/public/assets/games/main-street/svg/cards/upg-imax.svg +++ b/public/assets/games/main-street/svg/cards/upg-imax.svg @@ -11,7 +11,7 @@ Upgrade to IMAX Theater -5 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-luxury-retreat.svg b/public/assets/games/main-street/svg/cards/upg-luxury-retreat.svg index 8acaa558..d3222c7d 100644 --- a/public/assets/games/main-street/svg/cards/upg-luxury-retreat.svg +++ b/public/assets/games/main-street/svg/cards/upg-luxury-retreat.svg @@ -11,7 +11,7 @@ Upgrade to Luxury Retreat -6 +14 diff --git a/public/assets/games/main-street/svg/cards/upg-medical-center.svg b/public/assets/games/main-street/svg/cards/upg-medical-center.svg index 7b7af9c8..9d90f74b 100644 --- a/public/assets/games/main-street/svg/cards/upg-medical-center.svg +++ b/public/assets/games/main-street/svg/cards/upg-medical-center.svg @@ -11,7 +11,7 @@ Upgrade to Medical Center -5 +3 diff --git a/public/assets/games/main-street/svg/cards/upg-multiplex.svg b/public/assets/games/main-street/svg/cards/upg-multiplex.svg index d6b847f4..1e0e0c84 100644 --- a/public/assets/games/main-street/svg/cards/upg-multiplex.svg +++ b/public/assets/games/main-street/svg/cards/upg-multiplex.svg @@ -11,7 +11,7 @@ Upgrade to Multiplex -6 +14 diff --git a/public/assets/games/main-street/svg/cards/upg-museum.svg b/public/assets/games/main-street/svg/cards/upg-museum.svg index dc8ae443..b46e13b0 100644 --- a/public/assets/games/main-street/svg/cards/upg-museum.svg +++ b/public/assets/games/main-street/svg/cards/upg-museum.svg @@ -11,7 +11,7 @@ Upgrade to Museum -4 +7 diff --git a/public/assets/games/main-street/svg/cards/upg-patisserie.svg b/public/assets/games/main-street/svg/cards/upg-patisserie.svg index a9904df9..975930f9 100644 --- a/public/assets/games/main-street/svg/cards/upg-patisserie.svg +++ b/public/assets/games/main-street/svg/cards/upg-patisserie.svg @@ -11,7 +11,7 @@ Upgrade to Patisserie -4 +3 diff --git a/public/assets/games/main-street/svg/cards/upg-private-medical-center.svg b/public/assets/games/main-street/svg/cards/upg-private-medical-center.svg index 85fded14..eaaacfab 100644 --- a/public/assets/games/main-street/svg/cards/upg-private-medical-center.svg +++ b/public/assets/games/main-street/svg/cards/upg-private-medical-center.svg @@ -11,7 +11,7 @@ Upgrade to Private Medical Center -4 +9 diff --git a/public/assets/games/main-street/svg/cards/upg-resort-spa.svg b/public/assets/games/main-street/svg/cards/upg-resort-spa.svg index 7865e81c..81d30dbd 100644 --- a/public/assets/games/main-street/svg/cards/upg-resort-spa.svg +++ b/public/assets/games/main-street/svg/cards/upg-resort-spa.svg @@ -11,7 +11,7 @@ Upgrade to Resort Spa -5 +9 diff --git a/public/assets/games/main-street/svg/cards/upg-restaurant.svg b/public/assets/games/main-street/svg/cards/upg-restaurant.svg index 18432f97..04bac322 100644 --- a/public/assets/games/main-street/svg/cards/upg-restaurant.svg +++ b/public/assets/games/main-street/svg/cards/upg-restaurant.svg @@ -11,7 +11,7 @@ Upgrade to Restaurant -5 +14 diff --git a/public/assets/games/main-street/svg/cards/upg-roastery.svg b/public/assets/games/main-street/svg/cards/upg-roastery.svg index bd387c5a..7183f51d 100644 --- a/public/assets/games/main-street/svg/cards/upg-roastery.svg +++ b/public/assets/games/main-street/svg/cards/upg-roastery.svg @@ -11,7 +11,7 @@ Upgrade to Roastery -3 +9 diff --git a/public/assets/games/main-street/svg/cards/upg-salon.svg b/public/assets/games/main-street/svg/cards/upg-salon.svg index 6269a5c6..27f53586 100644 --- a/public/assets/games/main-street/svg/cards/upg-salon.svg +++ b/public/assets/games/main-street/svg/cards/upg-salon.svg @@ -11,7 +11,7 @@ Upgrade to Salon -3 +9 diff --git a/public/assets/games/main-street/svg/cards/upg-wellness-center.svg b/public/assets/games/main-street/svg/cards/upg-wellness-center.svg index d8516d93..2a19954a 100644 --- a/public/assets/games/main-street/svg/cards/upg-wellness-center.svg +++ b/public/assets/games/main-street/svg/cards/upg-wellness-center.svg @@ -11,7 +11,7 @@ Upgrade to Wellness Center -4 +9 diff --git a/public/assets/games/sushi-go/thumbnail.png b/public/assets/games/sushi-go/thumbnail.png index 7c3f371e..3916be44 100644 Binary files a/public/assets/games/sushi-go/thumbnail.png and b/public/assets/games/sushi-go/thumbnail.png differ diff --git a/scripts/generate-all-thumbnails.ts b/scripts/generate-all-thumbnails.ts new file mode 100644 index 00000000..b073461c --- /dev/null +++ b/scripts/generate-all-thumbnails.ts @@ -0,0 +1,137 @@ +#!/usr/bin/env npx tsx +/** + * generate-all-thumbnails.ts + * + * Runs the replay pipeline for every game with a transcript fixture, + * producing actual in-game screenshots as thumbnails. + * + * Usage: + * npx tsx scripts/generate-all-thumbnails.ts [--game ] + * + * Arguments: + * --game Optionally restrict to a single game type. + * + * Pipeline per game: + * 1. npm run replay -- --output data/screenshots/ + * 2. npx tsx scripts/generate-thumbnail.ts data/screenshots/ + * + * Related work item: CG-0MQK3CJUX0043I3U + */ + +import { execSync } from 'node:child_process'; +import { existsSync, readdirSync, statSync as stat } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, '..'); + +// ── Types ────────────────────────────────────────────────── + +interface GameInfo { + gameName: string; + transcriptPath: string; + screenshotsDir: string; +} + +// ── Helpers ──────────────────────────────────────────────── + +/** + * Discover all games with transcript fixtures and return GameInfo objects. + */ +function discoverGames(): GameInfo[] { + const transcriptsDir = join(ROOT, 'data', 'transcripts'); + const games: GameInfo[] = []; + + if (!existsSync(transcriptsDir)) { + console.error(`Error: transcripts directory not found: ${transcriptsDir}`); + process.exit(1); + } + + const entries = readdirSync(transcriptsDir); + for (const entry of entries) { + const gameDir = join(transcriptsDir, entry); + if (!existsSync(gameDir)) continue; + + const jsonFiles = readdirSync(gameDir).filter((f) => f.endsWith('.json')); + if (jsonFiles.length === 0) continue; + + // Use the most recently modified transcript + let newest = jsonFiles[0]; + let newestMtime = 0; + for (const f of jsonFiles) { + const mtime = stat(join(gameDir, f)).mtimeMs; + if (mtime > newestMtime) { + newest = f; + newestMtime = mtime; + } + } + + games.push({ + gameName: entry, + transcriptPath: join(gameDir, newest), + screenshotsDir: join(ROOT, 'data', 'screenshots', entry), + }); + } + + return games.sort((a, b) => a.gameName.localeCompare(b.gameName)); +} + +// ── Main ─────────────────────────────────────────────────── + +function main(): void { + const args = process.argv.slice(2); + const filterGame = args.find((a) => a === '--game') + ? args[args.indexOf('--game') + 1] + : undefined; + + const allGames = discoverGames(); + const games = filterGame + ? allGames.filter((g) => g.gameName === filterGame) + : allGames; + + if (games.length === 0) { + console.error(`No games found${filterGame ? ` matching '${filterGame}'` : ''}.`); + process.exit(1); + } + + console.log(`\n=== Generating thumbnails for ${games.length} game(s) ===\n`); + + let success = 0; + let failed = 0; + + for (const game of games) { + console.log(`────────────────────────────────────────`); + console.log(`Game: ${game.gameName}`); + console.log(`Transcript: ${game.transcriptPath}`); + + try { + // Step 1: Replay + console.log('▶ Step 1/2: Running replay...'); + execSync( + `npm run replay -- "${game.transcriptPath}" --output "${game.screenshotsDir}"`, + { cwd: ROOT, stdio: 'inherit', env: { ...process.env, CI: '1' } }, + ); + + // Step 2: Generate thumbnail + console.log('▶ Step 2/2: Generating thumbnail...'); + execSync( + `npx tsx scripts/generate-thumbnail.ts ${game.gameName} "${game.screenshotsDir}"`, + { cwd: ROOT, stdio: 'inherit' }, + ); + + console.log(`✅ ${game.gameName} done\n`); + success++; + } catch (err) { + console.error(`❌ ${game.gameName} failed\n`); + failed++; + } + } + + console.log(`\n=== Results: ${success} succeeded, ${failed} failed out of ${games.length} ===\n`); + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/scripts/generate-main-street-card-svgs.mjs b/scripts/generate-main-street-card-svgs.mjs index cb988760..4f4b7395 100644 --- a/scripts/generate-main-street-card-svgs.mjs +++ b/scripts/generate-main-street-card-svgs.mjs @@ -1,8 +1,26 @@ +#!/usr/bin/env node + +/** + * Main Street Card SVG Generator + * + * Generates static SVG card images from the card-data.csv file. + * Can be used as a standalone CLI tool or imported programmatically. + * + * CLI: node scripts/generate-main-street-card-svgs.mjs + * Import: import { regenerateCardSvgs } from './scripts/generate-main-street-card-svgs.mjs' + * + * Outputs: + * - SVG files for each card in public/assets/games/main-street/svg/cards/ + * - csv-checksum.json alongside the SVGs for runtime change detection + * + * @module + */ + import fs from 'fs'; import path from 'path'; // --------------------------------------------------------------------------- -// Read card data from CSV (source of truth — CG-0MR6ZR23J006ZDNZ) +// CSV parsing // --------------------------------------------------------------------------- function parseCsvLine(line) { @@ -38,33 +56,32 @@ function parseCsvLine(line) { return fields; } -const csvPath = path.resolve('example-games/main-street/card-data.csv'); -const csvText = fs.readFileSync(csvPath, 'utf8'); -const lines = csvText.trim().split('\n'); -const headers = parseCsvLine(lines[0]); - -const templates = []; -for (let i = 1; i < lines.length; i++) { - const values = parseCsvLine(lines[i]); - const card = {}; - for (let j = 0; j < headers.length; j++) { - card[headers[j]] = values[j] !== undefined ? values[j] : ''; - } - - // Determine family from prefix if not in CSV - const family = card.family || (card.id.startsWith('biz-') ? 'business' : card.id.startsWith('evt-') ? 'event' : card.id.startsWith('cs-') ? 'community-space' : card.id.startsWith('staff-') ? 'staff' : 'upgrade'); - const synergies = card.synergyTypes ? card.synergyTypes.split('|').filter(Boolean) : []; - const cost = card.cost ? Number(card.cost) : null; - const trigger = card.trigger || null; +// --------------------------------------------------------------------------- +// Checksum computation +// --------------------------------------------------------------------------- - templates.push({ id: card.id, name: card.name, cost, family, trigger, synergies }); +/** + * Compute a deterministic DJB2 hash of the CSV text, returned as hex. + * Matches the browser-side computeCsvChecksum() in CsvChecksum.ts. + * + * @param {string} csvText - Raw CSV text + * @returns {string} 8-character hex checksum + */ +export function computeCsvChecksum(csvText) { + let hash = 5381; + for (let i = 0; i < csvText.length; i++) { + hash = ((hash << 5) + hash + csvText.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(16).padStart(8, '0'); } // --------------------------------------------------------------------------- -// SVG generation (unchanged) +// SVG generation // --------------------------------------------------------------------------- -// Color map +/** + * Color map for synergy types. + */ const synergyColor = { Food: '#E67E22', Culture: '#3498DB', @@ -74,6 +91,9 @@ const synergyColor = { Health: '#1ABC9C', }; +/** + * Determine background color for a card family + trigger type. + */ function familyColor(family, trigger) { if (family === 'business') return '#2f2f2f'; if (family === 'upgrade') return '#6B4C9A'; @@ -83,51 +103,161 @@ function familyColor(family, trigger) { return '#333333'; } -const outDir = path.resolve('public/assets/games/main-street/svg/cards'); -if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); +// --------------------------------------------------------------------------- +// SVG icon inlining +// --------------------------------------------------------------------------- + +/** + * Inline a synergy icon SVG file into the card SVG. + * Falls back to a simple colored circle if the icon file doesn't exist. + * + * @param {string[]} synergies - List of synergy type names + * @param {string} accent - Accent color string (CSS) + * @param {number} h - Card height in pixels + * @param {string} iconsDir - Path to the icons directory + * @returns {string} SVG markup for the icon + */ +function buildIconMarkup(synergies, accent, h, iconsDir) { + if (!synergies || synergies.length === 0) return ''; + + const key = synergies[0]; + const iconFile = `ms-icon-${String(key).toLowerCase()}.svg`; + const iconPath = path.join(iconsDir, iconFile); + if (fs.existsSync(iconPath)) { + let iconSvg = fs.readFileSync(iconPath, 'utf8'); + iconSvg = iconSvg.replace(/<\?xml[^>]*\?>\s*/i, ''); + iconSvg = iconSvg.replace(/^\s*]*>/i, '').replace(/<\/svg>\s*$/i, ''); + return ` \n ${iconSvg}\n \n `; + } + + // fallback dot + return `