Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)
Enable Route 35, Route 36, Route 37, the National Park and its gates, Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's room and both floors of the Burned Tower. The story runs from Floria, the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the rival in the Burned Tower and the beasts' release to Ecruteak Gym, Morty, the FOGBADGE and TM30. Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons, CheckEncounterRoamMon) with their state in the save; only Continue moves them on restore, so a failed import's rollback keeps them in place. Port the Bug-Catching Contest: registration on contest days, Park Balls, contestants, the timer, QUIT, the catch-and-swap prompt, judging from the ROM's contestant table, prizes and the saved contest state. Port the Game Corner's SlotMachine and CardFlip on a new realtime tile screen that advances two frames per tick with buffered buttons: reel strips and scrolling, bias tables, the seven streak, Golem and Chansey, payouts, the Card Flip board, cursor and navigation. Their tilemaps are checked against the ROM's own data. Also port getnum, a plain warp that continues its script on the new map, and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the Burned Tower rival) is no longer sighted once its own flag is set, as the original's read of bit 1 of wNumKeyItems gives with the usual key items. The playthrough walks through the National Park and continues to Morty; its driver chooses moves by type matchup, buys at Marts and uses Super Potions and Awakenings in trainer battles. New tests cover roaming, the contest, the Game Corner and Ecruteak's side content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0930b780fc
commit
2640fa2725
31 files changed
+4743
-91
No files matched your search
@@ -0,0 +1,588 @@
|
||||
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);
|
||||
expect(session.golem).toBeUndefined();
|
||||
});
|
||||
|
||||
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).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user