import { test, expect } from "@playwright/test"; import { existsSync } from "node:fs"; import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture"; test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols."); test("CardFlip_UpdateCursorOAM matches cursorSprites for every cursor position", async ({ page }) => { const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), cgb: at("hCGB"), cursorX: at("wCardFlipCursorX"), cursorY: at("wCardFlipCursorY"), shadowOAM: at("wShadowOAMSprite00"), }; const updateCursorOAM = routine("CardFlip_UpdateCursorOAM"); await openReference(page); const result = await page.evaluate(async ({ a, updateCursorOAM }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { cursorSprites } = await import("/src/game/scripts/card-flip.ts"); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const read = (p: number) => reference.readMemory(p); // DI; SP=$c7fe; CGB mode so the blink check is skipped, as the port's // own comment assumes; CALL CardFlip_UpdateCursorOAM; sentinel. const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, updateCursorOAM.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0xcd, updateCursorOAM.address & 255, updateCursorOAM.address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe, ]; const OBJ_BASE = 0x100; // ScreenSprite.x/y are plain screen pixels (tile-screen.ts draws them // with the same col*8/row*8 convention as background tiles); hardware // OAM bytes are 8 (x) and 16 (y) beyond that (see unown-puzzle.ts's own // "OAM coordinates are 16 (y) and 8 (x) beyond the screen's"). const OAM_Y_OFFSET = 16, OAM_X_OFFSET = 8; const failures: unknown[] = []; let compared = 0; // Every cell ChooseCard_HandleJoypad's grid can address (single tiles, // number/Pokémon rows, columns and the paired groups), all 48 of them. for (let cursorY = 0; cursorY < 8; cursorY++) for (let cursorX = 0; cursorX < 6; cursorX++) { write(a.cursorX, cursorX); write(a.cursorY, cursorY); write(a.cgb, 1); write(0xc0f0, 0); program.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw Error("CardFlip_UpdateCursorOAM did not return"); const original: number[] = []; for (let i = 0; i < 40 * 4; i++) original.push(read(a.shadowOAM + i)); const sprites = cursorSprites(assets.reader, cursorX, cursorY); const native = new Array(160).fill(0); sprites.forEach((sprite, i) => { native[i * 4 + 0] = (sprite.y + OAM_Y_OFFSET) & 0xff; native[i * 4 + 1] = (sprite.x + OAM_X_OFFSET) & 0xff; native[i * 4 + 2] = (sprite.tile - OBJ_BASE) & 0xff; native[i * 4 + 3] = (sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0) | (sprite.behind ? 0x80 : 0); }); if (original.some((v, i) => v !== native[i])) failures.push({ cursorX, cursorY, count: sprites.length, original, native }); compared++; } reference.dispose(); return { compared, failures: failures.slice(0, 3), count: failures.length }; }, { a, updateCursorOAM }); expect(result.compared).toBe(48); expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); }); test("Slots_UpdateReelPositionAndOAM/.SpinReel match reelSprites for reel positions and spin distances", async ({ page }) => { const { at, routine } = loadSymbols(); // RomReader addresses (as JOHTO.Reel1Tilemap etc. are generated) are flat // ROM-file offsets, bank * $4000 + (address - $4000); CPU addresses // written into the reel struct for the ROM's own code to dereference are // the plain 16-bit address instead. const flat = ({ bank, address }: { bank: number; address: number }) => bank * 0x4000 + (address - 0x4000); const reel1Tilemap = routine("Reel1Tilemap"), reel2Tilemap = routine("Reel2Tilemap"), reel3Tilemap = routine("Reel3Tilemap"); const a = { romBank: at("hROMBank"), wReel1: at("wReel1"), wReel2: at("wReel2"), wReel3: at("wReel3"), reel1Tilemap: reel1Tilemap.address, reel2Tilemap: reel2Tilemap.address, reel3Tilemap: reel3Tilemap.address, reel1TilemapFlat: flat(reel1Tilemap), reel2TilemapFlat: flat(reel2Tilemap), reel3TilemapFlat: flat(reel3Tilemap), shadow16: at("wShadowOAMSprite16"), shadow24: at("wShadowOAMSprite24"), shadow32: at("wShadowOAMSprite32"), }; const updateReel = routine("Slots_UpdateReelPositionAndOAM"); const spinReel = routine("Slots_SpinReels.SpinReel"); await openReference(page); const result = await page.evaluate(async ({ a, updateReel, spinReel }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { readReelTilemap, makeReel, reelSprites } = await import("/src/game/scripts/slot-machine.ts"); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const read = (p: number) => reference.readMemory(p); // struct fields (engine/games/slot_machine.asm): REEL_ACTION=0, // REEL_TILEMAP_ADDR=1(w), REEL_POSITION=3, REEL_SPIN_DISTANCE=4, // REEL_SPIN_RATE=5, REEL_OAM_ADDR=6(w), REEL_X_COORD=8. const call = (bank: number, address: number, bc: number) => { const program = [ 0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, 0x01, bc & 255, bc >> 8, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe, ]; write(0xc0f0, 0); program.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw Error("reel routine did not return"); }; const SYMBOL_BASE_TILE = 0x25; // .LoadOAM writes one hardware 8x16 sprite per column (2 per row, tile // auto-pairing its own bottom half); reelSprites instead draws each as // two separate 8x8 ScreenSprites (dy 0 and 1). Keep only the dy=0 // (top) halves, at the even positions of each row's four pushes, to // compare against the ROM's own 8 real OAM entries. const toNative = (sprites: { x: number; y: number; tile: number; palette: number }[]) => { const tops = sprites.filter((_, i) => i % 2 === 0); const oam = new Array(32).fill(0); tops.forEach((sprite, i) => { oam[i * 4 + 0] = (sprite.y + 16) & 0xff; oam[i * 4 + 1] = (sprite.x + 8) & 0xff; oam[i * 4 + 2] = (sprite.tile - SYMBOL_BASE_TILE) & 0xff; oam[i * 4 + 3] = (sprite.palette & 7) | 0x80; // B_OAM_PRIO, always set }); return oam; }; const defs = [ { wReel: a.wReel1, tilemapAddr: a.reel1Tilemap, tilemapFlat: a.reel1TilemapFlat, shadow: a.shadow16, xTile: 6 }, { wReel: a.wReel2, tilemapAddr: a.reel2Tilemap, tilemapFlat: a.reel2TilemapFlat, shadow: a.shadow24, xTile: 10 }, { wReel: a.wReel3, tilemapAddr: a.reel3Tilemap, tilemapFlat: a.reel3TilemapFlat, shadow: a.shadow32, xTile: 14 }, ]; const failures: unknown[] = []; let compared = 0; for (const def of defs) { const tilemap = readReelTilemap(assets.reader, def.tilemapFlat); for (const position of [0, 5, 14]) { write(def.wReel + 1, def.tilemapAddr & 255); write(def.wReel + 2, def.tilemapAddr >> 8); write(def.wReel + 3, position); write(def.wReel + 6, def.shadow & 255); write(def.wReel + 7, def.shadow >> 8); write(def.wReel + 8, def.xTile * 8); call(updateReel.bank, updateReel.address, def.wReel); const original: number[] = []; for (let i = 0; i < 32; i++) original.push(read(def.shadow + i)); const reel = makeReel(tilemap); reel.position = position; const native = toNative(reelSprites(reel, def.xTile)); if (original.some((v, i) => v !== native[i])) failures.push({ reel: def.xTile, position, original, native }); compared++; } } // Spin distance: one real .SpinReel tick from a freshly redrawn (subY=0) // reel 1, with the low nibble of the resulting distance left nonzero so // it takes the partial, Y-bytes-only shift path instead of a full // redraw. for (const rate of [5, 11]) { write(a.wReel1 + 0, 0); // REEL_ACTION_DO_NOTHING (so .SpinReel's own ReelActionJumptable call is a no-op) write(a.wReel1 + 1, a.reel1Tilemap & 255); write(a.wReel1 + 2, a.reel1Tilemap >> 8); write(a.wReel1 + 3, 5); write(a.wReel1 + 6, a.shadow16 & 255); write(a.wReel1 + 7, a.shadow16 >> 8); write(a.wReel1 + 8, 6 * 8); call(updateReel.bank, updateReel.address, a.wReel1); write(a.wReel1 + 4, 0); // REEL_SPIN_DISTANCE write(a.wReel1 + 5, rate); // REEL_SPIN_RATE call(spinReel.bank, spinReel.address, a.wReel1); const original: number[] = []; for (let i = 0; i < 32; i++) original.push(read(a.shadow16 + i)); const tilemap = readReelTilemap(assets.reader, a.reel1TilemapFlat); const reel = makeReel(tilemap); reel.position = 5; reel.spinDistance = rate; const native = toNative(reelSprites(reel, 6)); if (original.some((v, i) => v !== native[i])) failures.push({ rate, original, native }); compared++; } reference.dispose(); return { compared, failures: failures.slice(0, 3), count: failures.length }; }, { a, updateReel, spinReel }); expect(result.compared).toBe(11); expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); }); test("the sprite-animation engine's Golem, Chansey and Egg OAM match golemSprites/chanseySprites/eggSprites", async ({ page }) => { const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), structs: at("wSpriteAnimationStructs"), count: at("wSpriteAnimCount"), oamAddr: at("wCurSpriteOAMAddr"), shadow: at("wShadowOAM"), globalX: at("wGlobalAnimXOffset"), globalY: at("wGlobalAnimYOffset"), }; const initStruct = routine("_InitSpriteAnimStruct"); const updateFrame = routine("UpdateAnimFrame"); const animateGolem = routine("Slots_AnimateGolem"); const animateChansey = routine("Slots_AnimateChansey"); const animateEgg = routine("SpriteAnimFunc_SlotsChanseyEgg"); await openReference(page); const result = await page.evaluate(async ({ a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const { initGolem, stepGolem, golemSprites, initChansey, stepChansey, chanseySprites, stepEgg, eggSprites } = await import( "/src/game/scripts/slot-machine.ts" ); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const read = (p: number) => reference.readMemory(p); const call = (bank: number, address: number, program: number[]) => { const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; write(0xc0f0, 0); full.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return"); }; // SPRITEANIMSTRUCT field offsets (constants/sprite_anim_constants.asm). const VAR3 = 14; const failures: unknown[] = []; let compared = 0; function run(animId: number, d: number, e: number, animate: { bank: number; address: number }, extra?: () => void) { for (let i = 0; i < 160; i++) write(a.structs + i, 0); write(a.count, 0); write(a.oamAddr, 0); for (let i = 0; i < 32; i++) write(a.shadow + i, 0); // wGlobalAnimXOffset/YOffset are an untracked engine-global leftover, // like the OBJ palette applySlotsAttrs' own doc comment describes; // UpdateAnimFrame adds them into every OAM byte it writes, so pin // them at 0 here to compare the engine's own per-sprite math cleanly. write(a.globalX, 0); write(a.globalY, 0); // LD DE, de; LD A, animId; CALL _InitSpriteAnimStruct. call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]); if (extra) extra(); // The struct lands at wSpriteAnimationStructs (the only free slot); // CALL animate(bc=struct); CALL UpdateAnimFrame(bc=struct) to draw it. call(animate.bank, animate.address, [0x01, a.structs & 255, a.structs >> 8]); call(updateFrame.bank, updateFrame.address, [0x01, a.structs & 255, a.structs >> 8]); const oam: number[] = []; for (let i = 0; i < 32; i++) oam.push(read(a.shadow + i)); return oam; } // Bit 4 of the attribute byte is set in several of the ROM's own OAM // tables (e.g. $15, $16) but is not a real CGB OBJ attribute bit // (palette 0-2, bank 3, XFLIP 5, YFLIP 6, PRIO 7): it has no effect on // hardware, and golemSprites/chanseySprites/eggSprites do not keep it. // Masked out here rather than reproduced. const ATTR_MASK = 0xe7; const toNative = (sprites: { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean }[]) => { const oam = new Array(32).fill(0); sprites.forEach((sprite, i) => { oam[i * 4 + 0] = (sprite.y + 16) & 0xff; oam[i * 4 + 1] = (sprite.x + 8) & 0xff; // Only CREATURE_BASE_TILE (this port's own merged-tile-layout // rebasing) comes off; GOLEM_VTILE/CHANSEY_VTILE/EGG_VTILE already // equal the ROM's own per-animal TILE_ID (GetSpriteAnimVTile). oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff; oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK; }); return oam; }; const maskAttrs = (oam: number[]) => oam.map((v, i) => (i % 4 === 3 ? v & ATTR_MASK : v)); // Golem: depixel 12, 13 -> D=$60, E=$68; VAR3=1 so .init falls through // .retain into .fall with VAR1=$30 (matching initGolem(session, 1)), // computing the first real sine step (one stepGolem tick). { const session: { golem?: never } = {}; initGolem(session as never, 1); const original = maskAttrs(run(6, 0x60, 0x68, animateGolem, () => write(a.structs + VAR3, 1))); stepGolem(session as never, assets.reader); const native = toNative(golemSprites(assets.reader, session.golem!)); if (original.some((v, i) => v !== native[i])) failures.push({ which: "golem", original, native }); compared++; } // Chansey: depixel 12, 0 -> D=$60, E=$00; a single `.walk` tick (the // state Slots_AnimateChansey's own jumptable index 0 starts at). { const session: { chansey?: never } = {}; initChansey(session as never); const original = maskAttrs(run(7, 0x60, 0x00, animateChansey)); stepChansey(session as never); const native = toNative(chanseySprites(assets.reader, session.chansey!)); if (original.some((v, i) => v !== native[i])) failures.push({ which: "chansey", original, native }); compared++; } // Egg: depixel 12, 13, 0, 4 -> D=$60, E=$6c; SpriteAnimFunc_SlotsChanseyEgg // dispatches on JUMPTABLE_INDEX's own parity (not a jumptable), so it // is directly callable the same way. There is no ReelAction_InitChansey- // style helper for just the egg, so its state is built by hand from the // same depixel values (`.spawn_egg`'s own `session.egg = {...}`). { const egg = { counter: 0, xCoord: 0x6c, yOffset: 0 }; const original = maskAttrs(run(8, 0x60, 0x6c, animateEgg)); stepEgg({ egg } as never, assets.reader); const native = toNative(eggSprites(assets.reader, egg)); if (original.some((v, i) => v !== native[i])) failures.push({ which: "egg", original, native }); compared++; } reference.dispose(); return { compared, failures, count: failures.length }; }, { a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg }); expect(result.compared).toBe(3); expect(result, JSON.stringify(result)).toMatchObject({ count: 0 }); }); test("PlaySpriteAnimations' Golem, Chansey and Egg sequences match the native steps frame by frame", async ({ page }) => { const { at, routine } = loadSymbols(); const a = { romBank: at("hROMBank"), structs: at("wSpriteAnimationStructs"), count: at("wSpriteAnimCount"), shadow: at("wShadowOAM"), globalX: at("wGlobalAnimXOffset"), globalY: at("wGlobalAnimYOffset"), delay: at("wSlotsDelay"), scy: at("hSCY"), }; const initStruct = routine("_InitSpriteAnimStruct"); const playAnimations = routine("PlaySpriteAnimations"); await openReference(page); const result = await page.evaluate(async ({ a, initStruct, playAnimations }) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const { CrystalAssets } = await import("/src/game/rom/assets.ts"); const slots = await import("/src/game/scripts/slot-machine.ts"); const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer()); const assets = new CrystalAssets(rom); const reference = new BinjgbEmulator(); await reference.load(rom, document.createElement("canvas")); const write = (p: number, v: number) => reference.writeMemory(p, v & 255); const read = (p: number) => reference.readMemory(p); const call = (bank: number, address: number, program: number[] = []) => { const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe]; write(0xc0f0, 0); full.forEach((v, i) => write(0xc000 + i, v)); reference.setProgramCounter(0xc000); for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1); if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return"); }; // See the first-frame test above: attribute bit 4 is not a CGB OBJ bit. const ATTR_MASK = 0xe7; type Sprite = { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean }; const toNative = (sprites: Sprite[]) => { const oam = new Array(160).fill(0); sprites.forEach((sprite, i) => { oam[i * 4 + 0] = (sprite.y + 16) & 0xff; oam[i * 4 + 1] = (sprite.x + 8) & 0xff; oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff; oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK; }); return oam; }; type Session = Parameters[0]; const failures: unknown[] = []; let compared = 0; /** Runs both sides tick by tick. `drive` applies the reel actions' * wSlotsDelay writes to both, between ticks; it returns false to stop. */ function sequence( which: string, animId: number, d: number, e: number, setup: (session: Session) => void, var3: number | undefined, drive: (tick: number, delay: number, session: Session) => number | false, ) { for (let i = 0; i < 160; i++) write(a.structs + i, 0); write(a.count, 0); write(a.globalX, 0); write(a.globalY, 0); write(a.delay, 0); write(a.scy, 0); call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]); if (var3 !== undefined) write(a.structs + 14, var3); // SPRITEANIMSTRUCT_VAR3 const session = { delay: 0 } as Session; setup(session); for (let tick = 0; tick < 1000; tick++) { call(playAnimations.bank, playAnimations.address); slots.stepGolem(session, assets.reader); slots.stepChansey(session); slots.stepEgg(session, assets.reader); const original: number[] = []; for (let i = 0; i < 160; i++) original.push(i % 4 === 3 ? read(a.shadow + i) & ATTR_MASK : read(a.shadow + i)); const sprites: Sprite[] = []; if (session.golem) sprites.push(...slots.golemSprites(assets.reader, session.golem)); if (session.chansey) sprites.push(...slots.chanseySprites(assets.reader, session.chansey)); if (session.egg) sprites.push(...slots.eggSprites(assets.reader, session.egg)); const native = toNative(sprites); const delay = read(a.delay); if (delay !== session.delay) failures.push({ which, tick, delay, native: session.delay }); else if (read(a.scy) !== (session.scy ?? 0)) failures.push({ which, tick, scy: read(a.scy), native: session.scy }); else if (original.some((v, i) => v !== native[i])) failures.push({ which, tick, original: original.slice(0, 32), native: native.slice(0, 32) }); compared++; if (failures.length) return tick; const next = drive(tick, delay, session); if (next === false) return tick; write(a.delay, next); session.delay = next; } throw Error(`${which} did not finish`); } // Golem: three throws. ReelAction_WaitGolem/EndGolem answer each landing // (wSlotsDelay 1) by clearing it again a few ticks later; the sequence // ends two ticks after .init's last-Golem branch (wSlotsDelay 2). let landed = -1, finished = -1; const golemTicks = sequence("golem", 6, 0x60, 0x68, (s) => slots.initGolem(s, 3), 3, (tick, delay) => { if (delay === 1 && landed < 0) landed = tick; if (landed >= 0 && tick === landed + 4) { landed = -1; return 0; } if (delay === 2 && finished < 0) finished = tick; if (finished >= 0 && tick === finished + 2) return false; return delay; }); // Chansey: walks in (wSlotsDelay 1), ReelAction_WaitChansey answers with // 2, the egg rolls out (4), DropReel's EggAgain asks for another (1, then // 2 again), and the second egg ends the round (5, Chansey removed). let eggs = 0, at = -1, done = -1; const chanseyTicks = sequence("chansey", 7, 0x60, 0x00, (s) => slots.initChansey(s), undefined, (tick, delay) => { if (delay === 1 && at < 0) at = tick; if (delay === 1 && tick === at + 3) { at = -1; return 2; } if (delay === 4) { if (at < 0) at = tick; if (tick === at + 17) { at = -1; return ++eggs === 1 ? 1 : 5; } } if (delay === 5 && done < 0) done = tick; if (done >= 0 && tick === done + 2) return false; return delay; }); reference.dispose(); return { compared, golemTicks, chanseyTicks, eggs, failures: failures.slice(0, 3), count: failures.length }; }, { a, initStruct, playAnimations }); expect(result, JSON.stringify(result)).toMatchObject({ count: 0, eggs: 2 }); // Three throws of 17 falling and 37 rolling ticks each, plus the last .init. expect(result.golemTicks).toBe(167); expect(result.chanseyTicks).toBe(216); expect(result.compared).toBe(168 + 217); });