import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { RomReader } from "../src/game/rom/reader"; import { PokemonData } from "../src/game/rom/pokemon"; import { emptyScriptState, type ScriptEffect } from "../src/game/scripts/interpreter"; import { defaultTextContext, readText } from "../src/game/scripts/text"; import { C } from "../src/game/battle/constants"; import { slotMachine, initBias, SLOTS_NO_BIAS, SLOTS_SEVEN, makeReel, spinAllReels, ReelAction, type SlotsSession, } from "../src/game/scripts/slot-machine"; import { cardFlip, shuffleDeck, cardFlipPayout, moveCardFlipCursor, } from "../src/game/scripts/card-flip"; import type { SpecialContext } from "../src/game/scripts/special-context"; import { CrystalAssets } from "../src/game/rom/assets"; import { GameWorld } from "../src/game/world/world"; import { settle } from "./world-driver"; import { JOHTO } from "../src/game/rom/profile"; import { SCREEN_WIDTH } from "../src/game/scripts/screen"; const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; /** A simple deterministic byte stream, like the other seeded-RNG tests. */ function seededRandom(seed: number) { let s = seed >>> 0; return () => { s = (s * 1103515245 + 12345) >>> 0; return (s >>> 16) & 0xff; }; } /** A minimal SpecialContext over the real ROM: enough state for the Game * Corner's coin checks and text, built the same way interpreter.ts's own * `specialContext` is. */ function makeContext( reader: RomReader, data: PokemonData, random: () => number, coins: number, ): SpecialContext { const state = emptyScriptState(); state.coins = coins; state.bag.keyItems.push(C.COIN_CASE); const pages: string[] = []; const strings: Record = {}, numbers: Record = {}; // Merge the mutable string/number buffers into the text context, as // interpreter.ts's own `text()` does with `this.strings`/`this.numbers`. const text = () => ({ ...defaultTextContext("CHRIS"), strings, numbers }); return { reader, data, state, strings, numbers, text, random, player: () => ({ id: 1, name: "CHRIS" }), today: () => 0, weekday: () => 0, write: (address: number) => { const content = readText(reader, address, text()); if (content) pages.push(...content.split("\n\n")); }, take: () => pages.splice(0, pages.length), flush: function* () { if (pages.length) yield { kind: "text", pages: pages.splice(0, pages.length) } as ScriptEffect; }, selectMon: function* () { return -1; }, }; } type Answer = number | string | undefined; /** Drive a Game Corner generator to completion: `screens` answers "screen" * effects in order (defaulting to A once exhausted, which both confirms a * choice and passes every non-interactive realtime frame); `plays` answers * "yesno" effects in order (defaulting to YES); the bet menu always picks * `bet` coins. */ function play( generator: Generator, options: { bet?: number; screens?: string[]; plays?: boolean[] } = {}, ): T { const screens = [...(options.screens ?? [])]; const plays = [...(options.plays ?? [])]; let result = generator.next(); let steps = 0; while (!result.done) { if (++steps > 300_000) throw new Error("The Game Corner session did not finish."); const effect = result.value; let answer: Answer; if (effect.kind === "menu") answer = options.bet !== undefined ? 3 - options.bet : 1; else if (effect.kind === "yesno") answer = (plays.length ? plays.shift() : true) ? 1 : 0; else if (effect.kind === "screen") answer = screens.length ? screens.shift() : "a"; result = generator.next(answer); } return result.value; } /** ChooseCard_HandleJoypad's board (CollapseCursorPosition: Y*6+X), moved * one cell per press as this port's `moveCardFlipCursor` does. */ function moveButtons(fromX: number, fromY: number, toX: number, toY: number): string[] { const buttons: string[] = []; for (let i = 0; i < Math.abs(toY - fromY); i++) buttons.push(toY > fromY ? "down" : "up"); for (let i = 0; i < Math.abs(toX - fromX); i++) buttons.push(toX > fromX ? "right" : "left"); return buttons; } describe("CardFlip_CheckWinCondition's paytable", () => { it("pays the species pair, exact species, level pair, exact level and exact card cells", () => { // Card 0: species 0 (Pikachu), level 0 ("1"). expect(cardFlipPayout(0, 0)).toBe(0); // row 0's outer cells always lose expect(cardFlipPayout(1, 0)).toBe(0); expect(cardFlipPayout(2, 0)).toBe(6); // Pikachu/Jigglypuff pair expect(cardFlipPayout(4, 0)).toBe(0); // Poliwag/Oddish pair does not match expect(cardFlipPayout(8, 0)).toBe(12); // row 1, exact species (Pikachu) expect(cardFlipPayout(12, 0)).toBe(9); // row 2, level pair ("1"-"2") expect(cardFlipPayout(13, 0)).toBe(18); // row 2, exact level ("1") expect(cardFlipPayout(14, 0)).toBe(72); // row 2, exact card (Pikachu "1") expect(cardFlipPayout(14, 1)).toBe(0); // the same cell loses for a different card // Card 23: species 3 (Oddish), level 5 ("6"). expect(cardFlipPayout(5, 23)).toBe(6); // Poliwag/Oddish pair expect(cardFlipPayout(11, 23)).toBe(12); // row 1, exact species (Oddish) expect(cardFlipPayout(42, 23)).toBe(9); // row 6/7's shared level pair ("5"-"6") expect(cardFlipPayout(43, 23)).toBe(18); // row 7, exact level ("6") expect(cardFlipPayout(47, 23)).toBe(72); // row 7, exact card (Oddish "6") }); }); describe("ChooseCard_HandleJoypad's exact navigation", () => { const at = (cursorX: number, cursorY: number) => ({ cursorX, cursorY }); it("snaps left/right to the nearest even cell in the Pokémon-pair row (Y=0)", () => { const session = at(5, 0); moveCardFlipCursor(session, "left"); // 5 & 0xe = 4, which is not below 3, so it becomes 4 - 2. expect(session).toEqual({ cursorX: 2, cursorY: 0 }); const session2 = at(3, 0); moveCardFlipCursor(session2, "left"); // 3 & 0xe = 2, which is below 3, so the cursor jumps to the mon-group // junction cell instead of stopping on the pair row. expect(session2).toEqual({ cursorX: 1, cursorY: 2 }); const session3 = at(4, 0); moveCardFlipCursor(session3, "right"); // 4 & 0xe = 4, which is not below 4, so right is refused (but the // mask is still applied, a kept quirk of the original). expect(session3).toEqual({ cursorX: 4, cursorY: 0 }); }); it("snaps to the mon-group junction from the Pokémon-group row (Y=1) too, without masking", () => { const session = at(2, 1); moveCardFlipCursor(session, "left"); expect(session).toEqual({ cursorX: 1, cursorY: 2 }); const session2 = at(4, 1); moveCardFlipCursor(session2, "left"); expect(session2).toEqual({ cursorX: 3, cursorY: 1 }); }); it("snaps up/down to the nearest even cell in the number-pair column (X=0), or to the number-group junction", () => { const session = at(0, 5); moveCardFlipCursor(session, "up"); expect(session).toEqual({ cursorX: 0, cursorY: 2 }); const session2 = at(0, 3); moveCardFlipCursor(session2, "up"); expect(session2).toEqual({ cursorX: 2, cursorY: 1 }); const session3 = at(0, 4); moveCardFlipCursor(session3, "down"); expect(session3).toEqual({ cursorX: 0, cursorY: 6 }); const session4 = at(0, 6); moveCardFlipCursor(session4, "down"); expect(session4).toEqual({ cursorX: 0, cursorY: 6 }); }); it("moves one cell at a time everywhere else, refusing past either edge", () => { const session = at(3, 5); moveCardFlipCursor(session, "left"); expect(session).toEqual({ cursorX: 2, cursorY: 5 }); moveCardFlipCursor(session, "right"); moveCardFlipCursor(session, "right"); expect(session).toEqual({ cursorX: 4, cursorY: 5 }); moveCardFlipCursor(session, "right"); expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // the last column moveCardFlipCursor(session, "right"); expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // refused past it const bottom = at(2, 7); moveCardFlipCursor(bottom, "down"); expect(bottom).toEqual({ cursorX: 2, cursorY: 7 }); // the last row, refused }); }); describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const reader = new RomReader(bytes); const data = new PokemonData(reader); describe("Slots_InitBias", () => { it("re-rolls a fresh bias every round, except when it is already SLOTS_SEVEN", () => { // Slots_InitBias's own "and a; ret z" guard: a seven bias is never // re-rolled, keeping last round's streak untouched. expect(initBias({ reader, random: () => 0 }, SLOTS_SEVEN, false)).toBe(SLOTS_SEVEN); // 255 falls past every real entry, into the table's own 100% "no // bias" slice for both tables. expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, false)).toBe(SLOTS_NO_BIAS); expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, true)).toBe(SLOTS_NO_BIAS); }); it("the lucky machine (wScriptVar true) widens the cherry slice well past the normal table's", () => { // Normal's cumulative cherry threshold is 19 percent (48 of 255); // Lucky's is 31 percent + 1 (80 of 255). A byte of 60 falls past // Normal's cherry slice (into "no bias") but still inside Lucky's. const byte = 60; const normal = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, false); const lucky = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, true); expect(normal).toBe(SLOTS_NO_BIAS); expect(lucky).not.toBe(SLOTS_NO_BIAS); }); }); describe("the Golem and Chansey/egg replay (reel 3's real frame-by-frame hops)", () => { const sevenEverywhere = Array(18).fill(SLOTS_SEVEN); // Positions 0 and 15 (the wraparound repeat of 0) are the seven; every // other position (including the reel's own starting position, 14) is // not, so InitGolem's own first check does not already count as a win. const oneSevenReel3 = [0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0, 4, 4]; // Every position but the reel's own starting one (14) is the seven, so // InitChansey's own first check does not already count as a win, but // wherever DropReel's 16-rate spin leaves it afterward still does. const allButStartingSeven = Array(18).fill(SLOTS_SEVEN).map((value, i) => (i === 14 ? 4 : value)); function newSession(reel3Tilemap: readonly number[], bias: number): SlotsSession { return { reels: [makeReel(sevenEverywhere), makeReel(sevenEverywhere), makeReel(reel3Tilemap)], bias, bet: 1, // only the middle line, so reel1/reel2's own middle entry decides the match matched: -1, firstTwoReelsMatching: true, firstTwoReelsMatchingSevens: true, buildingMatch: 0, payout: 0, delay: 0, phase: 0, joypadSumA: false, flashFrame: 0, resultLines: [], }; } function runUntilStopped(session: SlotsSession) { const host = { reader, random: () => 0 }; for (let tick = 0; tick < 5000 && session.reels[2].action !== ReelAction.DoNothing; tick++) spinAllReels(session, host); if (session.reels[2].action !== ReelAction.DoNothing) throw new Error("Reel 3 never stopped."); } it("Golem's replay advances reel 3 by exactly the probe's real hop count, not the probe's own discarded position", () => { const session = newSession(oneSevenReel3, SLOTS_SEVEN); const reel3 = session.reels[2]; reel3.action = ReelAction.InitGolem; const startPosition = reel3.position; runUntilStopped(session); // Slots_GetNumberOfGolems' seven-bias probe (reel.position starts at // 14): the very first incremented position (raw 15, the tilemap's // own wraparound repeat of index 0) already matches, so the probe // reports one hop, and reel 3's real position should have advanced // by exactly that one hop for real. expect(reel3.position).toBe((startPosition + 1) % 15); expect(session.matched).toBe(SLOTS_SEVEN); // Removed (wSlotsDelay 2); a removed struct is still drawn that tick. expect(session.golem?.removed ?? true).toBe(true); }); it("Chansey's replay walks in, lays the egg and lets DropReel stop reel 3 on seven", () => { const session = newSession(allButStartingSeven, SLOTS_NO_BIAS); // Chansey always targets seven, regardless of bias const reel3 = session.reels[2]; reel3.action = ReelAction.InitChansey; runUntilStopped(session); expect(session.matched).toBe(SLOTS_SEVEN); // The egg always finishes its bounce (and clears itself) before // DropReel's own match check runs, whether that check succeeds or // (EggAgain) loops back through Chansey for another egg. expect(session.egg?.removed ?? true).toBe(true); }); }); it("spins through a seeded random source, over several rounds, to a payout", () => { const context = makeContext(reader, data, seededRandom(7), 150); const rounds = 30; const plays = Array(rounds - 1).fill(true).concat([false]); play(slotMachine(context, true), { bet: 3, plays }); // 30 rounds at 3 coins each (90 total) must have paid out at least // once for the balance to end this high; a run that only ever lost // could not exceed 150 - 90. expect(context.state.coins).toBeGreaterThan(150 - rounds * 3); }); it("the lucky machine plays a full round without refusing", () => { const context = makeContext(reader, data, seededRandom(21), 50); play(slotMachine(context, true), { bet: 1, plays: [false] }); expect(context.state.coins).toBeGreaterThanOrEqual(0); expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS); }); it("refuses to start with no coins, and takes nothing", () => { const context = makeContext(reader, data, seededRandom(1), 0); play(slotMachine(context, false), {}); expect(context.state.coins).toBe(0); }); it("refuses to start without a Coin Case, even with coins", () => { const context = makeContext(reader, data, seededRandom(1), 50); context.state.bag.keyItems = []; play(slotMachine(context, false), {}); expect(context.state.coins).toBe(50); }); it("the coin case never exceeds 9999 while paying out", () => { const context = makeContext(reader, data, seededRandom(13), 9990); const rounds = 40; const plays = Array(rounds - 1).fill(true).concat([false]); play(slotMachine(context, true), { bet: 3, plays }); expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS); }); it("quitting the bet menu (B) ends the game without spending coins", () => { const context = makeContext(reader, data, seededRandom(9), 20); const generator = slotMachine(context, false); const first = generator.next(); expect(first.done).toBe(false); const second = generator.next(-1); // B cancels Slots_AskBet's menu expect(second.done).toBe(true); expect(context.state.coins).toBe(20); }); }); describe.skipIf(!existsSync(romPath))("Card Flip with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const reader = new RomReader(bytes); const data = new PokemonData(reader); it("a round the player bets on the dealt card's exact species wins", () => { const deck = shuffleDeck({ random: seededRandom(5) }); const species = deck[0] & 3; const context = makeContext(reader, data, seededRandom(5), 50); const buttons = ["a", ...moveButtons(2, 2, 2 + species, 1), "a"]; play(cardFlip(context), { screens: buttons, plays: [true, false] }); expect(context.state.coins).toBe(50 - 3 + 12); }); it("a round the player bets on the wrong exact species and loses", () => { // ChooseCard_HandleJoypad's own snapping keeps the cursor off the // board's four truly "impossible" cells (row 0-1, col 0-1) from any // reachable approach, so this port's exact translation of it cannot // reach one either; betting the wrong exact species is an equally // sure loss that stays reachable. const deck = shuffleDeck({ random: seededRandom(6) }); const wrongSpecies = ((deck[0] & 3) + 1) % 4; const context = makeContext(reader, data, seededRandom(6), 50); const buttons = ["a", ...moveButtons(2, 2, 2 + wrongSpecies, 1), "a"]; play(cardFlip(context), { screens: buttons, plays: [true, false] }); expect(context.state.coins).toBe(50 - 3); }); it("refuses to start with no coins, and takes nothing", () => { const context = makeContext(reader, data, seededRandom(1), 0); play(cardFlip(context), {}); expect(context.state.coins).toBe(0); }); it("refuses to start without a Coin Case, even with coins", () => { const context = makeContext(reader, data, seededRandom(1), 50); context.state.bag.keyItems = []; play(cardFlip(context), {}); expect(context.state.coins).toBe(50); }); it("the coin case never exceeds 9999, even betting the dealt card's exact match (72 coins)", () => { const deck = shuffleDeck({ random: seededRandom(5) }); const card = deck[0]; const level = card >> 2, species = card & 3; const pairIndex = Math.floor(level / 2), isPairRow = level % 2 === 0; const row = 2 + pairIndex * 2 + (isPairRow ? 0 : 1); const col = 2 + species; const context = makeContext(reader, data, seededRandom(5), 9995); const buttons = ["a", ...moveButtons(2, 2, col, row), "a"]; play(cardFlip(context), { screens: buttons, plays: [true, false] }); expect(context.state.coins).toBe(C.MAX_COINS); }); it("declining to play with three coins ends the game without spending any", () => { const context = makeContext(reader, data, seededRandom(2), 30); play(cardFlip(context), { plays: [false] }); expect(context.state.coins).toBe(30); }); }); describe.skipIf(!existsSync(romPath))( "the Game Corner's realtime screen through the world, with the local ROM", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const GAME_CORNER = "11:19"; function enterGameCorner(coins: number) { let seed = 17; const game = new GameWorld( new CrystalAssets(bytes), () => new Date("2026-09-22T12:00:00"), () => { seed = (seed * 1103515245 + 12345) >>> 0; return (seed >>> 16) & 0xff; }, ); game.state.coins = coins; game.state.bag.keyItems.push(C.COIN_CASE); return game; } /** Stand beside the object at (x, y), face it and press A, as * tests/goldenrod.test.ts's own `talk` helper does. No settle() runs * after `interact()`: the special's first effect may be a realtime * screen, which the generic turn-based driver cannot answer. */ function talk(game: GameWorld, map: string, x: number, y: number) { const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0], } as const; for (const [face, [dx, dy]] of Object.entries(STEP) as [ keyof typeof STEP, readonly [number, number], ][]) { try { game.warpTo(map, x - dx, y - dy); } catch { continue; } settle(game); game.move(face); settle(game); game.interact(); return; } throw new Error(`No place to talk to ${x},${y} on ${map}`); } /** Turn-based effects (Slots_AskBet's menu, a refusal's text) up to * the first realtime screen, picking the first menu item and YES. */ function driveToScreen(game: GameWorld) { for (let tick = 0; tick < 2000; tick++) { const dialogue = game.dialogue; if (dialogue?.choice?.kind === "screen") return; if (dialogue) { const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice) game.choose(dialogue.choice.kind === "yesno" ? 1 : 0); else if (last && dialogue.holdTicks !== undefined) game.tick(); else game.advanceDialogue(); } else if (game.busy) game.tick(); else return; } throw new Error("Did not reach a screen choice."); } /** The realtime screen itself, holding A every fourth tick (as a * player holding the button would, since setButton only reports * presses) until Slots_AskPlayAgain's yes/no replaces it. */ function driveRealtimeToPlayAgain(game: GameWorld) { for (let tick = 0; tick < 20_000; tick++) { const choice = game.dialogue?.choice; if (choice?.kind === "yesno") return; if (choice?.kind === "screen" && (choice as { realtime?: boolean }).realtime) { if (tick % 4 === 0) game.bufferScreenButton("a"); game.tick(); continue; } if (game.dialogue) { const dialogue = game.dialogue; const last = dialogue.index === dialogue.pages.length - 1; if (last && dialogue.choice) game.choose(dialogue.choice.kind === "menu" ? 0 : 1); else if (last && dialogue.holdTicks !== undefined) game.tick(); else game.advanceDialogue(); } else game.tick(); } throw new Error("Did not reach the play-again question."); } it("opens the lucky slot machine as a realtime screen, driven through world.tick", () => { const game = enterGameCorner(50); // GoldenrodGameCornerLuckySlotsMachineScript's fixed tile. talk(game, GAME_CORNER, 7, 7); driveToScreen(game); const choice = game.dialogue?.choice; expect(choice?.kind).toBe("screen"); expect((choice as { realtime?: boolean }).realtime).toBe(true); driveRealtimeToPlayAgain(game); expect(game.dialogue?.choice?.kind).toBe("yesno"); // A bet was taken (3 coins) before any payout; a win would only add // to the balance, never make it exceed the starting amount after // the bet. expect(game.state.coins).toBeLessThanOrEqual(50); expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 300); game.choose(0); // Slots_AskPlayAgain: No. settle(game); expect(game.busy).toBe(false); expect(game.dialogue).toBeUndefined(); }); it("opens Card Flip as a realtime screen, driven through world.tick", () => { const game = enterGameCorner(50); talk(game, GAME_CORNER, 13, 7); // GoldenrodGameCornerCardFlipMachineScript // CardFlip.AskPlayWithThree is a plain yes/no before any screen. driveToScreen(game); expect(game.dialogue?.choice?.kind).toBe("screen"); driveRealtimeToPlayAgain(game); expect(game.dialogue?.choice?.kind).toBe("yesno"); expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 72); game.choose(0); // CardFlip.PlayAgain: No. settle(game); expect(game.busy).toBe(false); }); }, ); describe.skipIf(!existsSync(romPath))( "the port's own static tilemaps match the ROM's data, copied the asm's way", () => { const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array(); const reader = new RomReader(bytes); const data = new PokemonData(reader); /** Drive a Game Corner generator to its first "screen" effect that * carries sprites (PlaceYourBet for Card Flip, any spinning frame for * the slot machine; ChooseACard's own screen has none), answering * every other effect the same way `play()` does. A regression here * would mean the board/frame these screens draw moved or changed * shape without anyone noticing a pixel. */ function firstScreenWithSprites(generator: Generator) { let result = generator.next(); let steps = 0; while (!result.done) { if (++steps > 2000) throw new Error("Did not reach a screen with sprites."); const effect = result.value; let answer: Answer; if (effect.kind === "menu") answer = 1; // bet 2 else if (effect.kind === "yesno") answer = 1; // yes else if (effect.kind === "screen") { if (effect.screen.sprites && effect.screen.sprites.length > 0) return effect.screen; answer = "a"; } result = generator.next(answer); } throw new Error("The generator finished before a screen with sprites appeared."); } it("the slot machine's static frame matches SlotsTilemap's own flat 20x12 copy (InitGFX's CopyBytes)", () => { const context = makeContext(reader, data, seededRandom(11), 50); const screen = firstScreenWithSprites(slotMachine(context, false)); const ground = Array.from(reader.slice(JOHTO.SlotsTilemap, SCREEN_WIDTH * 12)); const port = Array.from(screen.map.slice(0, SCREEN_WIDTH * 12)); // Mask out the two cells PrintNum/illuminateBetLights redraw every // tick (the CREDIT/PAYOUT digits at row 1, and the bet-light column // at x=3/16 rows 2-7): everywhere else must still match the ROM's // own static SlotsTilemap bytes exactly. for (let col = 5; col < 9; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col]; for (let col = 11; col < 15; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col]; for (let row = 2; row < 8; row++) for (const col of [3, 16]) port[row * SCREEN_WIDTH + col] = ground[row * SCREEN_WIDTH + col]; expect(port).toEqual(ground); }); it("Card Flip's paytable board matches CardFlipTilemap's own 12-row by 11-column copy (CardFlip_InitTilemap's `lb bc, 12, 11`)", () => { const context = makeContext(reader, data, seededRandom(11), 50); const screen = firstScreenWithSprites(cardFlip(context)); const ground = reader.slice(JOHTO.CardFlipTilemap, 12 * 11); const board: number[] = []; for (let row = 0; row < 12; row++) for (let col = 0; col < 11; col++) board.push(screen.map[row * SCREEN_WIDTH + 9 + col]); expect(board).toEqual(Array.from(ground)); // Columns 0-1 (left of the dealt-card box, which PlaceYourBet's own // `cards: "chosen"` view draws at x=2..6) stay untouched background, // never leaked-into by a row/column dimension mismatch. for (let row = 0; row < 12; row++) for (let col = 0; col < 2; col++) expect(screen.map[row * SCREEN_WIDTH + col]).toBe(0x29); }); }, );