Ring with trainers' calls, rematch them and play the radio (Closes #30)
Every registered trainer's callee and caller scripts run, with the missing specials and commands (RandomPhoneWildMon, RandomUnseenWildMon, swarms, gettrainerclassname, Kenji's break, the Blue Card and Buena's password variables). Fight counts are saved, so rematches and gifts work on every open map, and the daily reset clears the phone's flags. Random calls follow CheckPhoneCall and the 20/10/5/3-minute call timer; received calls ring twice with the caller's name, and calls play their ringing, hang-up and no-signal sounds. Grass and fishing swarms use the swarm tables. The radio ports PlayRadioShow and every station frame by frame, with its music: the Pokégear's radio card tunes by knob, house radios play through MapRadio, and Buena's show takes the day's password for Blue Card points that her receptionist exchanges for prizes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f30c3f450
commit
7c12c81a27
21 files changed
+2021
-64
No files matched your search
@@ -0,0 +1,448 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { receiveItem } from "../src/game/party/bag";
|
||||
import { ENGINE_FLAGS, PC, SYMBOLS } from "../src/game/rom/profile";
|
||||
import { TrainerData } from "../src/game/rom/trainers";
|
||||
import { PokedexData } from "../src/game/rom/pokedex";
|
||||
import { RadioShow, RADIO_LINES as L, buenasPassword, radioStation, type RadioHost } from "../src/game/scripts/radio";
|
||||
import { radioSpecialContext } from "../src/game/scripts/pokegear";
|
||||
import { copyScriptState, type ScriptState } from "../src/game/scripts/interpreter";
|
||||
import { SOUND } from "../src/game/audio/constants";
|
||||
import { isRinging } from "./world-driver";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PHONE_CONTACT_SIZE = 12;
|
||||
const PHONE_YOUNGSTER_JOEY = 15;
|
||||
const ROUTE_29 = "24:3";
|
||||
const ROUTE_30 = "26:1";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the phone and the radio with the local ROM", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const seeded = (seed: number) => () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
};
|
||||
const strongParty = (world: GameWorld) => {
|
||||
for (const species of [157, 160, 154])
|
||||
world.state.party.push(
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species, level: 90, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
/** Talk, then answer everything until the world is idle again; the
|
||||
* texts shown, and any refusal. */
|
||||
const drive = (world: GameWorld) => {
|
||||
const texts: string[] = [];
|
||||
for (let tick = 0; tick < 30000; tick++) {
|
||||
if (world.notice) return { texts, refused: world.diagnostic || world.notice };
|
||||
const battle = world.battle;
|
||||
if (battle) {
|
||||
const prompt = battle.prompt;
|
||||
if (battle.message) texts.push(battle.message.text);
|
||||
if (prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
||||
// No switching when a trainer sends out the next Pokémon.
|
||||
else if (prompt?.kind === "yesno") world.battleChoose(0);
|
||||
else if (prompt?.kind === "party") world.battleChoose(prompt.cancel ? -1 : 0);
|
||||
else if (prompt?.kind === "forget") world.battleChoose(4);
|
||||
else if (prompt?.kind === "stats" || battle.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (texts.at(-1) !== page && !isRinging(page)) texts.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
||||
world.choose(dialogue.choice.kind === "yesno" ? 1 : 0);
|
||||
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) return { texts, refused: "" };
|
||||
world.tick();
|
||||
}
|
||||
throw new Error(`The world did not settle: ${JSON.stringify({ map: world.mapId, x: world.x, y: world.y, choice: world.dialogue?.choice?.kind, page: world.dialogue?.pages[world.dialogue.index], battle: world.battle?.prompt?.kind ?? world.battle?.message, texts: texts.slice(-4) })}`);
|
||||
};
|
||||
|
||||
it("lets every registered trainer rematch, give gifts and talk again on their own map", () => {
|
||||
const rom = assets(),
|
||||
r = rom.reader,
|
||||
trainers = new TrainerData(r);
|
||||
const rematchFlags = rom.dailyEngineFlags();
|
||||
const refusals: string[] = [];
|
||||
let battles = 0;
|
||||
for (let contact = 5; contact <= 36; contact++) {
|
||||
const entry = PC.PhoneContacts + contact * PHONE_CONTACT_SIZE;
|
||||
const classId = r.byte(entry);
|
||||
if (!classId) continue;
|
||||
const mapId = `${r.byte(entry + 2)}:${r.byte(entry + 3)}`;
|
||||
if (!rom.isPorted(mapId)) continue;
|
||||
const map = rom.map(mapId);
|
||||
const objects = map.objects.filter((object) => {
|
||||
if (object.kind !== 2) return false;
|
||||
const header = trainers.header(object.script);
|
||||
return header.classId === classId && trainers.trainer(header.classId, header.id).name ===
|
||||
trainers.trainer(classId, r.byte(entry + 1)).name;
|
||||
});
|
||||
for (const object of objects)
|
||||
for (const fights of [0, 1, 2, 3, 4]) {
|
||||
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(contact * 7 + fights));
|
||||
strongParty(world);
|
||||
const header = trainers.header(object.script);
|
||||
world.state.events.add(header.eventFlag);
|
||||
// The trainer is out (Joey appears only once the Egg is found).
|
||||
if (object.eventFlag !== 0xffff) world.state.events.delete(object.eventFlag);
|
||||
world.state.phone.push(contact);
|
||||
for (const flag of rematchFlags) world.state.flags.add(flag);
|
||||
for (let badge = 0; badge < 16; badge++) world.state.flags.add(0x1b + badge);
|
||||
for (const label of Object.keys(world.state.memory)) delete world.state.memory[label as never];
|
||||
const counts = Object.keys(SAVED_FIGHTS);
|
||||
for (const label of counts) (world.state.memory as Record<string, number>)[label] = fights;
|
||||
const save = world.save();
|
||||
// Stand next to the trainer, facing it.
|
||||
let placed = false;
|
||||
for (const [dx, dy, direction] of [[0, 1, "up"], [0, -1, "down"], [1, 0, "left"], [-1, 0, "right"]] as const) {
|
||||
const x = object.x + dx,
|
||||
y = object.y + dy;
|
||||
if (x < 0 || y < 0 || x >= map.width || y >= map.height) continue;
|
||||
if (rom.permission(rom.collision(map, x, y)) !== 0) continue;
|
||||
try {
|
||||
world.restore({ ...save, map: mapId, x, y, direction });
|
||||
placed = true;
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!placed) continue;
|
||||
world.interact();
|
||||
const { texts, refused } = drive(world);
|
||||
if (refused) refusals.push(`${mapId} contact ${contact} fights ${fights}: ${refused}`);
|
||||
if (texts.some((text) => text.includes("defeated") || text.includes("Wants to battle") || text.includes("would like to battle")))
|
||||
battles++;
|
||||
}
|
||||
}
|
||||
expect(refusals).toEqual([]);
|
||||
expect(battles).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("rings with a registered trainer's call once the call timer runs out outdoors", () => {
|
||||
const rom = assets();
|
||||
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||
const world = new GameWorld(rom, () => clock.now, seeded(3));
|
||||
strongParty(world);
|
||||
world.state.phone.push(1, PHONE_YOUNGSTER_JOEY);
|
||||
world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 });
|
||||
// InitCallReceiveDelay's first wait is 20 minutes.
|
||||
for (let tick = 0; tick < 50; tick++) world.tick();
|
||||
expect(world.busy).toBe(false);
|
||||
let calls = 0,
|
||||
rang = false;
|
||||
const pages: string[] = [];
|
||||
for (let step = 0; step < 40 && !calls; step++) {
|
||||
clock.now = new Date(clock.now.getTime() + 21 * 60_000);
|
||||
for (let tick = 0; tick < 4 && !world.busy; tick++) world.tick();
|
||||
if (!world.busy) continue;
|
||||
calls++;
|
||||
for (let tick = 0; tick < 4000 && world.busy; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (page.startsWith("☎ JOEY:")) rang = true;
|
||||
if (!isRinging(page) && pages.at(-1) !== page) pages.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) world.choose(1);
|
||||
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
} else world.tick();
|
||||
}
|
||||
}
|
||||
expect(calls).toBe(1);
|
||||
expect(rang).toBe(true);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(pages.slice(-2)).toEqual(["Click!", "……"]);
|
||||
// Joey's caller script runs: a rematch request or news of a Pokémon.
|
||||
expect(pages.some((page) => page.includes("JOEY"))).toBe(true);
|
||||
});
|
||||
|
||||
it("never rings in a doorway, without a signal or with nobody to call", () => {
|
||||
const rom = assets();
|
||||
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||
const world = new GameWorld(rom, () => clock.now, seeded(5));
|
||||
world.state.phone.push(1, 4); // Mom and Elm have no caller hours.
|
||||
world.restore({ ...world.save(), map: ROUTE_29, x: 50, y: 10 });
|
||||
for (let step = 0; step < 20; step++) {
|
||||
clock.now = new Date(clock.now.getTime() + 21 * 60_000);
|
||||
world.tick();
|
||||
expect(world.busy).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the rematch and phone flags at the daily reset and counts Kenji's break down", () => {
|
||||
const rom = assets();
|
||||
const clock = { now: new Date("2026-09-22T13:00:00") };
|
||||
const world = new GameWorld(rom, () => clock.now, seeded(9));
|
||||
world.tick();
|
||||
const flags = rom.dailyEngineFlags();
|
||||
const rematch = flags.filter((flag) => rom.reader.word(SYMBOLS.engineFlags + flag * 3) >= 0xdc4c);
|
||||
expect(rematch.length).toBeGreaterThan(30);
|
||||
for (const flag of rematch) world.state.flags.add(flag);
|
||||
const kenji = world.state.memory.wKenjiBreakTimer!;
|
||||
expect(kenji).toBeGreaterThanOrEqual(3);
|
||||
expect(kenji).toBeLessThanOrEqual(6);
|
||||
clock.now = new Date("2026-09-23T13:00:00");
|
||||
world.tick();
|
||||
for (const flag of rematch) expect(world.state.flags.has(flag)).toBe(false);
|
||||
expect(world.state.memory.wKenjiBreakTimer).toBe(kenji - 1);
|
||||
});
|
||||
|
||||
it("swarms: the Dunsparce swarm's table on its map, and the fishing swarms", () => {
|
||||
const rom = assets();
|
||||
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(11));
|
||||
const table = (state: ScriptState) =>
|
||||
(world as unknown as { encounterTable(state: ScriptState): { grass?: { slots: { species: number }[][] } } })
|
||||
.encounterTable(state);
|
||||
const darkCave = "3:78"; // DARK_CAVE_VIOLET_ENTRANCE
|
||||
world.restore({ ...world.save(), map: darkCave, x: 3, y: 15 });
|
||||
const plain = table(world.state).grass!;
|
||||
const state = copyScriptState(world.state);
|
||||
state.flags.add(ENGINE_FLAGS.dunsparceSwarm);
|
||||
Object.assign(state.memory, { wDunsparceMapGroup: 3, wDunsparceMapNumber: 78 });
|
||||
const swarming = table(state).grass!;
|
||||
const DUNSPARCE = 206;
|
||||
expect(plain.slots[1].filter((slot) => slot.species === DUNSPARCE).length).toBeLessThan(
|
||||
swarming.slots[1].filter((slot) => slot.species === DUNSPARCE).length,
|
||||
);
|
||||
});
|
||||
|
||||
describe("the radio's stations (PlayRadioShow)", () => {
|
||||
const run = (line: number, frames: number, setup: Partial<{ hours: number; state: ScriptState }> = {}) => {
|
||||
const rom = assets();
|
||||
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(13));
|
||||
const state = setup.state ?? copyScriptState(world.state);
|
||||
const random = seeded(17);
|
||||
const music: number[] = [];
|
||||
const playMusic = world.sound.playMusic.bind(world.sound);
|
||||
world.sound.playMusic = (id) => {
|
||||
if (id) music.push(id);
|
||||
playMusic(id);
|
||||
};
|
||||
const scriptHost = (world as unknown as { host(id: number): never }).host(0) as Parameters<typeof radioSpecialContext>[1];
|
||||
const host: RadioHost = {
|
||||
reader: rom.reader,
|
||||
data: rom.pokemon,
|
||||
dex: new PokedexData(rom.reader),
|
||||
state,
|
||||
text: () => scriptHost.text(),
|
||||
random,
|
||||
timeOfDay: () => 1,
|
||||
hours: () => setup.hours ?? 13,
|
||||
weekday: () => 2,
|
||||
landmark: () => rom.map(ROUTE_30).landmark,
|
||||
landmarkOf: (id) => rom.map(id).landmark,
|
||||
special: () => radioSpecialContext(state, scriptHost),
|
||||
sound: world.sound,
|
||||
};
|
||||
const show = new RadioShow(host, line, "");
|
||||
const shown: string[] = [];
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
show.frame();
|
||||
for (const text of show.lines) if (text && shown.at(-1) !== text && !shown.includes(text)) shown.push(text);
|
||||
}
|
||||
return { shown, music, state };
|
||||
};
|
||||
|
||||
it("Oak's Pokémon Talk names a route's Pokémon, then the Pokémon Channel jingle", () => {
|
||||
const { shown, music } = run(L.OAKS_POKEMON_TALK, 101 * 40);
|
||||
expect(shown[0]).toBe("MARY: PROF.OAK'S");
|
||||
expect(shown.some((line) => line.startsWith("OAK: "))).toBe(true);
|
||||
expect(shown).toContain("POKéMON Channel");
|
||||
expect(music).toEqual([SOUND.MUSIC_POKEMON_TALK, SOUND.MUSIC_POKEMON_CHANNEL, SOUND.MUSIC_POKEMON_TALK]);
|
||||
});
|
||||
|
||||
it("the Pokédex Show reads a caught Pokémon's entry line by line", () => {
|
||||
const rom = assets();
|
||||
const state = copyScriptState(new GameWorld(rom).state);
|
||||
state.pokedex.caught.add(1);
|
||||
const { shown } = run(L.POKEDEX_SHOW, 101 * 9, { state });
|
||||
const entry = new PokedexData(rom.reader).entry(1);
|
||||
expect(shown).toContain(entry.category);
|
||||
expect(shown).toContain(entry.pages[0].split("\n")[0]);
|
||||
expect(shown).toContain(entry.pages[1].split("\n")[2]);
|
||||
});
|
||||
|
||||
it("the Lucky Channel reads this week's number", () => {
|
||||
const { shown, state } = run(L.LUCKY_CHANNEL, 101 * 10);
|
||||
const number = String(state.luckyNumber.id).padStart(5, "0");
|
||||
expect(shown.some((line) => line.includes(number))).toBe(true);
|
||||
});
|
||||
|
||||
it("Buena's Password is on the air from 6 PM with the day's password", () => {
|
||||
const evening = run(L.BUENAS_PASSWORD, 101 * 6, { hours: 19 });
|
||||
const password = evening.state.memory.wBuenasPassword!;
|
||||
expect(evening.state.flags.has(ENGINE_FLAGS.buenasPassword)).toBe(true);
|
||||
const rom = assets();
|
||||
const word = buenasPassword(rom.reader, rom.pokemon, password, defaultTextContext("CHRIS"));
|
||||
expect(word.length).toBeGreaterThan(0);
|
||||
expect(evening.music).toEqual([SOUND.MUSIC_BUENAS_PASSWORD]);
|
||||
const afternoon = run(L.BUENAS_PASSWORD, 101 * 3, { hours: 13 });
|
||||
// Off the air: no music, and BuenaOffTheAirText is an empty line.
|
||||
expect(afternoon.music).toEqual([]);
|
||||
expect(afternoon.shown).toEqual([]);
|
||||
});
|
||||
|
||||
it("tunes in only the stations a place can hear", () => {
|
||||
const rom = assets();
|
||||
const johto = { landmark: rom.map(ROUTE_30).landmark, timeOfDay: 1, expnCard: false, rocketSignal: false, rocketsInRadioTower: false };
|
||||
const context = defaultTextContext("CHRIS");
|
||||
expect(radioStation(rom.reader, 16, johto, context)?.line).toBe(L.OAKS_POKEMON_TALK);
|
||||
expect(radioStation(rom.reader, 16, { ...johto, timeOfDay: 0 }, context)?.line).toBe(L.POKEDEX_SHOW);
|
||||
expect(radioStation(rom.reader, 18, johto, context)).toBeUndefined();
|
||||
expect(radioStation(rom.reader, 52, johto, context)).toBeUndefined();
|
||||
expect(radioStation(rom.reader, 64, johto, context)).toBeUndefined();
|
||||
expect(radioStation(rom.reader, 52, { ...johto, landmark: L.LANDMARK_RUINS_OF_ALPH }, context)?.line).toBe(L.UNOWN_RADIO);
|
||||
});
|
||||
});
|
||||
|
||||
it("plays the Pokégear's radio card: tuning, the station and its music", () => {
|
||||
const rom = assets();
|
||||
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(19));
|
||||
strongParty(world);
|
||||
for (const flag of [0, 4]) world.state.flags.add(flag); // ENGINE_RADIO_CARD, ENGINE_POKEGEAR
|
||||
world.restore({ ...world.save(), map: ROUTE_30, x: 7, y: 30 });
|
||||
world.openStartMenu();
|
||||
const menu = world.dialogue!.choice!;
|
||||
expect(menu.kind).toBe("menu");
|
||||
const items = (menu as { items: string[] }).items;
|
||||
world.choose(items.indexOf("POKéGEAR"));
|
||||
// The clock card first, then right to the radio.
|
||||
world.choose("right");
|
||||
expect(world.pokegear?.card).toBe("radio");
|
||||
for (let press = 0; press < 8; press++) {
|
||||
world.bufferScreenButton("up");
|
||||
world.tick();
|
||||
}
|
||||
expect(world.pokegear?.radio?.frequency).toBe("4.5");
|
||||
expect(world.pokegear?.radio?.name).toMatch(/Talk/);
|
||||
for (let tick = 0; tick < 10; tick++) world.tick();
|
||||
expect(world.pokegear?.radio?.lines[0]).toBe("MARY: PROF.OAK'S");
|
||||
// A hundred frames later the next line comes.
|
||||
for (let tick = 0; tick < 60; tick++) world.tick();
|
||||
expect(world.pokegear?.radio?.lines[1]).toBe("POKéMON TALK!");
|
||||
expect(world.sound.engine.channelMusicId(0)).toBe(SOUND.MUSIC_POKEMON_TALK);
|
||||
world.bufferScreenButton("b");
|
||||
world.tick();
|
||||
expect(world.pokegear?.card).not.toBe("radio");
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("plays a house radio until a button stops it", () => {
|
||||
const rom = assets();
|
||||
const world = new GameWorld(rom, () => new Date("2026-09-22T13:00:00"), seeded(23));
|
||||
strongParty(world);
|
||||
// Kurt's house radio (Radio1Script) at 6, 1.
|
||||
world.restore({ ...world.save(), map: "8:4", x: 6, y: 2, direction: "up" });
|
||||
world.interact();
|
||||
expect(world.dialogue?.choice?.kind, world.diagnostic).toBe("mapradio");
|
||||
expect(world.dialogue?.pages[0]).toMatch(/^“.+”$/);
|
||||
for (let tick = 0; tick < 200; tick++) world.tick();
|
||||
expect(world.dialogue?.pages[0]).not.toMatch(/^“/);
|
||||
world.bufferScreenButton("a");
|
||||
for (let tick = 0; tick < 100 && world.busy; tick++) {
|
||||
if (world.dialogue && world.dialogue.choice?.kind !== "mapradio") world.advanceDialogue();
|
||||
world.tick();
|
||||
}
|
||||
expect(world.busy).toBe(false);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Buena's Password show with the local ROM", () => {
|
||||
const RADIO_TOWER_2F = "3:18";
|
||||
const EVENT_MET_BUENA = 829,
|
||||
BLUE_CARD = 0x74;
|
||||
const buena = (hour: number) => {
|
||||
const rom = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const world = new GameWorld(rom, () => new Date(`2026-09-22T${hour}:00:00`));
|
||||
world.state.party.push(
|
||||
generatePartyMon(rom.pokemon, { species: 155, level: 20, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0, caughtData: 0 }),
|
||||
);
|
||||
world.state.events.add(EVENT_MET_BUENA);
|
||||
receiveItem(rom.pokemon, world.state.bag, BLUE_CARD, 1);
|
||||
return world;
|
||||
};
|
||||
const answer = (world: GameWorld, pick: (items: string[]) => number) => {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 5000 && world.busy; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
pages.push(dialogue.pages[dialogue.index]);
|
||||
const choice = dialogue.index === dialogue.pages.length - 1 ? dialogue.choice : undefined;
|
||||
if (choice?.kind === "menu") world.choose(pick(choice.items));
|
||||
else if (choice) world.choose(1);
|
||||
else if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
it("takes today's password for a Blue Card point once a day", () => {
|
||||
const world = buena(19);
|
||||
world.state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||
world.state.memory.wBuenasPassword = 0x01; // JOHTO STARTERS: TOTODILE
|
||||
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||
world.interact();
|
||||
answer(world, (items) => items.indexOf("TOTODILE"));
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.blueCardBalance).toBe(1);
|
||||
expect(world.state.flags.has(96)).toBe(true); // ENGINE_BUENAS_PASSWORD_2
|
||||
});
|
||||
|
||||
it("refuses a wrong password, and sends the player to the radio before the show", () => {
|
||||
const world = buena(19);
|
||||
world.state.flags.add(ENGINE_FLAGS.buenasPassword);
|
||||
world.state.memory.wBuenasPassword = 0x01;
|
||||
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||
world.interact();
|
||||
answer(world, (items) => items.indexOf("CYNDAQUIL"));
|
||||
expect(world.state.blueCardBalance).toBe(0);
|
||||
const early = buena(13);
|
||||
early.restore({ ...early.save(), map: RADIO_TOWER_2F, x: 14, y: 6, direction: "up" });
|
||||
early.interact();
|
||||
answer(early, () => 0);
|
||||
expect(early.notice, early.diagnostic).toBe("");
|
||||
expect(early.state.blueCardBalance).toBe(0);
|
||||
});
|
||||
|
||||
it("exchanges Blue Card points for prizes at the receptionist", () => {
|
||||
const world = buena(13);
|
||||
world.state.blueCardBalance = 3;
|
||||
world.restore({ ...world.save(), map: RADIO_TOWER_2F, x: 13, y: 7, direction: "left" });
|
||||
world.interact();
|
||||
let bought = false;
|
||||
answer(world, (items) => {
|
||||
if (bought) return -1;
|
||||
bought = true;
|
||||
return items.indexOf("ULTRA BALL");
|
||||
});
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.blueCardBalance).toBe(1);
|
||||
expect(world.state.bag.balls.find((slot) => slot.item === 2)?.quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/** The trainers' fight counts (SAVED_WRAM). */
|
||||
const SAVED_FIGHTS = Object.fromEntries(
|
||||
["Jack", "Beverly", "Huey", "Gaven", "Beth", "Jose", "Reena", "Joey", "Wade", "Ralph", "Liz", "Anthony", "Todd",
|
||||
"Gina", "Irwin", "Arnie", "Alan", "Dana", "Chad", "Derek", "Tully", "Brent", "Tiffany", "Vance", "Wilton",
|
||||
"Kenji", "Parry", "Erin"].map((name) => [`w${name}FightCount`, 0]),
|
||||
);
|
||||
Reference in new issue
Block a user