diff --git a/.gitignore b/.gitignore index f7e840b..4a4c1db 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,8 @@ test-results/ ################################## # Runtime Data (transcripts, screenshots, etc.) ################################## +# Temporary runtime artifacts used by tests and local runs - do not commit +tmp/ data/ results/ diff --git a/README.md b/README.md index bfd3b01..f7c3ed8 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ tableau-card-engine/ | Sushi Go! | `example-games/sushi-go/` | Card drafting game (human vs. AI). Pick and pass hands over 3 rounds, collect sets of sushi dishes, and score the most points | | Feudalism | `example-games/feudalism/` | Engine-building card game (human vs. AI). Collect gem tokens, purchase development cards for bonuses, attract nobles, and reach 15 prestige to win | | Lost Cities | `example-games/lost-cities/` | Two-player expedition card game (human vs. AI). Bet on up to 5 colored expeditions across a 3-round match with investment multipliers, ascending-play rules, and cumulative scoring | +| Main Street | `example-games/main-street/` | Single-player tableau builder. Buy businesses/upgrades/events, place businesses on a 10-slot street rendered as a responsive 2x5 grid, and optimize score over 20 turns | -More games are planned: The Mind and Coloretto. +More games are planned: Coloretto. ## Contributing diff --git a/docs/main-street/card-dimensions.md b/docs/main-street/card-dimensions.md new file mode 100644 index 0000000..24e68cb --- /dev/null +++ b/docs/main-street/card-dimensions.md @@ -0,0 +1,67 @@ +# Card Dimensions and Rendering Guidelines (Main Street) + +## Canonical source art + +- Orientation: portrait +- Canonical pixel size (in-repo source SVGs): 140 × 190 px + +Rationale: 140×190 is already used across generators and is a good balance between detail and CI size. SVG source art preserves vector fidelity and can be rasterised to derived thumbnails programmatically. + +## Rendering rules (aspect-preserving) + +All runtime rendering MUST preserve the canonical aspect ratio (portrait 140:190) unless an explicit designer decision is made and documented. + +General rule: use "fit-inside" scaling (preserve aspect ratio and ensure the whole card is visible). Only use "fill-and-crop" when a tight visual crop is required by a layout (e.g., decorative hero images). Prefer letterboxing/pillarboxing over stretching. + +UI slot mappings (recommendations) + +- Market card slot (example-games/main-street): visual slot nominal size = 140 × 80 px (landscape). + - Rendering: scale canonical art to fit inside the slot width (max width = 140) while preserving aspect ratio. Center vertically; allow top/bottom letterbox space. + - Phaser: add Image with displayWidth = slotWidth, set displayHeight = (displayWidth * canonicalH / canonicalW) and position centered in slot. + +- Street slot (10-slot grid shown as 2 rows × 5 columns): nominal slot size = 96 × 100 px (desktop reference), scaled down on narrower viewports. + - Rendering: fit-inside within slot bounds, center in slot. Do NOT stretch; vertical/horizontal centering is acceptable. If a tighter crop is desired for visual density, consider providing a derived thumbnail (see "Derived thumbnails" below) and document exception. + +- Incident queue and Investment/held-event thumbnails: nominal sizes vary; recommended approach is to scale to fit inside the slot and use a fixed padding (2–6 px) so glyphs and text do not clip. + +- Hand / small runtime cards (UI helpers and layout constants): use shared runtime constants in `src/ui/constants.ts` (CARD_W, CARD_H). Derive display sizes by computing scale = CARD_W / canonicalWidth and applying the same scale to height. + +- Game Selector thumbnails (120×68 px): render a composed scene at double or triple canonical scale and downscale to the thumbnail size. Maintain legibility by ensuring the card art occupies >= 40% of the thumbnail's dominant area. + +## Derived thumbnails and export guidance + +- Prefer generating thumbnails at runtime by rasterising SVGs into Phaser textures at the target display size. This keeps the repo free of large raster assets and allows dynamic scaling for different DPIs. +- If exporting raster thumbnails (for web or CDN delivery), produce them from the canonical SVG using a vector renderer at the desired pixel width and preserve aspect ratio. + - Example: `rsvg-convert -w 140 -h 190 placeholder-card.svg -o placeholder-card-140x190.png` (or use the project's Node/TS generator scripts) + +- Recommended derived sizes (suggested presets): + - Full card (portrait reference): 140×190 (canonical) + - Market slot thumbnail: fit to width 140 (height auto) — displayed in 140×80 slot + - Street small thumbnail: fit to 105×110 + - UI small (compact hand): CARD_W × CARD_H (48×65 default runtime) + - Selector thumbnail: 120×68 (scene screenshot) + +## Layout notes (Main Street) + +- Main Street presents the street as a responsive 2×5 grid to preserve readability and avoid overlap with market, incident queue, hand, action controls, and instruction text across desktop and narrow/tall viewports. +- Bottom-right action controls are compact by design to preserve vertical space for the lower hand/challenge area. + +## Migration notes + +- Existing scene loaders should switch to loading canonical SVGs where possible and compute display sizes using fit-inside math to avoid distortion. +- Avoid committing rasterized card art into the repo; prefer SVGs plus small generated thumbnails only where necessary. + +## Examples (Phaser pseudocode) + +``` +// load SVG once in preload +this.load.svg('ms_placeholder_card', 'assets/games/main-street/svg/placeholder-card.svg'); + +// when creating a market slot image +const img = this.add.image(slotX, slotY, 'ms_placeholder_card'); +img.displayWidth = SLOT_WIDTH; // e.g. 140 +img.displayHeight = (SLOT_WIDTH * 190) / 140; // maintain aspect +img.setOrigin(0.5, 0.5); +``` + +Keep this document in sync with `docs/main-street/prd-milestone-*` and `public/assets/CREDITS.md` when canonical dimensions change. \ No newline at end of file diff --git a/docs/main-street/screenshots/after-challenge-move.png b/docs/main-street/screenshots/after-challenge-move.png new file mode 100644 index 0000000..26d11bc Binary files /dev/null and b/docs/main-street/screenshots/after-challenge-move.png differ diff --git a/docs/main-street/screenshots/current.png b/docs/main-street/screenshots/current.png new file mode 100644 index 0000000..3d16b08 Binary files /dev/null and b/docs/main-street/screenshots/current.png differ diff --git a/docs/main-street/screenshots/final-fixed.png b/docs/main-street/screenshots/final-fixed.png new file mode 100644 index 0000000..3abcfe5 Binary files /dev/null and b/docs/main-street/screenshots/final-fixed.png differ diff --git a/docs/main-street/screenshots/final-test.png b/docs/main-street/screenshots/final-test.png new file mode 100644 index 0000000..2be77ac Binary files /dev/null and b/docs/main-street/screenshots/final-test.png differ diff --git a/docs/main-street/screenshots/latest.png b/docs/main-street/screenshots/latest.png new file mode 100644 index 0000000..ea862b0 Binary files /dev/null and b/docs/main-street/screenshots/latest.png differ diff --git a/docs/main-street/screenshots/layout-fixes.png b/docs/main-street/screenshots/layout-fixes.png new file mode 100644 index 0000000..804d030 Binary files /dev/null and b/docs/main-street/screenshots/layout-fixes.png differ diff --git a/docs/main-street/screenshots/main-street-base.png b/docs/main-street/screenshots/main-street-base.png new file mode 100644 index 0000000..2392041 Binary files /dev/null and b/docs/main-street/screenshots/main-street-base.png differ diff --git a/docs/main-street/screenshots/main-street-current.png b/docs/main-street/screenshots/main-street-current.png new file mode 100644 index 0000000..713fa7a Binary files /dev/null and b/docs/main-street/screenshots/main-street-current.png differ diff --git a/docs/main-street/screenshots/main-street-desktop-final.png b/docs/main-street/screenshots/main-street-desktop-final.png new file mode 100644 index 0000000..d0b1b2f Binary files /dev/null and b/docs/main-street/screenshots/main-street-desktop-final.png differ diff --git a/docs/main-street/screenshots/main-street-desktop-old.png b/docs/main-street/screenshots/main-street-desktop-old.png new file mode 100644 index 0000000..db688ad Binary files /dev/null and b/docs/main-street/screenshots/main-street-desktop-old.png differ diff --git a/docs/main-street/screenshots/main-street-desktop-v3.png b/docs/main-street/screenshots/main-street-desktop-v3.png new file mode 100644 index 0000000..716042a Binary files /dev/null and b/docs/main-street/screenshots/main-street-desktop-v3.png differ diff --git a/docs/main-street/screenshots/main-street-desktop-v5.png b/docs/main-street/screenshots/main-street-desktop-v5.png new file mode 100644 index 0000000..ac9a313 Binary files /dev/null and b/docs/main-street/screenshots/main-street-desktop-v5.png differ diff --git a/docs/main-street/screenshots/main-street-desktop.png b/docs/main-street/screenshots/main-street-desktop.png new file mode 100644 index 0000000..90aee3e Binary files /dev/null and b/docs/main-street/screenshots/main-street-desktop.png differ diff --git a/docs/main-street/screenshots/main-street-narrow.png b/docs/main-street/screenshots/main-street-narrow.png new file mode 100644 index 0000000..3550d1b Binary files /dev/null and b/docs/main-street/screenshots/main-street-narrow.png differ diff --git a/docs/main-street/screenshots/main-street-onechange.png b/docs/main-street/screenshots/main-street-onechange.png new file mode 100644 index 0000000..10b0b1e Binary files /dev/null and b/docs/main-street/screenshots/main-street-onechange.png differ diff --git a/docs/main-street/screenshots/main-street-step1.png b/docs/main-street/screenshots/main-street-step1.png new file mode 100644 index 0000000..b5c5595 Binary files /dev/null and b/docs/main-street/screenshots/main-street-step1.png differ diff --git a/docs/main-street/screenshots/main-street-step2.png b/docs/main-street/screenshots/main-street-step2.png new file mode 100644 index 0000000..59ea521 Binary files /dev/null and b/docs/main-street/screenshots/main-street-step2.png differ diff --git a/docs/main-street/screenshots/new-layout.png b/docs/main-street/screenshots/new-layout.png new file mode 100644 index 0000000..d9c0d74 Binary files /dev/null and b/docs/main-street/screenshots/new-layout.png differ diff --git a/docs/main-street/screenshots/step1-log.png b/docs/main-street/screenshots/step1-log.png new file mode 100644 index 0000000..d21ce8d Binary files /dev/null and b/docs/main-street/screenshots/step1-log.png differ diff --git a/docs/main-street/screenshots/step2-challenges.png b/docs/main-street/screenshots/step2-challenges.png new file mode 100644 index 0000000..ee54927 Binary files /dev/null and b/docs/main-street/screenshots/step2-challenges.png differ diff --git a/docs/main-street/screenshots/step3-rightalign.png b/docs/main-street/screenshots/step3-rightalign.png new file mode 100644 index 0000000..08bb4b8 Binary files /dev/null and b/docs/main-street/screenshots/step3-rightalign.png differ diff --git a/docs/main-street/screenshots/step4-hand.png b/docs/main-street/screenshots/step4-hand.png new file mode 100644 index 0000000..c96e257 Binary files /dev/null and b/docs/main-street/screenshots/step4-hand.png differ diff --git a/docs/main-street/screenshots/test-fixed.png b/docs/main-street/screenshots/test-fixed.png new file mode 100644 index 0000000..6a374fa Binary files /dev/null and b/docs/main-street/screenshots/test-fixed.png differ diff --git a/docs/main-street/screenshots/test.png b/docs/main-street/screenshots/test.png new file mode 100644 index 0000000..e56ec65 Binary files /dev/null and b/docs/main-street/screenshots/test.png differ diff --git a/docs/main-street/screenshots/test1.png b/docs/main-street/screenshots/test1.png new file mode 100644 index 0000000..1398110 Binary files /dev/null and b/docs/main-street/screenshots/test1.png differ diff --git a/example-games/main-street/scenes/MainStreetScene.ts b/example-games/main-street/scenes/MainStreetScene.ts index 7ff8615..33b1f9e 100644 --- a/example-games/main-street/scenes/MainStreetScene.ts +++ b/example-games/main-street/scenes/MainStreetScene.ts @@ -77,60 +77,33 @@ import { MainStreetTranscriptRecorder, setMainStreetRecorder, recordMainStreetEv const BG_COLOR = '#2a1f14'; // ── Layout regions ────────────────────────────────────────── -// Canvas is 1280 x 720. -// -// Top bar (y 0-40): header (title + menu button) -// HUD band (y 44-78): Turn/phase, coins, reputation, score -// Street (y 90-220): 10 grid slots, horizontally centered -// Market (y 240-440): 2 rows (business, investments) -// Incident queue (y 450-530): face-up upcoming incidents -// Bottom bar (y 550-710): player hand (left), actions (right) - -const HUD_Y = 50; - -// Street grid -const STREET_TOP = 100; -const SLOT_W = 105; -const SLOT_H = 110; -const SLOT_GAP = 10; -const STREET_TOTAL_W = GRID_SIZE * SLOT_W + (GRID_SIZE - 1) * SLOT_GAP; -const STREET_X = (GAME_W - STREET_TOTAL_W) / 2; - -// Market -const MARKET_TOP = 240; -const MARKET_ROW_H = 90; -const MARKET_ROW_GAP = 10; -const MARKET_CARD_W = 140; -const MARKET_CARD_H = 80; -const MARKET_CARD_GAP = 12; -const MARKET_LABEL_W = 90; - -// Incident queue (below market) -const QUEUE_TOP = 455; -const QUEUE_CARD_W = 140; -const QUEUE_CARD_H = 70; -const QUEUE_CARD_GAP = 12; -const QUEUE_LABEL_W = MARKET_LABEL_W; - -// Player hand (bottom-left) -const HAND_Y = 570; -const HAND_CARD_W = 150; -const HAND_CARD_H = 90; - -// Action area (right-aligned) -const INSTRUCTION_Y = 580; -const ACTION_Y = 640; // Section box styling const BOX_STROKE = 0x665544; const BOX_FILL = 0x2a1f14; const BOX_RADIUS = 6; +// Base metrics are tuned for 1280x720 and scaled at runtime for narrower/taller viewports. +const BASE_HUD_Y = 50; +const BASE_MARKET_CARD_W = 140; +const BASE_MARKET_CARD_H = 80; +const BASE_MARKET_ROW_GAP = 10; +const BASE_MARKET_CARD_GAP = 12; +const BASE_MARKET_LABEL_W = 90; +// Incident queue uses same card size as market for consistency +const BASE_QUEUE_CARD_W = BASE_MARKET_CARD_W; +const BASE_QUEUE_CARD_H = BASE_MARKET_CARD_H; +const BASE_QUEUE_CARD_GAP = 10; +const BASE_SLOT_W = 96; +const BASE_SLOT_H = 100; +const BASE_SLOT_GAP = 10; +const STREET_COLS = 5; +const STREET_ROWS = 2; +const STREET_ROW_GAP = 12; +const BASE_HAND_CARD_W = 150; +const BASE_HAND_CARD_H = 90; + // Activity Log panel layout -const LOG_X = 810; -const LOG_Y = 240; -const LOG_W = 440; -const LOG_H = 290; const LOG_TITLE_H = 22; const LOG_PAD = 8; const LOG_FONT_SIZE = 13; @@ -145,14 +118,52 @@ const LOG_COLORS: Record = { 'turn-header': '#ffdd44', }; -// Challenge Tracker panel layout (bottom section, between hand and actions) -const CHALLENGE_X = 230; -const CHALLENGE_Y = 550; -const CHALLENGE_W = 560; +// Challenge Tracker panel layout const CHALLENGE_LINE_H = 20; const CHALLENGE_PAD = 6; const CHALLENGE_TITLE_H = 20; +interface SceneLayout { + gameW: number; + gameH: number; + hudY: number; + marketTop: number; + marketRowH: number; + marketRowGap: number; + marketCardW: number; + marketCardH: number; + marketCardGap: number; + marketLabelW: number; + queueTop: number; + queueCardW: number; + queueCardH: number; + queueCardGap: number; + queueLabelW: number; + streetTop: number; + slotW: number; + slotH: number; + slotGap: number; + streetX: number; + streetRowGap: number; + streetCols: number; + handY: number; + handX: number; + handCardW: number; + handCardH: number; + instructionY: number; + actionY: number; + actionButtonH: number; + actionButtonW: number; + hintButtonW: number; + smallButtonW: number; + challengeX: number; + challengeY: number; + challengeW: number; + logX: number; + logY: number; + logW: number; + logH: number; +} // ── UI Phase (scene-level interaction state) ──────────────── type UIPhase = @@ -179,6 +190,9 @@ export class MainStreetScene extends CardGameScene { // Pending selection for placing a business private pendingBusinessCard: BusinessCard | null = null; + // Computed responsive layout metrics + private layout!: SceneLayout; + // Display containers private hudContainer!: Phaser.GameObjects.Container; private streetContainer!: Phaser.GameObjects.Container; @@ -222,6 +236,23 @@ export class MainStreetScene extends CardGameScene { super({ key: 'MainStreetScene' }); } + // Preload placeholder SVG used for visual scale testing in the market + preload(): void { + // Canonical card size (140x190) — loader will keep vector fidelity and + // we scale when rendering into market slots. + try { + this.load.svg('ms_placeholder_card', 'assets/games/main-street/svg/placeholder-card.svg', { + width: 140, + height: 190, + }); + } catch (e) { + // If svg loader is unavailable in the current environment, ignore + // the error — tests can still validate the file on disk. + // eslint-disable-next-line no-console + console.debug('[MS] preload: svg load failed', e); + } + } + // ── Create ────────────────────────────────────────────── create(): void { @@ -268,10 +299,14 @@ export class MainStreetScene extends CardGameScene { } // UI scaffolding + this.layout = this.computeLayout(); this.createHeader(); this.createContainers(); this.createInstructions(); + this.scale.off(Phaser.Scale.Events.RESIZE, this.handleResize, this); + this.scale.on(Phaser.Scale.Events.RESIZE, this.handleResize, this); + // Help panel const helpSections: HelpSection[] = [ { @@ -338,6 +373,95 @@ export class MainStreetScene extends CardGameScene { createSceneTitle(this, 'Main Street'); } + private computeLayout(): SceneLayout { + const gameW = Math.max(720, Math.floor(this.scale.width || GAME_W)); + const gameH = Math.max(640, Math.floor(this.scale.height || GAME_H)); + const compact = gameW < 1100; + + const margin = compact ? 16 : 20; + const marketCardW = compact ? 126 : BASE_MARKET_CARD_W; + const marketCardH = compact ? 72 : BASE_MARKET_CARD_H; + const marketLabelW = compact ? 80 : BASE_MARKET_LABEL_W; + const marketRowGap = BASE_MARKET_ROW_GAP; + const marketRowH = marketCardH + 14; + const marketTop = 90; + + const queueCardW = compact ? 126 : BASE_QUEUE_CARD_W; + const queueCardH = compact ? 72 : BASE_QUEUE_CARD_H; + const queueCardGap = compact ? 10 : BASE_QUEUE_CARD_GAP; + const queueTop = marketTop + (2 * marketRowH + marketRowGap + 20) + 12; + + const slotGap = compact ? 8 : BASE_SLOT_GAP; + const slotW = compact ? 88 : BASE_SLOT_W; + const slotH = compact ? 92 : BASE_SLOT_H; + const streetTotalW = STREET_COLS * slotW + (STREET_COLS - 1) * slotGap; + const streetX = (gameW - streetTotalW) / 2; + const streetTop = queueTop + queueCardH + 22; + + const handCardW = compact ? 132 : BASE_HAND_CARD_W; + const handCardH = compact ? 78 : BASE_HAND_CARD_H; + const handY = gameH - margin - handCardH; + const handX = gameW - margin - handCardW / 2; + const instructionY = handY - 20; + + const actionButtonH = compact ? 32 : 34; + const actionY = gameH - 16 - actionButtonH; + + // Challenge tracker: position between hand and action buttons + const logW = compact ? 360 : 430; + const logX = gameW - margin - logW - 10; // left edge just left of right margin + // Challenge to the left of the log - expand to fill space + const challengeW = Math.min(350, logX - handCardW - margin - 20); + const challengeX = logX - challengeW - 10; + const challengeY = queueTop; // align with incidents top + const logY = marketTop - 10; // align top with market + // bottom aligns with market bottom border + const logH = Math.max(100, (queueTop + queueCardH + 20) - logY); + const logVisible = compact || logY < gameH - 140; + + return { + gameW, + gameH, + hudY: BASE_HUD_Y, + marketTop, + marketRowH, + marketRowGap, + marketCardW, + marketCardH, + marketCardGap: BASE_MARKET_CARD_GAP, + marketLabelW, + queueTop, + queueCardW, + queueCardH, + queueCardGap, + queueLabelW: marketLabelW, + streetTop, + slotW, + slotH, + slotGap, + streetX, + streetRowGap: STREET_ROW_GAP, + streetCols: STREET_COLS, + handY: handY, + handX, + handCardW, + handCardH, + instructionY, + actionY, + actionButtonH, + actionButtonW: compact ? 132 : 140, + hintButtonW: compact ? 98 : 104, + smallButtonW: compact ? 64 : 68, + challengeX, + challengeY, + challengeW, + logX: logVisible ? logX : -1000, + logY: logVisible ? logY : 0, + logW: logVisible ? logW : 0, + logH, + }; + } + private createContainers(): void { this.hudContainer = this.add.container(0, 0); this.streetContainer = this.add.container(0, 0); @@ -347,26 +471,26 @@ export class MainStreetScene extends CardGameScene { this.actionContainer = this.add.container(0, 0); // Challenge Tracker panel - this.challengeContainer = this.add.container(CHALLENGE_X, CHALLENGE_Y); + this.challengeContainer = this.add.container(this.layout.challengeX, this.layout.challengeY); // Activity Log panel (persistent, not rebuilt each refresh) - this.logContainer = this.add.container(LOG_X, LOG_Y); + this.logContainer = this.add.container(this.layout.logX, this.layout.logY); // Panel background const bg = this.add.graphics(); bg.fillStyle(0x1a1408, 0.85); - bg.fillRoundedRect(0, 0, LOG_W, LOG_H, 4); + bg.fillRoundedRect(0, 0, this.layout.logW, this.layout.logH, 4); bg.lineStyle(1, BOX_STROKE, 0.5); - bg.strokeRoundedRect(0, 0, LOG_W, LOG_H, 4); + bg.strokeRoundedRect(0, 0, this.layout.logW, this.layout.logH, 4); this.logContainer.add(bg); // Title bar const titleBg = this.add.graphics(); titleBg.fillStyle(0x332816, 0.9); - titleBg.fillRoundedRect(0, 0, LOG_W, LOG_TITLE_H, { tl: 4, tr: 4, bl: 0, br: 0 }); + titleBg.fillRoundedRect(0, 0, this.layout.logW, LOG_TITLE_H, { tl: 4, tr: 4, bl: 0, br: 0 }); this.logContainer.add(titleBg); - const titleText = this.add.text(LOG_W / 2, LOG_TITLE_H / 2, 'Activity Log', { + const titleText = this.add.text(this.layout.logW / 2, LOG_TITLE_H / 2, 'Activity Log', { fontSize: '12px', fontStyle: 'bold', color: '#aa9977', fontFamily: FONT_FAMILY, }).setOrigin(0.5); this.logContainer.add(titleText); @@ -388,13 +512,22 @@ export class MainStreetScene extends CardGameScene { } private createInstructions(): void { + // Centered at bottom this.instructionText = this.add - .text(GAME_W - 40, INSTRUCTION_Y, '', { - fontSize: '16px', + .text(this.layout.gameW / 2, this.layout.gameH - 20, '', { + fontSize: '14px', color: '#ccaa77', fontFamily: FONT_FAMILY, }) - .setOrigin(1, 0.5); + .setOrigin(0.5, 1); + } + + private handleResize(): void { + this.layout = this.computeLayout(); + this.challengeContainer.setPosition(this.layout.challengeX, this.layout.challengeY); + this.logContainer.setPosition(this.layout.logX, this.layout.logY); + this.instructionText.setPosition(this.layout.gameW - 24, this.layout.instructionY); + this.refreshAll(); } // ── Campaign / Meta-Progression ───────────────────────── @@ -546,46 +679,31 @@ export class MainStreetScene extends CardGameScene { const score = computeScore(this.state); const { coins, reputation } = this.state.resourceBank; + const { gameW, hudY } = this.layout; - // Background strip - const strip = this.add.rectangle(GAME_W / 2, HUD_Y, GAME_W - 40, 28, 0x1a1408, 0.6); + // Background strip - 2/3 width, centered + const strip = this.add.rectangle(gameW / 2, hudY, gameW * 0.66, 28, 0x1a1408, 0.6); strip.setStrokeStyle(1, BOX_STROKE, 0.5); this.hudContainer.add(strip); - // Turn - const turnText = this.add.text(40, HUD_Y, `Turn ${this.state.turn}/${this.state.config.maxTurns}`, { - fontSize: '16px', fontStyle: 'bold', color: '#ffdd88', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); - this.hudContainer.add(turnText); - - // Phase - const phaseText = this.add.text(200, HUD_Y, `Phase: ${this.state.phase}`, { - fontSize: '14px', color: '#aa9977', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); - this.hudContainer.add(phaseText); - - // Difficulty - const diffText = this.add.text(420, HUD_Y, `[${this.state.config.difficultyName}]`, { - fontSize: '13px', color: '#999977', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); - this.hudContainer.add(diffText); - - // Coins - const coinText = this.add.text(GAME_W / 2 - 100, HUD_Y, `Coins: ${coins}`, { + // Coins - centered in strip + const stripWidth = gameW * 0.66; + const stripLeft = (gameW - stripWidth) / 2; + const coinText = this.add.text(stripLeft + stripWidth * 0.25, hudY, `Coins: ${coins}`, { fontSize: '16px', fontStyle: 'bold', color: '#ffcc44', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5); this.hudContainer.add(coinText); - // Reputation - const repText = this.add.text(GAME_W / 2 + 50, HUD_Y, `Rep: ${reputation}`, { + // Reputation - centered in strip + const repText = this.add.text(stripLeft + stripWidth * 0.5, hudY, `Rep: ${reputation}`, { fontSize: '16px', fontStyle: 'bold', color: '#88bbff', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5); this.hudContainer.add(repText); - // Score - const scoreText = this.add.text(GAME_W - 40, HUD_Y, `Score: ${score}`, { + // Score - right side of strip + const scoreText = this.add.text(stripLeft + stripWidth * 0.85, hudY, `Score: ${score}`, { fontSize: '16px', fontStyle: 'bold', color: '#ff8844', fontFamily: FONT_FAMILY, - }).setOrigin(1, 0.5); + }).setOrigin(0, 0.5); this.hudContainer.add(scoreText); } @@ -599,24 +717,25 @@ export class MainStreetScene extends CardGameScene { // Dynamic height based on number of challenges const panelH = CHALLENGE_TITLE_H + challenges.length * CHALLENGE_LINE_H + CHALLENGE_PAD * 2; + const challengeW = this.layout.challengeW; // Panel background const bg = this.add.graphics(); bg.fillStyle(0x1a1408, 0.85); - bg.fillRoundedRect(0, 0, CHALLENGE_W, panelH, 4); + bg.fillRoundedRect(0, 0, challengeW, panelH, 4); bg.lineStyle(1, BOX_STROKE, 0.5); - bg.strokeRoundedRect(0, 0, CHALLENGE_W, panelH, 4); + bg.strokeRoundedRect(0, 0, challengeW, panelH, 4); this.challengeContainer.add(bg); // Title bar const titleBg = this.add.graphics(); titleBg.fillStyle(0x332816, 0.9); - titleBg.fillRoundedRect(0, 0, CHALLENGE_W, CHALLENGE_TITLE_H, { tl: 4, tr: 4, bl: 0, br: 0 }); + titleBg.fillRoundedRect(0, 0, challengeW, CHALLENGE_TITLE_H, { tl: 4, tr: 4, bl: 0, br: 0 }); this.challengeContainer.add(titleBg); const completedCount = challenges.filter(ac => ac.completed).length; const titleText = this.add.text( - CHALLENGE_W / 2, CHALLENGE_TITLE_H / 2, + challengeW / 2, CHALLENGE_TITLE_H / 2, `Challenges (${completedCount}/${challenges.length})`, { fontSize: '11px', fontStyle: 'bold', color: '#aa9977', fontFamily: FONT_FAMILY }, ).setOrigin(0.5); @@ -651,13 +770,13 @@ export class MainStreetScene extends CardGameScene { // Description (right portion of the row) const descText = this.add.text( - CHALLENGE_W * 0.42, yOff, + challengeW * 0.42, yOff, ac.challenge.description, { fontSize: '10px', color: isComplete ? '#558855' : '#998877', fontFamily: FONT_FAMILY, - wordWrap: { width: CHALLENGE_W * 0.56 }, + wordWrap: { width: challengeW * 0.56 }, }, ).setOrigin(0, 0); this.challengeContainer.add(descText); @@ -671,15 +790,19 @@ export class MainStreetScene extends CardGameScene { private refreshStreetGrid(): void { this.streetContainer.removeAll(true); + const { gameW, streetTop, streetX, slotW, slotGap, slotH, streetCols, streetRowGap } = this.layout; + // Section label - const label = this.add.text(GAME_W / 2, STREET_TOP - 16, 'Your Street', { + const label = this.add.text(gameW / 2, streetTop - 16, '', { fontSize: '14px', fontStyle: 'bold', color: '#aa9966', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 1); this.streetContainer.add(label); for (let i = 0; i < GRID_SIZE; i++) { - const x = STREET_X + i * (SLOT_W + SLOT_GAP); - const y = STREET_TOP; + const col = i % streetCols; + const row = Math.floor(i / streetCols); + const x = streetX + col * (slotW + slotGap); + const y = streetTop + row * (slotH + streetRowGap); const biz = this.state.streetGrid[i]; if (biz) { @@ -691,36 +814,37 @@ export class MainStreetScene extends CardGameScene { } private drawBusinessSlot(x: number, y: number, _index: number, biz: BusinessCard): void { + const { slotW, slotH } = this.layout; const primaryColor = synergyColor(biz.synergyTypes[0]); const isHinted = this.hintedSlotIndex === _index; // Card background const bg = this.add.rectangle( - x + SLOT_W / 2, y + SLOT_H / 2, - SLOT_W, SLOT_H, primaryColor, 0.7, + x + slotW / 2, y + slotH / 2, + slotW, slotH, primaryColor, 0.7, ); // Highlight the slot if it is the hint target (e.g., upgrade target) bg.setStrokeStyle(isHinted ? 3 : 2, isHinted ? 0x44ffff : 0xffffff, isHinted ? 1.0 : 0.4); this.streetContainer.add(bg); // Name - const nameText = this.add.text(x + SLOT_W / 2, y + 12, biz.name, { + const nameText = this.add.text(x + slotW / 2, y + 12, biz.name, { fontSize: '12px', fontStyle: 'bold', color: '#ffffff', fontFamily: FONT_FAMILY, - wordWrap: { width: SLOT_W - 8 }, + wordWrap: { width: slotW - 8 }, align: 'center', }).setOrigin(0.5, 0); this.streetContainer.add(nameText); // Income const income = biz.baseIncome + biz.incomeBonus; - const incText = this.add.text(x + SLOT_W / 2, y + SLOT_H - 30, `+${income}/turn`, { + const incText = this.add.text(x + slotW / 2, y + slotH - 30, `+${income}/turn`, { fontSize: '13px', color: '#ffee88', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 0); this.streetContainer.add(incText); // Level if (biz.level > 0) { - const lvlText = this.add.text(x + SLOT_W - 6, y + 4, `Lv${biz.level}`, { + const lvlText = this.add.text(x + slotW - 6, y + 4, `Lv${biz.level}`, { fontSize: '11px', color: '#ffdd44', fontFamily: FONT_FAMILY, }).setOrigin(1, 0); this.streetContainer.add(lvlText); @@ -728,7 +852,7 @@ export class MainStreetScene extends CardGameScene { // Synergy label at bottom const synLabel = biz.synergyTypes.join('/'); - const synText = this.add.text(x + SLOT_W / 2, y + SLOT_H - 12, synLabel, { + const synText = this.add.text(x + slotW / 2, y + slotH - 12, synLabel, { fontSize: '10px', color: '#dddddd', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 1); this.streetContainer.add(synText); @@ -741,6 +865,7 @@ export class MainStreetScene extends CardGameScene { } private drawEmptySlot(x: number, y: number, index: number): void { + const { slotW, slotH } = this.layout; const isSelectable = this.uiPhase === 'placing-business'; const isHinted = this.hintedSlotIndex === index && !isSelectable; const fillAlpha = isSelectable ? 0.4 : isHinted ? 0.35 : 0.2; @@ -748,14 +873,14 @@ export class MainStreetScene extends CardGameScene { const strokeWidth = (isSelectable || isHinted) ? 2 : 1; const bg = this.add.rectangle( - x + SLOT_W / 2, y + SLOT_H / 2, - SLOT_W, SLOT_H, 0x333322, fillAlpha, + x + slotW / 2, y + slotH / 2, + slotW, slotH, 0x333322, fillAlpha, ); bg.setStrokeStyle(strokeWidth, strokeColor); this.streetContainer.add(bg); // Slot number - const idxText = this.add.text(x + SLOT_W / 2, y + SLOT_H / 2, `${index}`, { + const idxText = this.add.text(x + slotW / 2, y + slotH / 2, `${index}`, { fontSize: '18px', color: (isSelectable || isHinted) ? '#ffdd44' : '#666655', fontFamily: FONT_FAMILY, }).setOrigin(0.5); @@ -775,23 +900,25 @@ export class MainStreetScene extends CardGameScene { private refreshMarket(): void { this.marketContainer.removeAll(true); + const { gameW, marketTop, marketRowH, marketRowGap } = this.layout; + // Section background (2 rows: business + investments) - const totalH = 2 * MARKET_ROW_H + MARKET_ROW_GAP + 20; + const totalH = 2 * marketRowH + marketRowGap + 20; const bgBox = this.add.graphics(); bgBox.fillStyle(BOX_FILL, 0.3); - bgBox.fillRoundedRect(20, MARKET_TOP - 10, GAME_W - 40, totalH, BOX_RADIUS); + bgBox.fillRoundedRect(20, marketTop - 10, gameW - 40, totalH, BOX_RADIUS); bgBox.lineStyle(1, BOX_STROKE, 0.4); - bgBox.strokeRoundedRect(20, MARKET_TOP - 10, GAME_W - 40, totalH, BOX_RADIUS); + bgBox.strokeRoundedRect(20, marketTop - 10, gameW - 40, totalH, BOX_RADIUS); this.marketContainer.add(bgBox); - const sectionLabel = this.add.text(GAME_W / 2, MARKET_TOP - 4, 'Market', { + const sectionLabel = this.add.text(gameW / 2, marketTop - 4, 'Market', { fontSize: '13px', fontStyle: 'bold', color: '#887766', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 1); this.marketContainer.add(sectionLabel); // Business row this.drawMarketRow( - MARKET_TOP + 6, + marketTop + 6, 'Business', this.state.market.business, MARKET_BUSINESS_SLOTS, @@ -800,7 +927,7 @@ export class MainStreetScene extends CardGameScene { // Investments row (mixed upgrades + investment events) this.drawMarketRow( - MARKET_TOP + 6 + MARKET_ROW_H + MARKET_ROW_GAP, + marketTop + 6 + marketRowH + marketRowGap, 'Investments', this.state.market.investments, MARKET_INVESTMENT_SLOTS, @@ -821,49 +948,87 @@ export class MainStreetScene extends CardGameScene { maxSlots: number, onClick: (card: BusinessCard | EventCard | UpgradeCard) => void, ): void { - // Row label - const label = this.add.text(40, y + MARKET_CARD_H / 2, rowLabel, { + const { marketCardW, marketCardH, marketCardGap, marketLabelW } = this.layout; + + // Row label - also use for positioning deck count + const label = this.add.text(40, y, rowLabel, { fontSize: '14px', fontStyle: 'bold', color: '#aa9977', fontFamily: FONT_FAMILY, }).setOrigin(0, 0.5); this.marketContainer.add(label); - const startX = MARKET_LABEL_W + 50; + const startX = marketLabelW + 50; for (let i = 0; i < maxSlots; i++) { - const cx = startX + i * (MARKET_CARD_W + MARKET_CARD_GAP); + const cx = startX + i * (marketCardW + marketCardGap); const card = cards[i]; if (card) { - const cardObj = this.drawMarketCard(cx, y, card, onClick); - this.marketContainer.add(cardObj); + // If a placeholder texture is available, render it in the first slot + // so developers can visually validate SVG scaling. Otherwise fall back + // to the normal drawMarketCard rendering. + if (i === 0 && this.textures && this.textures.exists && this.textures.exists('ms_placeholder_card')) { + const container = this.add.container(cx + marketCardW / 2, y + marketCardH / 2); + const img = this.add.image(0, 0, 'ms_placeholder_card'); + // Preserve source aspect ratio when fitting into the slot. + const SRC_W = 140; + const SRC_H = 190; + const fitW = marketCardW - 4; + const fitH = marketCardH - 4; + const scale = Math.min(fitW / SRC_W, fitH / SRC_H); + img.setDisplaySize(Math.round(SRC_W * scale), Math.round(SRC_H * scale)); + container.add(img); + + // Add a simple label so the card still shows its name/cost + const labelStr = cardLabel(card); + const nameText = this.add.text(0, -marketCardH / 2 + 10, labelStr, { + fontSize: '12px', fontStyle: 'bold', color: '#ffffff', fontFamily: FONT_FAMILY, + wordWrap: { width: marketCardW - 12 }, + align: 'center', + }).setOrigin(0.5, 0); + container.add(nameText); + + // Make it interactive like the regular card if applicable + const isIncidentEvent = card.family === 'event' && (card as EventCard).trigger === 'Incident'; + if (this.uiPhase === 'market' && !isIncidentEvent) { + img.setInteractive({ useHandCursor: true }); + img.on('pointerdown', () => onClick(card)); + img.on('pointerover', () => container.setScale(1.03)); + img.on('pointerout', () => container.setScale(1.0)); + } + + this.marketContainer.add(container); + } else { + const cardObj = this.drawMarketCard(cx, y, card, onClick); + this.marketContainer.add(cardObj); + } } else { // Empty slot const empty = this.add.rectangle( - cx + MARKET_CARD_W / 2, y + MARKET_CARD_H / 2, - MARKET_CARD_W, MARKET_CARD_H, 0x222211, 0.3, + cx + marketCardW / 2, y + marketCardH / 2, + marketCardW, marketCardH, 0x222211, 0.3, ); empty.setStrokeStyle(1, 0x333322); this.marketContainer.add(empty); } } - // Deck count (right side) - const deckX = startX + maxSlots * (MARKET_CARD_W + MARKET_CARD_GAP) + 10; + // Deck count - immediately below the label + const deckY = y + 16; if (rowLabel === 'Business') { const deckCount = this.state.decks.business.length; - const deckText = this.add.text(deckX, y + MARKET_CARD_H / 2, `Deck: ${deckCount}`, { + const deckText = this.add.text(40, deckY, `Deck: ${deckCount}`, { fontSize: '12px', color: '#776655', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); + }).setOrigin(0, 0); this.marketContainer.add(deckText); } else { - // Investments row: show both upgrade and event deck counts + // Investments row: show both upgrade and event deck counts - below label const upgCount = this.state.decks.upgrade.length; const evtCount = this.state.decks.event.length; const deckText = this.add.text( - deckX, y + MARKET_CARD_H / 2, + 40, deckY, `Upg: ${upgCount} Evt: ${evtCount}`, { fontSize: '11px', color: '#776655', fontFamily: FONT_FAMILY }, - ).setOrigin(0, 0.5); + ).setOrigin(0, 0); this.marketContainer.add(deckText); } } @@ -874,7 +1039,8 @@ export class MainStreetScene extends CardGameScene { card: BusinessCard | EventCard | UpgradeCard, onClick: (card: BusinessCard | EventCard | UpgradeCard) => void, ): Phaser.GameObjects.Container { - const container = this.add.container(x + MARKET_CARD_W / 2, y + MARKET_CARD_H / 2); + const { marketCardW, marketCardH } = this.layout; + const container = this.add.container(x + marketCardW / 2, y + marketCardH / 2); // Determine if this is a non-purchasable Incident event const isIncidentEvent = card.family === 'event' && (card as EventCard).trigger === 'Incident'; @@ -894,7 +1060,7 @@ export class MainStreetScene extends CardGameScene { // Background const fillAlpha = isIncidentEvent ? 0.5 : 0.7; - const bg = this.add.rectangle(0, 0, MARKET_CARD_W, MARKET_CARD_H, fillColor, fillAlpha); + const bg = this.add.rectangle(0, 0, marketCardW, marketCardH, fillColor, fillAlpha); // Hinted cards get a bright cyan border; incident events use their normal border const strokeColor = isHinted ? 0x44ffff : (isIncidentEvent ? 0x556688 : 0x888877); const strokeWidth = isHinted ? 3 : 1; @@ -903,11 +1069,11 @@ export class MainStreetScene extends CardGameScene { // Card label (name + cost for business/upgrade) const labelStr = cardLabel(card); - const nameText = this.add.text(0, -MARKET_CARD_H / 2 + 10, labelStr, { + const nameText = this.add.text(0, -marketCardH / 2 + 10, labelStr, { fontSize: '12px', fontStyle: 'bold', color: isIncidentEvent ? '#8899bb' : '#ffffff', fontFamily: FONT_FAMILY, - wordWrap: { width: MARKET_CARD_W - 12 }, + wordWrap: { width: marketCardW - 12 }, align: 'center', }).setOrigin(0.5, 0); container.add(nameText); @@ -917,7 +1083,7 @@ export class MainStreetScene extends CardGameScene { const evt = card as EventCard; const triggerColor = isIncidentEvent ? '#6688bb' : '#cc9944'; const triggerLabel = this.add.text( - MARKET_CARD_W / 2 - 4, -MARKET_CARD_H / 2 + 4, + marketCardW / 2 - 4, -marketCardH / 2 + 4, evt.trigger, { fontSize: '9px', fontStyle: 'bold', color: triggerColor, fontFamily: FONT_FAMILY }, ).setOrigin(1, 0); @@ -940,10 +1106,10 @@ export class MainStreetScene extends CardGameScene { infoStr = `For: ${upg.targetBusiness}`; } - const infoText = this.add.text(0, MARKET_CARD_H / 2 - 18, infoStr, { + const infoText = this.add.text(0, marketCardH / 2 - 18, infoStr, { fontSize: '11px', color: isIncidentEvent ? '#7788aa' : '#ddddcc', fontFamily: FONT_FAMILY, - wordWrap: { width: MARKET_CARD_W - 12 }, + wordWrap: { width: marketCardW - 12 }, align: 'center', }).setOrigin(0.5, 1); container.add(infoText); @@ -974,48 +1140,49 @@ export class MainStreetScene extends CardGameScene { const queue = this.state.incidentQueue; const deckRemaining = this.state.decks.event.length; - // Section background - const queueW = QUEUE_LABEL_W + INCIDENT_QUEUE_SIZE * (QUEUE_CARD_W + QUEUE_CARD_GAP) + 100; - const queueH = QUEUE_CARD_H + 24; + const { queueLabelW, queueCardW, queueCardH, queueCardGap, queueTop } = this.layout; + + // Section background - width to just fit cards with small right margin + const queueW = queueLabelW + 50 + INCIDENT_QUEUE_SIZE * (queueCardW + queueCardGap) - queueCardGap + 20; + const queueH = queueCardH + 24; const bgBox = this.add.graphics(); bgBox.fillStyle(0x1a1830, 0.35); - bgBox.fillRoundedRect(20, QUEUE_TOP - 10, queueW, queueH, BOX_RADIUS); + bgBox.fillRoundedRect(110, queueTop - 10, queueW, queueH, BOX_RADIUS); bgBox.lineStyle(1, 0x445577, 0.5); - bgBox.strokeRoundedRect(20, QUEUE_TOP - 10, queueW, queueH, BOX_RADIUS); + bgBox.strokeRoundedRect(110, queueTop - 10, queueW, queueH, BOX_RADIUS); this.incidentQueueContainer.add(bgBox); // Section label - const label = this.add.text(40, QUEUE_TOP + QUEUE_CARD_H / 2 - 2, 'Upcoming\nIncidents', { + const label = this.add.text(40, queueTop + queueCardH / 2 - 2, 'Upcoming', { fontSize: '13px', fontStyle: 'bold', color: '#7788aa', fontFamily: FONT_FAMILY, align: 'center', }).setOrigin(0, 0.5); this.incidentQueueContainer.add(label); - const startX = QUEUE_LABEL_W + 50; + const startX = queueLabelW + 50; for (let i = 0; i < INCIDENT_QUEUE_SIZE; i++) { - const cx = startX + i * (QUEUE_CARD_W + QUEUE_CARD_GAP); + const cx = startX + i * (queueCardW + queueCardGap); const card = queue[i]; if (card) { - const cardContainer = this.drawIncidentCard(cx, QUEUE_TOP, card); + const cardContainer = this.drawIncidentCard(cx, queueTop, card); this.incidentQueueContainer.add(cardContainer); } else { // Empty queue slot const empty = this.add.rectangle( - cx + QUEUE_CARD_W / 2, QUEUE_TOP + QUEUE_CARD_H / 2, - QUEUE_CARD_W, QUEUE_CARD_H, 0x111122, 0.3, + cx + queueCardW / 2, queueTop + queueCardH / 2, + queueCardW, queueCardH, 0x111122, 0.3, ); empty.setStrokeStyle(1, 0x223344); this.incidentQueueContainer.add(empty); } } - // Deck count - const deckX = startX + INCIDENT_QUEUE_SIZE * (QUEUE_CARD_W + QUEUE_CARD_GAP) + 10; - const deckText = this.add.text(deckX, QUEUE_TOP + QUEUE_CARD_H / 2, `Deck: ${deckRemaining}`, { + // Deck count - immediately below the label + const deckText = this.add.text(40, queueTop + 32, `Deck: ${deckRemaining}`, { fontSize: '11px', color: '#556677', fontFamily: FONT_FAMILY, - }).setOrigin(0, 0.5); + }).setOrigin(0, 0); this.incidentQueueContainer.add(deckText); } @@ -1024,18 +1191,19 @@ export class MainStreetScene extends CardGameScene { y: number, card: EventCard, ): Phaser.GameObjects.Container { - const container = this.add.container(x + QUEUE_CARD_W / 2, y + QUEUE_CARD_H / 2); + const { queueCardW, queueCardH } = this.layout; + const container = this.add.container(x + queueCardW / 2, y + queueCardH / 2); // Indigo background (non-interactive) - const bg = this.add.rectangle(0, 0, QUEUE_CARD_W, QUEUE_CARD_H, 0x2B3A67, 0.5); + const bg = this.add.rectangle(0, 0, queueCardW, queueCardH, 0x2B3A67, 0.5); bg.setStrokeStyle(1, 0x556688); container.add(bg); // Card name - const nameText = this.add.text(0, -QUEUE_CARD_H / 2 + 8, cardLabel(card), { + const nameText = this.add.text(0, -queueCardH / 2 + 8, cardLabel(card), { fontSize: '11px', fontStyle: 'bold', color: '#8899bb', fontFamily: FONT_FAMILY, - wordWrap: { width: QUEUE_CARD_W - 12 }, + wordWrap: { width: queueCardW - 12 }, align: 'center', }).setOrigin(0.5, 0); container.add(nameText); @@ -1046,10 +1214,10 @@ export class MainStreetScene extends CardGameScene { if (card.reputationDelta !== 0) parts.push(`${card.reputationDelta > 0 ? '+' : ''}${card.reputationDelta} rep`); const infoStr = parts.join(', ') || card.effect; - const infoText = this.add.text(0, QUEUE_CARD_H / 2 - 12, infoStr, { + const infoText = this.add.text(0, queueCardH / 2 - 12, infoStr, { fontSize: '10px', color: '#7788aa', fontFamily: FONT_FAMILY, - wordWrap: { width: QUEUE_CARD_W - 12 }, + wordWrap: { width: queueCardW - 12 }, align: 'center', }).setOrigin(0.5, 1); container.add(infoText); @@ -1063,27 +1231,24 @@ export class MainStreetScene extends CardGameScene { this.handContainer.removeAll(true); const held = this.state.heldEvent; + const { handY, handX, handCardW, handCardH } = this.layout; - // Section label - const label = this.add.text(40, HAND_Y - 10, 'Your Hand', { - fontSize: '13px', fontStyle: 'bold', color: '#aa9944', fontFamily: FONT_FAMILY, - }).setOrigin(0, 1); - this.handContainer.add(label); + // Your Hand label removed if (held) { - const cardContainer = this.drawHeldEventCard(40, HAND_Y, held); + const cardContainer = this.drawHeldEventCard(handX, handY, held); this.handContainer.add(cardContainer); } else { // Empty hand slot const empty = this.add.rectangle( - 40 + HAND_CARD_W / 2, HAND_Y + HAND_CARD_H / 2, - HAND_CARD_W, HAND_CARD_H, 0x222211, 0.2, + 40 + handCardW / 2, handY + handCardH / 2, + handCardW, handCardH, 0x222211, 0.2, ); empty.setStrokeStyle(1, 0x333322, 0.4); this.handContainer.add(empty); const emptyText = this.add.text( - 40 + HAND_CARD_W / 2, HAND_Y + HAND_CARD_H / 2, + 40 + handCardW / 2, handY + handCardH / 2, 'No held event', { fontSize: '11px', color: '#555544', fontFamily: FONT_FAMILY }, ).setOrigin(0.5); @@ -1096,19 +1261,20 @@ export class MainStreetScene extends CardGameScene { y: number, card: EventCard, ): Phaser.GameObjects.Container { - const container = this.add.container(x + HAND_CARD_W / 2, y + HAND_CARD_H / 2); + const { handCardW, handCardH } = this.layout; + const container = this.add.container(x + handCardW / 2, y + handCardH / 2); const isHinted = this.hintedCardId !== null && card.id === this.hintedCardId; // Warm brown background (Investment) - const bg = this.add.rectangle(0, 0, HAND_CARD_W, HAND_CARD_H, 0x8B4513, 0.7); + const bg = this.add.rectangle(0, 0, handCardW, handCardH, 0x8B4513, 0.7); bg.setStrokeStyle(isHinted ? 3 : 2, isHinted ? 0x44ffff : 0xcc9944); container.add(bg); // Card name - const nameText = this.add.text(0, -HAND_CARD_H / 2 + 10, cardLabel(card), { + const nameText = this.add.text(0, -handCardH / 2 + 10, cardLabel(card), { fontSize: '12px', fontStyle: 'bold', color: '#ffffff', fontFamily: FONT_FAMILY, - wordWrap: { width: HAND_CARD_W - 12 }, + wordWrap: { width: handCardW - 12 }, align: 'center', }).setOrigin(0.5, 0); container.add(nameText); @@ -1119,16 +1285,16 @@ export class MainStreetScene extends CardGameScene { if (card.reputationDelta !== 0) parts.push(`${card.reputationDelta > 0 ? '+' : ''}${card.reputationDelta} rep`); const infoStr = parts.join(', ') || card.effect; - const infoText = this.add.text(0, HAND_CARD_H / 2 - 14, infoStr, { + const infoText = this.add.text(0, handCardH / 2 - 14, infoStr, { fontSize: '11px', color: '#ddddcc', fontFamily: FONT_FAMILY, - wordWrap: { width: HAND_CARD_W - 12 }, + wordWrap: { width: handCardW - 12 }, align: 'center', }).setOrigin(0.5, 1); container.add(infoText); // "Click to play" hint - const hint = this.add.text(0, HAND_CARD_H / 2 - 2, 'Click to play', { + const hint = this.add.text(0, handCardH / 2 - 2, 'Click to play', { fontSize: '9px', fontStyle: 'italic', color: '#ccaa66', fontFamily: FONT_FAMILY, }).setOrigin(0.5, 1); @@ -1178,8 +1344,8 @@ export class MainStreetScene extends CardGameScene { this.actionContainer.removeAll(true); if (this.uiPhase === 'market') { - const rightX = GAME_W - 40; - const by = ACTION_Y; + const rightX = this.layout.gameW - 24; + const by = this.layout.actionY; // Affordable summary const affordable = getAffordableBusinessCards(this.state); @@ -1197,45 +1363,45 @@ export class MainStreetScene extends CardGameScene { ? `Can buy: ${summaryParts.join(', ')}` : 'No affordable cards'; - const summary = this.add.text(rightX, by - 8, summaryStr, { - fontSize: '13px', color: '#887766', fontFamily: FONT_FAMILY, + const summary = this.add.text(rightX, by - 4, summaryStr, { + fontSize: '12px', color: '#887766', fontFamily: FONT_FAMILY, }).setOrigin(1, 1); this.actionContainer.add(summary); // End Turn button (right-aligned) - const btnW = 160; - const hintBtnW = 130; - const smallW = 80; + const btnW = this.layout.actionButtonW; + const hintBtnW = this.layout.hintButtonW; + const smallW = this.layout.smallButtonW; - const endBtn = this.createActionButton(rightX - btnW, by + 8, btnW, 'End Turn', () => { + const endBtn = this.createActionButton(rightX - btnW, by + 4, btnW, 'End Turn', () => { this.endTurn(); }); this.actionContainer.add(endBtn); // Hint button (to the left of End Turn) - const hintBtn = this.createHintButton(rightX - btnW - 12 - hintBtnW, by + 8, hintBtnW); + const hintBtn = this.createHintButton(rightX - btnW - 12 - hintBtnW, by + 4, hintBtnW); this.actionContainer.add(hintBtn); // Undo / Redo buttons (to the left of Hint) const undoBaseX = rightX - btnW - 12 - hintBtnW - 12 - smallW - 12 - smallW; - const undoBtn = this.createActionButton(undoBaseX, by + 8, smallW, 'Undo', () => this.performUndo()); + const undoBtn = this.createActionButton(undoBaseX, by + 4, smallW, 'Undo', () => this.performUndo()); this.actionContainer.add(undoBtn); - const redoBtn = this.createActionButton(undoBaseX + smallW + 12, by + 8, smallW, 'Redo', () => this.performRedo()); + const redoBtn = this.createActionButton(undoBaseX + smallW + 12, by + 4, smallW, 'Redo', () => this.performRedo()); this.actionContainer.add(redoBtn); } else if (this.uiPhase === 'placing-business') { - const rightX = GAME_W - 40; - const by = ACTION_Y; + const rightX = this.layout.gameW - 24; + const by = this.layout.actionY; const cardName = this.pendingBusinessCard?.name ?? '???'; - const hint = this.add.text(rightX, by - 8, `Place "${cardName}" -- click an empty slot`, { - fontSize: '15px', fontStyle: 'bold', color: '#ffdd44', fontFamily: FONT_FAMILY, + const hint = this.add.text(rightX, by - 4, `Place "${cardName}" -- click an empty slot`, { + fontSize: '14px', fontStyle: 'bold', color: '#ffdd44', fontFamily: FONT_FAMILY, }).setOrigin(1, 1); this.actionContainer.add(hint); // Cancel button (right-aligned) - const btnW = 160; - const cancelBtn = this.createActionButton(rightX - btnW, by + 8, btnW, 'Cancel', () => { + const btnW = this.layout.actionButtonW; + const cancelBtn = this.createActionButton(rightX - btnW, by + 4, btnW, 'Cancel', () => { this.pendingBusinessCard = null; this.uiPhase = 'market'; this.refreshAll(); @@ -1254,7 +1420,7 @@ export class MainStreetScene extends CardGameScene { text: string, callback: () => void, ): Phaser.GameObjects.Container { - const btnH = 40; + const btnH = this.layout.actionButtonH; const container = this.add.container(x + width / 2, y + btnH / 2); const bg = this.add.rectangle(0, 0, width, btnH, 0x554422, 0.8); @@ -1262,7 +1428,7 @@ export class MainStreetScene extends CardGameScene { container.add(bg); const label = this.add.text(0, 0, text, { - fontSize: '16px', fontStyle: 'bold', color: '#ffcc88', fontFamily: FONT_FAMILY, + fontSize: '14px', fontStyle: 'bold', color: '#ffcc88', fontFamily: FONT_FAMILY, }).setOrigin(0.5); container.add(label); @@ -1290,7 +1456,7 @@ export class MainStreetScene extends CardGameScene { y: number, width: number, ): Phaser.GameObjects.Container { - const btnH = 40; + const btnH = this.layout.actionButtonH; const isDisabled = this.hintUsedThisTurn; const container = this.add.container(x + width / 2, y + btnH / 2); @@ -1304,7 +1470,7 @@ export class MainStreetScene extends CardGameScene { container.add(bg); const label = this.add.text(0, 0, isDisabled ? 'Hint ✓' : 'Hint', { - fontSize: '16px', fontStyle: 'bold', color: textColor, fontFamily: FONT_FAMILY, + fontSize: '14px', fontStyle: 'bold', color: textColor, fontFamily: FONT_FAMILY, }).setOrigin(0.5); container.add(label); @@ -1531,8 +1697,8 @@ export class MainStreetScene extends CardGameScene { ); this.overlayObjects.push(...overlay.objects); - const cx = GAME_W / 2; - const cy = GAME_H / 2; + const cx = this.layout.gameW / 2; + const cy = this.layout.gameH / 2; const top = cy - MODAL_H / 2; // Title @@ -1628,7 +1794,7 @@ export class MainStreetScene extends CardGameScene { // Clear existing content this.logContentContainer.removeAll(true); - const contentW = LOG_W - LOG_PAD * 2; + const contentW = this.layout.logW - LOG_PAD * 2; let yOff = 0; for (const entry of entries) { @@ -1639,7 +1805,7 @@ export class MainStreetScene extends CardGameScene { // Subtle background bar for turn headers const barBg = this.add.graphics(); barBg.fillStyle(0x443311, 0.5); - barBg.fillRect(0, yOff, LOG_W, LOG_LINE_H); + barBg.fillRect(0, yOff, this.layout.logW, LOG_LINE_H); this.logContentContainer.add(barBg); } @@ -1659,7 +1825,7 @@ export class MainStreetScene extends CardGameScene { this.logTotalContentH = yOff; // Visible area inside the panel (below title bar, above bottom edge) - const visibleH = LOG_H - LOG_TITLE_H - 4; + const visibleH = this.layout.logH - LOG_TITLE_H - 4; this.logMaxScroll = Math.max(0, this.logTotalContentH - visibleH); // Keep scroll position valid for the current content height. @@ -1681,10 +1847,10 @@ export class MainStreetScene extends CardGameScene { this.logMaskGraphics.fillStyle(0xffffff); // Mask is in world coordinates this.logMaskGraphics.fillRect( - LOG_X, - LOG_Y + LOG_TITLE_H, - LOG_W, - LOG_H - LOG_TITLE_H - 2, + this.layout.logX, + this.layout.logY + LOG_TITLE_H, + this.layout.logW, + this.layout.logH - LOG_TITLE_H - 2, ); } @@ -1697,8 +1863,8 @@ export class MainStreetScene extends CardGameScene { ): void => { // Only scroll when pointer is inside the log panel bounds if ( - pointer.x < LOG_X || pointer.x > LOG_X + LOG_W || - pointer.y < LOG_Y || pointer.y > LOG_Y + LOG_H + pointer.x < this.layout.logX || pointer.x > this.layout.logX + this.layout.logW || + pointer.y < this.layout.logY || pointer.y > this.layout.logY + this.layout.logH ) { return; } @@ -1723,6 +1889,66 @@ export class MainStreetScene extends CardGameScene { this.updateLogMask(); } + /** Test helper: returns current computed scene layout metrics. */ + getLayoutMetricsForTest(): SceneLayout { + return { ...this.layout }; + } + + /** Test helper: returns rectangles for major play zones. */ + getSectionRectsForTest(): { + market: { x: number; y: number; w: number; h: number }; + queue: { x: number; y: number; w: number; h: number }; + street: { x: number; y: number; w: number; h: number }; + hand: { x: number; y: number; w: number; h: number }; + action: { x: number; y: number; w: number; h: number }; + instruction: { x: number; y: number; w: number; h: number }; + } { + const l = this.layout; + const market = { + x: 20, + y: l.marketTop - 10, + w: l.gameW - 40, + h: 2 * l.marketRowH + l.marketRowGap + 20, + }; + const queue = { + x: 20, + y: l.queueTop - 10, + w: l.queueLabelW + INCIDENT_QUEUE_SIZE * (l.queueCardW + l.queueCardGap) + 100, + h: l.queueCardH + 24, + }; + const street = { + x: l.streetX, + y: l.streetTop, + w: l.streetCols * l.slotW + (l.streetCols - 1) * l.slotGap, + h: STREET_ROWS * l.slotH + (STREET_ROWS - 1) * l.streetRowGap, + }; + const hand = { + x: 40, + y: l.handY, + w: l.handCardW, + h: l.handCardH, + }; + + const rightX = l.gameW - 24; + const actionRowY = l.actionY + 4; + const actionW = l.actionButtonW + 12 + l.hintButtonW + 12 + l.smallButtonW + 12 + l.smallButtonW; + const action = { + x: rightX - actionW, + y: actionRowY, + w: actionW, + h: l.actionButtonH, + }; + + const instruction = { + x: this.instructionText.x - this.instructionText.displayWidth, + y: this.instructionText.y - this.instructionText.displayHeight * 0.5, + w: this.instructionText.displayWidth, + h: this.instructionText.displayHeight, + }; + + return { market, queue, street, hand, action, instruction }; + } + // ── Game Over Overlay ─────────────────────────────────── private showGameOverOverlay( @@ -1768,10 +1994,10 @@ export class MainStreetScene extends CardGameScene { this.overlayObjects.push(...overlay.objects); // Vertical anchor: centre of the panel - const panelTop = GAME_H / 2 - panelH / 2; + const panelTop = this.layout.gameH / 2 - panelH / 2; // Title - const titleText = this.add.text(GAME_W / 2, panelTop + 30, title, { + const titleText = this.add.text(this.layout.gameW / 2, panelTop + 30, title, { fontSize: '36px', fontStyle: 'bold', color, fontFamily: FONT_FAMILY, }).setOrigin(0.5).setDepth(101); this.overlayObjects.push(titleText); @@ -1779,7 +2005,7 @@ export class MainStreetScene extends CardGameScene { // End reason const reason = this.state.endReason ?? 'unknown'; const reasonText = this.add.text( - GAME_W / 2, panelTop + 72, + this.layout.gameW / 2, panelTop + 72, reason.replace(/_/g, ' '), { fontSize: '18px', color: '#ccbbaa', fontFamily: FONT_FAMILY }, ).setOrigin(0.5).setDepth(101); @@ -1796,7 +2022,7 @@ export class MainStreetScene extends CardGameScene { `Final Score: ${result.finalScore}`, ]; const breakdownY = panelTop + 110; - const breakdown = this.add.text(GAME_W / 2, breakdownY, lines.join('\n'), { + const breakdown = this.add.text(this.layout.gameW / 2, breakdownY, lines.join('\n'), { fontSize: '16px', color: '#ddccbb', fontFamily: FONT_FAMILY, align: 'center', lineSpacing: 6, }).setOrigin(0.5, 0).setDepth(101); @@ -1806,7 +2032,7 @@ export class MainStreetScene extends CardGameScene { let cursorY = breakdownY + 100; // approximate height of score breakdown text if (challengeLineCount > 0) { const sectionTitle = this.add.text( - GAME_W / 2, cursorY, + this.layout.gameW / 2, cursorY, 'Challenge Details:', { fontSize: '14px', fontStyle: 'bold', color: '#aa9977', fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); @@ -1818,7 +2044,7 @@ export class MainStreetScene extends CardGameScene { const icon = done ? '\u2713' : '\u2717'; // checkmark or cross const lineColor = done ? '#44ff44' : '#ff6666'; const challengeLine = this.add.text( - GAME_W / 2, cursorY, + this.layout.gameW / 2, cursorY, `${icon} ${ac.challenge.title}`, { fontSize: '13px', color: lineColor, fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); @@ -1831,7 +2057,7 @@ export class MainStreetScene extends CardGameScene { if (newlyUnlockedTiers.length > 0) { cursorY += 8; const unlockHeader = this.add.text( - GAME_W / 2, cursorY, + this.layout.gameW / 2, cursorY, 'Tier Unlocked!', { fontSize: '14px', fontStyle: 'bold', color: '#44ff44', fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); @@ -1850,7 +2076,7 @@ export class MainStreetScene extends CardGameScene { ? '(via challenges)' : '(via reputation)'; const tierLine = this.add.text( - GAME_W / 2, cursorY, + this.layout.gameW / 2, cursorY, `NEW: Tier ${def.order} - ${def.name} ${triggerLabel}`, { fontSize: '13px', color: '#88ff88', fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); @@ -1861,7 +2087,7 @@ export class MainStreetScene extends CardGameScene { for (const cardId of def.newCardIds) { const cardName = CARD_TEMPLATE_NAMES.get(cardId) ?? cardId; const cardLine = this.add.text( - GAME_W / 2, cursorY, + this.layout.gameW / 2, cursorY, ` + ${cardName}`, { fontSize: '12px', color: '#aaddaa', fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); @@ -1880,7 +2106,7 @@ export class MainStreetScene extends CardGameScene { ? `Current Tier: ${highest.order} / ${tierCount} - ${highest.name}` : 'Current Tier: --'; const tierIndicator = this.add.text( - GAME_W / 2, cursorY, tierLabel, + this.layout.gameW / 2, cursorY, tierLabel, { fontSize: '14px', fontStyle: 'bold', color: '#ddbb88', fontFamily: FONT_FAMILY }, ).setOrigin(0.5, 0).setDepth(101); this.overlayObjects.push(tierIndicator); @@ -1894,7 +2120,7 @@ export class MainStreetScene extends CardGameScene { `High Score: ${this.campaign.highestScore} | Best Rep: ${this.campaign.persistentReputation}`, ]; const statsText = this.add.text( - GAME_W / 2, cursorY, statsLines.join('\n'), + this.layout.gameW / 2, cursorY, statsLines.join('\n'), { fontSize: '13px', color: '#bbaa99', fontFamily: FONT_FAMILY, align: 'center', lineSpacing: 4 }, ).setOrigin(0.5, 0).setDepth(101); this.overlayObjects.push(statsText); @@ -1903,14 +2129,14 @@ export class MainStreetScene extends CardGameScene { // Difficulty selector const diffY = panelTop + panelH - 80; const diffLabel = this.add.text( - GAME_W / 2 - 80, diffY, + this.layout.gameW / 2 - 80, diffY, `Difficulty: ${this.selectedDifficulty}`, { fontSize: '14px', color: '#ccbbaa', fontFamily: FONT_FAMILY }, ).setOrigin(0, 0.5).setDepth(101); this.overlayObjects.push(diffLabel); const cycleBtn = this.add.text( - GAME_W / 2 + 90, diffY, + this.layout.gameW / 2 + 90, diffY, '[ Change ]', { fontSize: '14px', color: '#ffdd88', fontFamily: FONT_FAMILY }, ).setOrigin(0, 0.5).setDepth(101).setInteractive({ useHandCursor: true }); @@ -1924,7 +2150,7 @@ export class MainStreetScene extends CardGameScene { // Buttons (positioned relative to panel bottom) const btnY = panelTop + panelH - 40; const playAgainBtn = createOverlayButton( - this, GAME_W / 2 - 110, btnY, + this, this.layout.gameW / 2 - 110, btnY, '[ Play Again ]', 101, ); playAgainBtn.on('pointerdown', () => { @@ -1935,7 +2161,7 @@ export class MainStreetScene extends CardGameScene { this.overlayObjects.push(playAgainBtn); const menuBtn = createOverlayMenuButton( - this, GAME_W / 2 + 30, btnY, 101, + this, this.layout.gameW / 2 + 30, btnY, 101, ); this.overlayObjects.push(menuBtn); } diff --git a/public/assets/CREDITS.md b/public/assets/CREDITS.md index fd36993..dcffc40 100644 --- a/public/assets/CREDITS.md +++ b/public/assets/CREDITS.md @@ -2,6 +2,13 @@ All assets in this directory are licensed for free commercial use. +## Canonical card art (project standard) + +- Orientation: portrait +- Canonical pixel size (source SVGs): 140 × 190 px + +Rendering guidance: The project's canonical card art is 140×190 (portrait). All runtime renderers should preserve aspect ratio (fit-inside) when deriving thumbnails for market slots, street slots, hand sprites, and UI components. See `docs/main-street/card-dimensions.md` for recommended mappings and Phaser examples. + ## Playing Card Assets 52 card face SVGs and 1 card back SVG sourced from: diff --git a/public/assets/games/main-street/svg/placeholder-card.svg b/public/assets/games/main-street/svg/placeholder-card.svg new file mode 100644 index 0000000..10e6dae --- /dev/null +++ b/public/assets/games/main-street/svg/placeholder-card.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + Main + Street + + + $5 + diff --git a/tests/golf/__screenshots__/GolfEvents.browser.test.ts/GolfScene-event-integration-should-emit-correct-event-sequence-for-human---AI-turn-cycle-and-game-ended-on-end-1.png b/tests/golf/__screenshots__/GolfEvents.browser.test.ts/GolfScene-event-integration-should-emit-correct-event-sequence-for-human---AI-turn-cycle-and-game-ended-on-end-1.png index ea4b6a2..83d6db1 100644 Binary files a/tests/golf/__screenshots__/GolfEvents.browser.test.ts/GolfScene-event-integration-should-emit-correct-event-sequence-for-human---AI-turn-cycle-and-game-ended-on-end-1.png and b/tests/golf/__screenshots__/GolfEvents.browser.test.ts/GolfScene-event-integration-should-emit-correct-event-sequence-for-human---AI-turn-cycle-and-game-ended-on-end-1.png differ diff --git a/tests/main-street/MainStreetScene.browser.test.ts b/tests/main-street/MainStreetScene.browser.test.ts index f022596..d49a1c7 100644 --- a/tests/main-street/MainStreetScene.browser.test.ts +++ b/tests/main-street/MainStreetScene.browser.test.ts @@ -4,7 +4,7 @@ import Phaser from 'phaser'; import { waitForScene } from '../helpers/waitForScene'; import { executeDayStart, processEndOfTurn } from '../../example-games/main-street/MainStreetEngine'; -async function bootGame(): Promise { +async function bootGame(options: { width?: number; height?: number } = {}): Promise { let container = document.getElementById('game-container'); if (container) container.remove(); @@ -13,7 +13,7 @@ async function bootGame(): Promise { document.body.appendChild(container); const { createMainStreetGame } = await import('../../example-games/main-street/createMainStreetGame'); - const game = createMainStreetGame(); + const game = createMainStreetGame(options); await waitForScene(game, 'MainStreetScene'); return game; } @@ -26,6 +26,17 @@ function destroyGame(game: Phaser.Game | null): void { if (container) container.remove(); } +function overlaps(a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }): boolean { + const aRight = a.x + a.w; + const aBottom = a.y + a.h; + const bRight = b.x + b.w; + const bBottom = b.y + b.h; + + if (aRight <= b.x || bRight <= a.x) return false; + if (aBottom <= b.y || bBottom <= a.y) return false; + return true; +} + describe('MainStreetScene browser tests', () => { let game: Phaser.Game | null = null; @@ -75,4 +86,88 @@ describe('MainStreetScene browser tests', () => { expect(textEntries.some((entry) => entry.text === 'Turn 2')).toBe(true); }); + + it('renders the street as a 2x5 grid', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as Phaser.Scene & { streetContainer?: Phaser.GameObjects.Container }; + + const street = scene.streetContainer as Phaser.GameObjects.Container; + const slots = street.list.filter((obj) => obj instanceof Phaser.GameObjects.Rectangle) as Phaser.GameObjects.Rectangle[]; + + expect(slots.length).toBe(10); + + const rows = new Map(); + for (const slot of slots) { + const y = Math.round(slot.y); + rows.set(y, (rows.get(y) ?? 0) + 1); + } + + expect(rows.size).toBe(2); + for (const count of rows.values()) { + expect(count).toBe(5); + } + }); + + it('keeps major zones non-overlapping at desktop and narrow mobile dimensions', async () => { + const viewports = [ + { width: 1280, height: 720 }, + { width: 900, height: 1100 }, + ]; + + for (const vp of viewports) { + game = await bootGame(vp); + const scene = game.scene.getScene('MainStreetScene') as Phaser.Scene & { + getSectionRectsForTest: () => { + market: { x: number; y: number; w: number; h: number }; + queue: { x: number; y: number; w: number; h: number }; + street: { x: number; y: number; w: number; h: number }; + hand: { x: number; y: number; w: number; h: number }; + action: { x: number; y: number; w: number; h: number }; + instruction: { x: number; y: number; w: number; h: number }; + }; + }; + + const rects = scene.getSectionRectsForTest(); + + expect(overlaps(rects.market, rects.queue), `market/queue overlap at ${vp.width}x${vp.height}`).toBe(false); + expect(overlaps(rects.queue, rects.street), `queue/street overlap at ${vp.width}x${vp.height}`).toBe(false); + expect(overlaps(rects.street, rects.hand), `street/hand overlap at ${vp.width}x${vp.height}`).toBe(false); + expect(overlaps(rects.hand, rects.action), `hand/action overlap at ${vp.width}x${vp.height}`).toBe(false); + expect(overlaps(rects.action, rects.instruction), `action/instruction overlap at ${vp.width}x${vp.height}`).toBe(false); + + destroyGame(game); + game = null; + } + }); + + it('loads placeholder texture and renders it without squashing', async () => { + game = await bootGame(); + const scene = game.scene.getScene('MainStreetScene') as Phaser.Scene & Record & { marketContainer?: Phaser.GameObjects.Container }; + + // Texture should be loaded by preload + expect((scene.textures as Phaser.Textures.TextureManager).exists('ms_placeholder_card')).toBe(true); + + // Look for image in marketContainer with that texture key + const market = scene.marketContainer as Phaser.GameObjects.Container; + const imgs = market.list.filter((obj) => obj instanceof Phaser.GameObjects.Container) as Phaser.GameObjects.Container[]; + let found = false; + for (const c of imgs) { + const childImg = c.list.find((o) => (o as Phaser.GameObjects.Image).texture && (o as Phaser.GameObjects.Image).texture.key === 'ms_placeholder_card'); + if (childImg) { + found = true; + const img = childImg as Phaser.GameObjects.Image; + // The image should preserve aspect ratio relative to canonical 140x190 + const srcW = 140; + const srcH = 190; + const displayedW = img.displayWidth; + const displayedH = img.displayHeight; + const srcRatio = srcW / srcH; + const dispRatio = Math.round((displayedW / displayedH) * 1000) / 1000; + const expectedRatio = Math.round(srcRatio * 1000) / 1000; + expect(Math.abs(dispRatio - expectedRatio)).toBeLessThan(0.02); + break; + } + } + expect(found).toBe(true); + }); }); diff --git a/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-keeps-major-zones-non-overlapping-at-desktop-and-narrow-mobile-dimensions-1.png b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-keeps-major-zones-non-overlapping-at-desktop-and-narrow-mobile-dimensions-1.png new file mode 100644 index 0000000..5a1f9ce Binary files /dev/null and b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-keeps-major-zones-non-overlapping-at-desktop-and-narrow-mobile-dimensions-1.png differ diff --git a/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-re-renders-activity-log-after-scene-restart-1.png b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-re-renders-activity-log-after-scene-restart-1.png new file mode 100644 index 0000000..5a1f9ce Binary files /dev/null and b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-re-renders-activity-log-after-scene-restart-1.png differ diff --git a/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-renders-the-street-as-a-2x5-grid-1.png b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-renders-the-street-as-a-2x5-grid-1.png new file mode 100644 index 0000000..5a1f9ce Binary files /dev/null and b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-renders-the-street-as-a-2x5-grid-1.png differ diff --git a/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-shows-new-entries-for-the-restarted-run-1.png b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-shows-new-entries-for-the-restarted-run-1.png new file mode 100644 index 0000000..5a1f9ce Binary files /dev/null and b/tests/main-street/__screenshots__/MainStreetScene.browser.test.ts/MainStreetScene-browser-tests-shows-new-entries-for-the-restarted-run-1.png differ diff --git a/tests/main-street/placeholder-card.test.ts b/tests/main-street/placeholder-card.test.ts new file mode 100644 index 0000000..a540a74 --- /dev/null +++ b/tests/main-street/placeholder-card.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, it, expect } from 'vitest'; + +describe('Main Street placeholder SVG', () => { + it('exists and has correct canonical dimensions (140x190)', () => { + const p = join('public', 'assets', 'games', 'main-street', 'svg', 'placeholder-card.svg'); + const src = readFileSync(p, 'utf8'); + + // Look for width/height attributes or viewBox + const widthMatch = src.match(/]*width="(\d+)"/); + const heightMatch = src.match(/]*height="(\d+)"/); + const viewBoxMatch = src.match(/]*viewBox="([^"]+)"/); + + if (viewBoxMatch) { + const parts = viewBoxMatch[1].split(/\s+/).map(Number); + expect(parts.length).toBe(4); + expect(parts[2]).toBeGreaterThan(0); + expect(parts[3]).toBeGreaterThan(0); + // prefer width/height check + } + + expect(widthMatch).not.toBeNull(); + expect(heightMatch).not.toBeNull(); + + const w = Number(widthMatch![1]); + const h = Number(heightMatch![1]); + expect(w).toBe(140); + expect(h).toBe(190); + }); +}); diff --git a/tests/main-street/transcript-recording.test.ts b/tests/main-street/transcript-recording.test.ts index 5413b75..9c69829 100644 --- a/tests/main-street/transcript-recording.test.ts +++ b/tests/main-street/transcript-recording.test.ts @@ -23,7 +23,9 @@ describe('Main Street transcript recording (action, undo, redo)', () => { const businessCards = state.market.business; expect(businessCards.length).toBeGreaterThan(0); - const cardId = businessCards[0].id; + // Pick an affordable business card for the test (avoid brittle cost assumptions) + const affordable = businessCards.find((b) => b.cost <= state.resourceBank.coins) ?? businessCards[0]; + const cardId = affordable.id; const slot = emptySlots[0]; // Attach a global recorder like the scene does diff --git a/tmp/audit-CG-0MMSCCES713H9HY6.txt b/tmp/audit-CG-0MMSCCES713H9HY6.txt deleted file mode 100644 index 7a9a205..0000000 --- a/tmp/audit-CG-0MMSCCES713H9HY6.txt +++ /dev/null @@ -1,16 +0,0 @@ -Ready to close: No - -## Summary -The repository shows Playwright is used (package.json and replay/test scripts) and GitHub Actions install browser binaries at runtime, but there are no container image/Dockerfile artifacts in this repo that would ensure Ampa review containers include native Playwright runtime libs (e.g. libnspr4). Opencode tooling is present locally, but there is no Ampa container shell or image recipe here to reproduce or validate the failing review-container behavior. For these reasons the work item cannot be closed. - -## Acceptance Criteria Status - -| # | Criterion | Verdict | Evidence | -|---|-----------|---------|----------| -| 1 | Ampa review container image includes runtime libs required by Playwright browser tests (including libnspr4 or equivalent). | unmet | .github/workflows/deploy.yml:66 — workflow runs `npx playwright install chromium`; repo lacks a container/Dockerfile to ensure libnspr4 is present | -| 2 | opencode audit/review commands execute successfully in Ampa review container shell. | partial | .opencode/package.json:2 — repo includes `@opencode-ai/plugin` (opencode support present) but no Ampa container shell/image recipe to validate execution | -| 3 | Re-run Review PR #433 command path to verify audit, code_review, and npm test browser stage all execute. | unmet | .github/workflows/pr-checks.yml:34 — PR checks run `npm test` (includes browser tests), but Ampa container reproduction is not present in repo so re-run cannot be performed here | - -## Children Status - -No children. diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.converted.json deleted file mode 100644 index 0fc1f63..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.converted.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-17T23:15:41.888Z", - "endedAt": "2026-04-17T23:15:41.890Z", - "initialState": { - "seed": "e2e-1776467740991" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-arcade-0 -> slot 0" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "action", - "turn": 3, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 3 - }, - { - "type": "action", - "turn": 4, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 4 - }, - { - "type": "game-end", - "turn": 4, - "finalScore": 1, - "result": { - "outcome": "loss" - }, - "endReason": "reputation_collapse" - } - ], - "results": { - "finalScore": 1, - "result": "loss", - "endReason": "reputation_collapse" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.json deleted file mode 100644 index fba659b..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467740991.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776467740991", - "startedAt": "2026-04-17T23:15:41.888Z", - "endedAt": "2026-04-17T23:15:41.890Z", - "totalTurns": 4, - "result": "loss", - "endReason": "reputation_collapse", - "finalScore": 1, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-arcade-0 -> slot 0" - } - ], - "income": 1, - "incident": "Noise Complaint", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 2, - "score": 10, - "gridOccupied": 1 - }, - { - "turn": 2, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Shoplifting Spree", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 2, - "score": 11, - "gridOccupied": 1 - }, - { - "turn": 3, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Vandalism", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 1, - "score": 6, - "gridOccupied": 1 - }, - { - "turn": 4, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Vandalism", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 0, - "score": 1, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.converted.json deleted file mode 100644 index 11c822c..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.converted.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-17T23:16:43.590Z", - "endedAt": "2026-04-17T23:16:43.599Z", - "initialState": { - "seed": "e2e-1776467802034" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-pawnshop-1 -> slot 0" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "action", - "turn": 3, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 3 - }, - { - "type": "action", - "turn": 4, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 4 - }, - { - "type": "action", - "turn": 5, - "action": { - "type": "buy-event", - "detail": "evt-festival-1" - } - }, - { - "type": "turn-end", - "turn": 5 - }, - { - "type": "action", - "turn": 6, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 6 - }, - { - "type": "action", - "turn": 7, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 7 - }, - { - "type": "action", - "turn": 8, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 8 - }, - { - "type": "action", - "turn": 9, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 9 - }, - { - "type": "action", - "turn": 10, - "action": { - "type": "buy-event", - "detail": "evt-charity-drive-27" - } - }, - { - "type": "turn-end", - "turn": 10 - }, - { - "type": "action", - "turn": 11, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 11 - }, - { - "type": "action", - "turn": 12, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 12 - }, - { - "type": "action", - "turn": 13, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 13 - }, - { - "type": "action", - "turn": 14, - "action": { - "type": "buy-event", - "detail": "evt-festival-0" - } - }, - { - "type": "turn-end", - "turn": 14 - }, - { - "type": "game-end", - "turn": 14, - "finalScore": 38, - "result": { - "outcome": "loss" - }, - "endReason": "bankruptcy" - } - ], - "results": { - "finalScore": 38, - "result": "loss", - "endReason": "bankruptcy" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.json deleted file mode 100644 index f76337e..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467802034.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776467802034", - "startedAt": "2026-04-17T23:16:43.590Z", - "endedAt": "2026-04-17T23:16:43.599Z", - "totalTurns": 14, - "result": "loss", - "endReason": "bankruptcy", - "finalScore": 38, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-pawnshop-1 -> slot 0" - } - ], - "income": 1, - "incident": "Shoplifting Spree", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 3, - "score": 16, - "gridOccupied": 1 - }, - { - "turn": 2, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Community Award", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 5, - "score": 27, - "gridOccupied": 1 - }, - { - "turn": 3, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Vandalism", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 4, - "score": 22, - "gridOccupied": 1 - }, - { - "turn": 4, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Community Award", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 6, - "score": 33, - "gridOccupied": 1 - }, - { - "turn": 5, - "actions": [ - { - "type": "buy-event", - "detail": "evt-festival-1" - } - ], - "income": 1, - "incident": "Road Construction", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 6, - "score": 30, - "gridOccupied": 1 - }, - { - "turn": 6, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Road Construction", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 7, - "score": 35, - "gridOccupied": 1 - }, - { - "turn": 7, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Vandalism", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 6, - "score": 30, - "gridOccupied": 1 - }, - { - "turn": 8, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Pipe Burst", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 6, - "score": 31, - "gridOccupied": 1 - }, - { - "turn": 9, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Health Inspection", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 5, - "score": 27, - "gridOccupied": 1 - }, - { - "turn": 10, - "actions": [ - { - "type": "buy-event", - "detail": "evt-charity-drive-27" - } - ], - "income": 1, - "incident": "Viral Review", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 6, - "score": 33, - "gridOccupied": 1 - }, - { - "turn": 11, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Power Outage", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 9, - "score": 47, - "gridOccupied": 1 - }, - { - "turn": 12, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Road Construction", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 9, - "score": 47, - "gridOccupied": 1 - }, - { - "turn": 13, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Noise Complaint", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 8, - "score": 43, - "gridOccupied": 1 - }, - { - "turn": 14, - "actions": [ - { - "type": "buy-event", - "detail": "evt-festival-0" - } - ], - "income": 1, - "incident": "Tax Audit", - "incidentQueueSize": 2, - "coinsAfter": -2, - "reputationAfter": 8, - "score": 38, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.converted.json deleted file mode 100644 index a9c2477..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.converted.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-17T23:17:30.418Z", - "endedAt": "2026-04-17T23:17:30.423Z", - "initialState": { - "seed": "e2e-1776467849129" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-laundromat-0 -> slot 0" - } - }, - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-event", - "detail": "evt-charity-drive-27" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "game-end", - "turn": 1, - "finalScore": 14, - "result": { - "outcome": "loss" - }, - "endReason": "bankruptcy" - } - ], - "results": { - "finalScore": 14, - "result": "loss", - "endReason": "bankruptcy" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.json deleted file mode 100644 index e7c8e38..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467849129.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776467849129", - "startedAt": "2026-04-17T23:17:30.418Z", - "endedAt": "2026-04-17T23:17:30.423Z", - "totalTurns": 1, - "result": "loss", - "endReason": "bankruptcy", - "finalScore": 14, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-laundromat-0 -> slot 0" - }, - { - "type": "buy-event", - "detail": "evt-charity-drive-27" - } - ], - "income": 1, - "incident": "Pipe Burst", - "incidentQueueSize": 2, - "coinsAfter": -1, - "reputationAfter": 3, - "score": 14, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.converted.json deleted file mode 100644 index ccb0927..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.converted.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-17T23:19:57.302Z", - "endedAt": "2026-04-17T23:19:57.309Z", - "initialState": { - "seed": "e2e-1776467996012" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-barbershop-2 -> slot 0" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "action", - "turn": 3, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 3 - }, - { - "type": "action", - "turn": 4, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 4 - }, - { - "type": "action", - "turn": 5, - "action": { - "type": "buy-event", - "detail": "evt-block-party-26" - } - }, - { - "type": "turn-end", - "turn": 5 - }, - { - "type": "action", - "turn": 6, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 6 - }, - { - "type": "action", - "turn": 7, - "action": { - "type": "buy-event", - "detail": "evt-charity-drive-29" - } - }, - { - "type": "turn-end", - "turn": 7 - }, - { - "type": "action", - "turn": 8, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 8 - }, - { - "type": "action", - "turn": 9, - "action": { - "type": "buy-event", - "detail": "evt-wellness-fair-23" - } - }, - { - "type": "turn-end", - "turn": 9 - }, - { - "type": "action", - "turn": 10, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 10 - }, - { - "type": "action", - "turn": 11, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 11 - }, - { - "type": "action", - "turn": 12, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 12 - }, - { - "type": "action", - "turn": 13, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 13 - }, - { - "type": "action", - "turn": 14, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 14 - }, - { - "type": "game-end", - "turn": 14, - "finalScore": 59, - "result": { - "outcome": "loss" - }, - "endReason": "bankruptcy" - } - ], - "results": { - "finalScore": 59, - "result": "loss", - "endReason": "bankruptcy" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.json deleted file mode 100644 index 0763be6..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776467996012.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776467996012", - "startedAt": "2026-04-17T23:19:57.302Z", - "endedAt": "2026-04-17T23:19:57.309Z", - "totalTurns": 14, - "result": "loss", - "endReason": "bankruptcy", - "finalScore": 59, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-barbershop-2 -> slot 0" - } - ], - "income": 1, - "incident": "Food Critic Visit", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 4, - "score": 23, - "gridOccupied": 1 - }, - { - "turn": 2, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Power Outage", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 4, - "score": 22, - "gridOccupied": 1 - }, - { - "turn": 3, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Road Construction", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 4, - "score": 22, - "gridOccupied": 1 - }, - { - "turn": 4, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Viral Review", - "incidentQueueSize": 2, - "coinsAfter": 5, - "reputationAfter": 5, - "score": 30, - "gridOccupied": 1 - }, - { - "turn": 5, - "actions": [ - { - "type": "buy-event", - "detail": "evt-block-party-26" - } - ], - "income": 1, - "incident": "Food Critic Visit", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 6, - "score": 32, - "gridOccupied": 1 - }, - { - "turn": 6, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Community Award", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 10, - "score": 53, - "gridOccupied": 1 - }, - { - "turn": 7, - "actions": [ - { - "type": "buy-event", - "detail": "evt-charity-drive-29" - } - ], - "income": 1, - "incident": "Rainy Day", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 10, - "score": 52, - "gridOccupied": 1 - }, - { - "turn": 8, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Health Inspection", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 12, - "score": 63, - "gridOccupied": 1 - }, - { - "turn": 9, - "actions": [ - { - "type": "buy-event", - "detail": "evt-wellness-fair-23" - } - ], - "income": 1, - "incident": "Noise Complaint", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 11, - "score": 56, - "gridOccupied": 1 - }, - { - "turn": 10, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Tax Audit", - "incidentQueueSize": 2, - "coinsAfter": 2, - "reputationAfter": 12, - "score": 62, - "gridOccupied": 1 - }, - { - "turn": 11, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Tax Audit", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 12, - "score": 60, - "gridOccupied": 1 - }, - { - "turn": 12, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Shoplifting Spree", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 12, - "score": 61, - "gridOccupied": 1 - }, - { - "turn": 13, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Pipe Burst", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 12, - "score": 60, - "gridOccupied": 1 - }, - { - "turn": 14, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 1, - "incident": "Pipe Burst", - "incidentQueueSize": 2, - "coinsAfter": -1, - "reputationAfter": 12, - "score": 59, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.converted.json deleted file mode 100644 index b6fbeba..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.converted.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-17T23:24:23.943Z", - "endedAt": "2026-04-17T23:24:23.947Z", - "initialState": { - "seed": "e2e-1776468262647" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-food-truck-1 -> slot 0" - } - }, - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-event", - "detail": "evt-grand-opening-19" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "action", - "turn": 3, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 3 - }, - { - "type": "action", - "turn": 4, - "action": { - "type": "buy-event", - "detail": "evt-wellness-fair-22" - } - }, - { - "type": "turn-end", - "turn": 4 - }, - { - "type": "action", - "turn": 5, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 5 - }, - { - "type": "action", - "turn": 6, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 6 - }, - { - "type": "game-end", - "turn": 6, - "finalScore": 39, - "result": { - "outcome": "loss" - }, - "endReason": "bankruptcy" - } - ], - "results": { - "finalScore": 39, - "result": "loss", - "endReason": "bankruptcy" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.json deleted file mode 100644 index 45d015b..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776468262647.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776468262647", - "startedAt": "2026-04-17T23:24:23.943Z", - "endedAt": "2026-04-17T23:24:23.947Z", - "totalTurns": 6, - "result": "loss", - "endReason": "bankruptcy", - "finalScore": 39, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-food-truck-1 -> slot 0" - }, - { - "type": "buy-event", - "detail": "evt-grand-opening-19" - } - ], - "income": 0, - "incident": "Food Critic Visit", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 4, - "score": 23, - "gridOccupied": 1 - }, - { - "turn": 2, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 0, - "incident": "Power Outage", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 4, - "score": 21, - "gridOccupied": 1 - }, - { - "turn": 3, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 0, - "incident": "Viral Review", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 5, - "score": 28, - "gridOccupied": 1 - }, - { - "turn": 4, - "actions": [ - { - "type": "buy-event", - "detail": "evt-wellness-fair-22" - } - ], - "income": 0, - "incident": "Pipe Burst", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 5, - "score": 25, - "gridOccupied": 1 - }, - { - "turn": 5, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 0, - "incident": "Community Award", - "incidentQueueSize": 2, - "coinsAfter": 0, - "reputationAfter": 8, - "score": 40, - "gridOccupied": 1 - }, - { - "turn": 6, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 0, - "incident": "Rainy Day", - "incidentQueueSize": 2, - "coinsAfter": -1, - "reputationAfter": 8, - "score": 39, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.converted.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.converted.json deleted file mode 100644 index 147d05a..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.converted.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-18T13:24:21.322Z", - "endedAt": "2026-04-18T13:24:21.328Z", - "initialState": { - "seed": "e2e-1776518659739" - }, - "events": [ - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-business", - "detail": "biz-florist-1 -> slot 0" - } - }, - { - "type": "action", - "turn": 1, - "action": { - "type": "buy-event", - "detail": "evt-festival-2" - } - }, - { - "type": "turn-end", - "turn": 1 - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "skip", - "detail": "No affordable actions" - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "action", - "turn": 3, - "action": { - "type": "buy-event", - "detail": "evt-wellness-fair-21" - } - }, - { - "type": "turn-end", - "turn": 3 - }, - { - "type": "game-end", - "turn": 3, - "finalScore": 12, - "result": { - "outcome": "loss" - }, - "endReason": "bankruptcy" - } - ], - "results": { - "finalScore": 12, - "result": "loss", - "endReason": "bankruptcy" - } -} \ No newline at end of file diff --git a/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.json b/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.json deleted file mode 100644 index e2ada48..0000000 --- a/tmp/test-e2e-main-street/main-street-demo-e2e-1776518659739.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "game": "main-street", - "version": "1.0.0", - "seed": "e2e-1776518659739", - "startedAt": "2026-04-18T13:24:21.322Z", - "endedAt": "2026-04-18T13:24:21.328Z", - "totalTurns": 3, - "result": "loss", - "endReason": "bankruptcy", - "finalScore": 12, - "turns": [ - { - "turn": 1, - "actions": [ - { - "type": "buy-business", - "detail": "biz-florist-1 -> slot 0" - }, - { - "type": "buy-event", - "detail": "evt-festival-2" - } - ], - "income": 0, - "incident": "Noise Complaint", - "incidentQueueSize": 2, - "coinsAfter": 1, - "reputationAfter": 2, - "score": 11, - "gridOccupied": 1 - }, - { - "turn": 2, - "actions": [ - { - "type": "skip", - "detail": "No affordable actions" - } - ], - "income": 0, - "incident": "Rainy Day", - "incidentQueueSize": 2, - "coinsAfter": 3, - "reputationAfter": 3, - "score": 18, - "gridOccupied": 1 - }, - { - "turn": 3, - "actions": [ - { - "type": "buy-event", - "detail": "evt-wellness-fair-21" - } - ], - "income": 0, - "incident": "Tax Audit", - "incidentQueueSize": 2, - "coinsAfter": -3, - "reputationAfter": 3, - "score": 12, - "gridOccupied": 1 - } - ] -} \ No newline at end of file diff --git a/tmp/test-e2e-transcripts/main-street-sample.json b/tmp/test-e2e-transcripts/main-street-sample.json deleted file mode 100644 index 881b8a0..0000000 --- a/tmp/test-e2e-transcripts/main-street-sample.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "version": 1, - "gameType": "main-street", - "startedAt": "2026-04-18T13:24:21.045Z", - "endedAt": null, - "initialState": { - "seed": "e2e-sample-0", - "streetGrid": [ - null, - null, - null, - null, - null, - null, - null, - null, - null, - null - ], - "market": { - "businesses": [], - "investments": [] - } - }, - "events": [ - { - "type": "ai-action", - "turn": 1, - "strategy": "Greedy", - "action": { - "type": "buy-business", - "cardId": "cafe-1", - "slotIndex": 2 - } - }, - { - "type": "hint", - "turn": 2, - "recommendedAction": { - "type": "buy-business", - "cardId": "bakery-3", - "slotIndex": 4 - }, - "rationale": "Buy Bakery for synergy" - }, - { - "type": "action", - "turn": 2, - "action": { - "type": "buy-business", - "cardId": "bakery-3", - "slotIndex": 4 - } - }, - { - "type": "undo", - "turn": 2, - "reversedAction": { - "type": "buy-business", - "cardId": "bakery-3", - "slotIndex": 4 - } - }, - { - "type": "redo", - "turn": 2, - "reappliedAction": { - "type": "buy-business", - "cardId": "bakery-3", - "slotIndex": 4 - } - }, - { - "type": "turn-end", - "turn": 2 - }, - { - "type": "game-end", - "turn": 10, - "result": { - "outcome": "win" - }, - "finalScore": 150 - } - ], - "results": null -} \ No newline at end of file