Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm, Olivine City and its buildings, the Lighthouse's six floors, Routes 40 and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's house, the Poke Seer and the Lugia speech house. Olivine's port, the Battle Tower gate and Whirl Islands stay closed. The story runs from the rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to Amphy's cure, Jasmine, the MINERALBADGE and TM23. Port GiveShuckle and ReturnShuckie with every return code and Mania's wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and reused caught level, PhotoStudio's no-printer path (Printer Error 2), and PlaySlowCry. wMooMooBerries joins the saved script bytes. Port trainer item AI: the 13 healing, status and X items, the highest-level gate, item use while Mean Look or Wrap prevents switching, and the original's random and state quirks, checked against the ROM over 5,632 states. Leaders with items are no longer refused. Game Corner sprites match the original frame by frame: cursor and reel positions, Golem's whole-frame flips, frame timing, the frame a removed sprite is still drawn, and the Golem roll's screen shake. The Burned Tower rival's garbage trainer header is documented as a boundary: its seen text dispatches text command $7f into home code before any battle. The playthrough continues for six stages to Jasmine; its driver can Surf, fish and travel through floors joined by holes. New tests cover trainer items, Olivine and Cianwood side content, and reference checks for trainer items, trainer sight and Game Corner animations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
26 KiB
TypeScript
590 lines
26 KiB
TypeScript
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<number, string> = {},
|
|
numbers: Record<number, number> = {};
|
|
// 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<T>(
|
|
generator: Generator<ScriptEffect, T, Answer>,
|
|
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<number>(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<number>(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<boolean>(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<boolean>(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<T>(generator: Generator<ScriptEffect, T, Answer>) {
|
|
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);
|
|
});
|
|
},
|
|
);
|