Add the TM/HM pocket, Egg screens and Violet's remaining scripts
Checks / web (push) Canceled after 0s
Checks / web (push) Canceled after 0s
The pack's TM/HM pocket lists TMs and HMs with their moves, gives TMs and teaches either through the ABLE/NOT ABLE party menu, the compatibility and "already knows" checks and LearnMove; a TM is used up and makes the Pokémon happier. Eggs show no level or HP in the party menus, get the Egg stats page with its hatching hint, and are skipped by HealParty. Bug Catcher Wade's number registers (VAR_CALLERID and RandomPhoneMon). The playthrough now runs through Falkner's ZEPHYRBADGE, the Togepi Egg, Kyle's Onix, Flash in Dark Cave, Wade's number and Earl's blackboard. With MAKE_TEST_SAVES it writes a Violet City backup for the manual checks. Validated with 610 unit tests, 157 browser tests (including all 256 badge bytes against the original BadgeStatBoosts), native/reference builds and reference/vendor/release checks. Egg hatching (#29) and trainer calls (#30) remain on their cards. Closes #31 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7909302db1
commit
73022b1505
13 files changed
+803
-31
No files matched your search
+292
-17
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { encodeSave } from "../src/storage/saves";
|
||||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import type { Direction } from "../src/game/world/types";
|
||||
@@ -23,13 +25,21 @@ const NEW_BARK = "24:4",
|
||||
VIOLET_GYM = "10:7",
|
||||
SPROUT_1F = "3:1",
|
||||
SPROUT_2F = "3:2",
|
||||
SPROUT_3F = "3:3";
|
||||
SPROUT_3F = "3:3",
|
||||
KYLES_HOUSE = "10:11";
|
||||
const DARK_CAVE = "3:78",
|
||||
EARLS_ACADEMY = "10:8";
|
||||
const PHONE_BUG_CATCHER_WADE = 16;
|
||||
const FLASH = 148,
|
||||
HM05 = 0xf7;
|
||||
const TOGEPI = 175,
|
||||
BELLSPROUT = 69,
|
||||
ONIX = 95;
|
||||
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||
EVENT_GOT_HM05_FLASH = 0x14,
|
||||
EVENT_BEAT_FALKNER = 0x2bd,
|
||||
EVENT_BEAT_FALKNER = 0x4bd,
|
||||
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
||||
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
||||
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
|
||||
ENGINE_ZEPHYRBADGE = 0x1b;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
@@ -53,8 +63,20 @@ describe.skipIf(!existsSync(romPath))(
|
||||
let world: GameWorld;
|
||||
const log: string[] = [];
|
||||
|
||||
/** A wild species to throw Poké Balls at instead of fighting. */
|
||||
let wanted = 0;
|
||||
/** The usable move with the most power, or the first with PP. */
|
||||
const strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
|
||||
let best = -1;
|
||||
mon.moves.forEach((move, i) => {
|
||||
if (!move || !(mon.pp[i] & 63)) return;
|
||||
if (best < 0 || assets!.pokemon.move(move).power > assets!.pokemon.move(mon.moves[best]).power)
|
||||
best = i;
|
||||
});
|
||||
return Math.max(0, best);
|
||||
};
|
||||
/** Run scripts and battles until the player is free again. Battles use
|
||||
* the first move with PP; a wild battle below half HP is run from, as
|
||||
* the strongest move with PP; a wild battle below half HP is run from, as
|
||||
* a cautious player would. New moves are learned when there is room,
|
||||
* and other questions take the queued answers or their defaults. */
|
||||
const play = (answers: (number | string)[] = []) => {
|
||||
@@ -68,17 +90,27 @@ describe.skipIf(!existsSync(romPath))(
|
||||
if (prompt?.kind === "action") {
|
||||
const { engine } = battle,
|
||||
mon = engine.player.mon;
|
||||
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
const ball = world.state.bag.balls.findIndex((slot) => slot.item === C.POKE_BALL);
|
||||
if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
engine.enemy.mon.species === wanted &&
|
||||
!world.state.party.some((member) => member.species === wanted) &&
|
||||
ball >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
world.battleAction({ action: "run" });
|
||||
else
|
||||
world.battleAction({
|
||||
action: "fight",
|
||||
move: Math.max(
|
||||
0,
|
||||
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
|
||||
),
|
||||
});
|
||||
world.battleAction({ action: "fight", move: strongest(mon) });
|
||||
} else if (prompt?.kind === "yesno") world.battleChoose(1);
|
||||
// SHIFT's party menu: keep the Pokémon out (or send the first
|
||||
// that can fight when one must go).
|
||||
else if (prompt?.kind === "party")
|
||||
world.battleChoose(
|
||||
prompt.cancel
|
||||
? -1
|
||||
: world.state.party.findIndex((mon, i) => i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0),
|
||||
);
|
||||
else if (prompt?.kind === "forget") world.battleChoose(4);
|
||||
else if (prompt?.kind === "name") world.battleName("");
|
||||
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
||||
@@ -213,6 +245,22 @@ describe.skipIf(!existsSync(romPath))(
|
||||
};
|
||||
|
||||
const lead = () => world.state.party[0];
|
||||
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
||||
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
||||
const writeTestSave = (name: string) => {
|
||||
if (!process.env.MAKE_TEST_SAVES) return;
|
||||
mkdirSync("test-saves", { recursive: true });
|
||||
writeFileSync(
|
||||
`test-saves/${name}.crystal-save.json`,
|
||||
encodeSave({
|
||||
version: 1,
|
||||
romHash: CRYSTAL_ROM.sha1,
|
||||
coreId: "crystal-native-world-v1",
|
||||
savedAt: new Date().toISOString(),
|
||||
state: new TextEncoder().encode(JSON.stringify(world.save())),
|
||||
}),
|
||||
);
|
||||
};
|
||||
/** PokecenterNurseScript from in front of the counter, then back out. */
|
||||
const heal = (center: string, city: string) => {
|
||||
enter(center, "up");
|
||||
@@ -268,8 +316,22 @@ describe.skipIf(!existsSync(romPath))(
|
||||
};
|
||||
/** Find a way through warps among `maps` to `target`, as a player
|
||||
* trying staircases would: depth first, backing out of dead ends. */
|
||||
const route = (target: string, maps: string[], depth = 6): boolean => {
|
||||
if (world.mapId === target) return true;
|
||||
const route = (
|
||||
target: string,
|
||||
maps: string[],
|
||||
depth = 6,
|
||||
goal?: { x: number; y: number },
|
||||
): boolean => {
|
||||
const reachable = () => {
|
||||
if (!goal) return true;
|
||||
try {
|
||||
findPath(world, goal.x, goal.y);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (world.mapId === target && reachable()) return true;
|
||||
if (!depth) return false;
|
||||
const here = world.save();
|
||||
for (const warp of world.map.warps) {
|
||||
@@ -281,11 +343,43 @@ describe.skipIf(!existsSync(romPath))(
|
||||
}
|
||||
const from = world.mapId;
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
|
||||
if (world.mapId !== from && route(target, maps, depth - 1, goal)) return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||
const toChoice = () => {
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice;
|
||||
if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
} else world.tick();
|
||||
}
|
||||
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
||||
};
|
||||
/** Choose a menu item by its label (or a pattern). */
|
||||
const choose = (label: string | RegExp) => {
|
||||
const choice = toChoice();
|
||||
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||
const index = choice.items.findIndex((item) => (typeof label === "string" ? item === label : label.test(item)));
|
||||
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||
world.choose(index);
|
||||
};
|
||||
/** Choose an item in the pack. */
|
||||
const choosePack = (item: number) => {
|
||||
const choice = toChoice();
|
||||
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
||||
const index = entries.findIndex((entry) => entry.item === item);
|
||||
if (index >= 0) return world.choosePackEntry(pocket, index);
|
||||
}
|
||||
throw new Error(`Item ${item} is not in the pack.`);
|
||||
};
|
||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||
const restInViolet = () => {
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
@@ -438,7 +532,8 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(VIOLET, "left");
|
||||
expect(world.state.flags.size).toBeGreaterThan(0);
|
||||
reload();
|
||||
});
|
||||
writeTestSave("violet-city");
|
||||
}, 120_000);
|
||||
|
||||
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
|
||||
traceFailures();
|
||||
@@ -458,6 +553,186 @@ describe.skipIf(!existsSync(romPath))(
|
||||
expect(pages).toContain("FLASH");
|
||||
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("beats Falkner for the ZEPHYRBADGE and TM31, then hears from Elm's aide", () => {
|
||||
traceFailures();
|
||||
expect(route(SPROUT_1F, [SPROUT_1F, SPROUT_2F, SPROUT_3F], 6, { x: 9, y: 15 })).toBe(true);
|
||||
exit(VIOLET);
|
||||
heal(VIOLET_CENTER, VIOLET);
|
||||
enter(VIOLET_GYM, "up");
|
||||
walk(5, 2);
|
||||
world.move("up"); // Falkner: this only turns the player
|
||||
world.interact();
|
||||
play();
|
||||
expect(world.state.events.has(EVENT_BEAT_FALKNER), log.join("|")).toBe(true);
|
||||
expect(world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
|
||||
expect(world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
|
||||
expect(world.state.bag.tmsHms[30]).toBe(1); // TM31
|
||||
reload();
|
||||
exit(VIOLET);
|
||||
// SPECIALCALL_ASSISTANT rings on a step: the aide waits in the
|
||||
// Pokémon Center with the Egg.
|
||||
enter(VIOLET_CENTER, "up");
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
|
||||
// The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER).
|
||||
const aide = world.assets.map(VIOLET_CENTER).objects[4];
|
||||
expect(world.state.events.has(aide.eventFlag)).toBe(false);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("takes the Egg from Elm's aide", () => {
|
||||
traceFailures();
|
||||
walk(4, 4);
|
||||
world.move("up"); // the aide: this only turns the player
|
||||
world.interact();
|
||||
play([1]);
|
||||
expect(world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
|
||||
const egg = world.state.party.at(-1)!;
|
||||
expect(egg).toMatchObject({
|
||||
species: TOGEPI,
|
||||
egg: true,
|
||||
nickname: "EGG",
|
||||
hp: 0,
|
||||
happiness: world.assets.pokemon.baseData(TOGEPI).eggCycles,
|
||||
});
|
||||
// An Egg is neither seen nor caught.
|
||||
expect(world.state.pokedex.caught.has(TOGEPI)).toBe(false);
|
||||
reload();
|
||||
});
|
||||
|
||||
it("catches a Bellsprout on Route 31 and trades it to Kyle for Onix", () => {
|
||||
traceFailures();
|
||||
exit(VIOLET);
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
wanted = BELLSPROUT;
|
||||
try {
|
||||
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
|
||||
if (!world.state.bag.balls.length) break;
|
||||
train(lead().level + 1, restInViolet);
|
||||
}
|
||||
} finally {
|
||||
wanted = 0;
|
||||
}
|
||||
const bellsprout = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
expect(bellsprout, log.slice(-20).join("|")).toBeGreaterThan(0);
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
enter(KYLES_HOUSE, "up");
|
||||
const kyle = world.objects().find((object) => object.sprite === world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === world.assets.map(KYLES_HOUSE).objects[1].id)!;
|
||||
walk(kyle.x - 1, kyle.y);
|
||||
world.move("right");
|
||||
world.interact();
|
||||
play([1, bellsprout]);
|
||||
const onix = world.state.party.at(-1)!;
|
||||
expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 });
|
||||
expect(world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
|
||||
expect(world.state.tradeFlags).toBe(1 << 1);
|
||||
expect(world.state.pokedex.caught.has(ONIX)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("teaches HM05 to a new Bellsprout and lights up Dark Cave with FLASH", () => {
|
||||
traceFailures();
|
||||
exit(VIOLET);
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
wanted = BELLSPROUT;
|
||||
try {
|
||||
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
|
||||
train(lead().level + 1, restInViolet);
|
||||
} finally {
|
||||
wanted = 0;
|
||||
}
|
||||
const slot = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
expect(slot).toBeGreaterThan(0);
|
||||
const nickname = world.state.party[slot].nickname;
|
||||
// PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout.
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
choose("PACK");
|
||||
choosePack(HM05);
|
||||
choose("USE");
|
||||
expect(toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
const party = toChoice();
|
||||
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
||||
world.choose(slot);
|
||||
if (world.state.party[slot].moves.every(Boolean)) {
|
||||
// A full move list asks which move to forget: the first one.
|
||||
expect(toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
toChoice();
|
||||
world.choose(0);
|
||||
}
|
||||
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
|
||||
// The menus work on a draft that is kept once they close.
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
else world.tick();
|
||||
}
|
||||
play();
|
||||
expect(world.state.party[slot].moves).toContain(FLASH);
|
||||
expect(world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
|
||||
// Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows.
|
||||
enter(DARK_CAVE, "up");
|
||||
expect(world.state.flash).toBe(false);
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
choose("POKéMON");
|
||||
choose(nickname);
|
||||
choose("FLASH");
|
||||
play();
|
||||
expect(world.state.flash, log.slice(-6).join("|")).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => {
|
||||
traceFailures();
|
||||
exit(ROUTE_31);
|
||||
// Talk to Wade wherever he stands (he walks up to battle on sight).
|
||||
// A trainer who spots the player on the way battles and moves, so
|
||||
// the approach is planned again.
|
||||
const talkTo = (id: number) => {
|
||||
for (let attempt = 0; attempt < 4; attempt++)
|
||||
for (const [dx, dy, face] of [[-1, 0, "right"], [1, 0, "left"], [0, 1, "up"], [0, -1, "down"]] as const) {
|
||||
const target = world.objects().find((object) => object.id === id)!;
|
||||
const x = target.x + dx,
|
||||
y = target.y + dy;
|
||||
try {
|
||||
if (!(world.x === x && world.y === y)) walk(x, y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const now = world.objects().find((object) => object.id === id)!;
|
||||
if (now.x !== target.x || now.y !== target.y) break;
|
||||
world.move(face);
|
||||
world.interact();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No way to talk to object ${id}`);
|
||||
};
|
||||
talkTo(world.map.objects[2].id);
|
||||
play([1]);
|
||||
const pages = log.join("\n").replace(/\n/g, " ");
|
||||
expect(pages).toContain("BUG CATCHER WADE");
|
||||
expect(world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
|
||||
reload();
|
||||
restInViolet();
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
enter(EARLS_ACADEMY, "up");
|
||||
walk(3, 1);
|
||||
world.move("up");
|
||||
world.interact();
|
||||
choose("PSN");
|
||||
toChoice();
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
|
||||
// B leaves the blackboard.
|
||||
world.choose(-1);
|
||||
play();
|
||||
expect(world.busy).toBe(false);
|
||||
reload();
|
||||
}, 120_000);
|
||||
},
|
||||
);
|
||||
@@ -607,15 +607,17 @@ test("native switch texts, healing, PP, happiness and CalcPlayerStats match the
|
||||
text: () => ({}) as never,
|
||||
});
|
||||
const internals = battle as unknown as {
|
||||
options: { party: { species: number; happiness: number }[] };
|
||||
options: { party: { species: number; happiness: number; egg?: true }[] };
|
||||
partyMenuCursor: number;
|
||||
turn: number;
|
||||
changeHappiness(index: number, action: number): void;
|
||||
calcPlayerStats(): void;
|
||||
};
|
||||
happinessCases.forEach((input, index) => {
|
||||
// An Egg is EGG in wPartySpecies and an egg flag here.
|
||||
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
||||
species: input.egg && slot === input.mon ? C.EGG : 155,
|
||||
species: 155,
|
||||
...(input.egg && slot === input.mon && { egg: true }),
|
||||
happiness: input.happiness,
|
||||
}));
|
||||
internals.partyMenuCursor = input.cursor;
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { RomReader } from "../src/game/rom/reader";
|
||||
import { ScriptRun, emptyScriptState, type ScriptState } from "../src/game/scripts/interpreter";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
|
||||
import { tmhmItem, tmhmNumber } from "../src/game/rom/pokemon";
|
||||
import type { GameWorld } from "../src/game/world/world";
|
||||
import { battleFixtureWorld } from "./battle-fixture";
|
||||
import { settle, walkTo } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe("askforphonenumber and addcellnum", () => {
|
||||
const host = {
|
||||
mapId: "1:1",
|
||||
lastTalked: 0,
|
||||
sceneOwner: () => undefined,
|
||||
timeMask: () => 2,
|
||||
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
|
||||
clockDay: () => 0,
|
||||
text: () => defaultTextContext("KAWA"),
|
||||
};
|
||||
/** Run `command contact`, then setevent 1, 2 or 3 for a result of 0, 1
|
||||
* or 2; returns the event set and the phone list. */
|
||||
const run = (command: number, phone: number[], answer?: number) => {
|
||||
const bytes = Uint8Array.from([
|
||||
command, 16,
|
||||
0x06, 0, 15, 0, // ifequal 0, $0f
|
||||
0x06, 1, 19, 0, // ifequal 1, $13
|
||||
0x06, 2, 23, 0, // ifequal 2, $17
|
||||
0x91,
|
||||
0x33, 1, 0, 0x91,
|
||||
0x33, 2, 0, 0x91,
|
||||
0x33, 3, 0, 0x91,
|
||||
]);
|
||||
const state: ScriptState = { ...emptyScriptState(), phone: [...phone] };
|
||||
const script = new ScriptRun(new RomReader(bytes), 0, state, host);
|
||||
let effect = script.resume();
|
||||
if (effect?.kind === "yesno") effect = script.resume(answer);
|
||||
expect(effect).toBeUndefined();
|
||||
return { result: [...script.state.events][0] - 1, phone: script.state.phone };
|
||||
};
|
||||
const others = [5, 6, 7, 11, 12, 13, 14, 15, 17];
|
||||
it("registers a contact, keeping room for Mom and Elm until they are listed", () => {
|
||||
expect(run(0x97, [], 1)).toEqual({ result: 0, phone: [16] });
|
||||
// Eight others leave no room: two slots wait for Mom (1) and Elm (4).
|
||||
expect(run(0x97, others.slice(0, 8), 1).result).toBe(1);
|
||||
// With Mom and Elm listed, the tenth slot is free.
|
||||
expect(run(0x97, [1, 4, ...others.slice(0, 7)], 1)).toMatchObject({ result: 0 });
|
||||
// An already listed contact cannot be added again: also "full".
|
||||
expect(run(0x97, [16], 1).result).toBe(1);
|
||||
expect(run(0x97, [], 0)).toEqual({ result: 2, phone: [] });
|
||||
});
|
||||
it("addcellnum answers TRUE when the contact could not be added", () => {
|
||||
expect(run(0x28, [])).toEqual({ result: 0, phone: [16] });
|
||||
expect(run(0x28, [16])).toMatchObject({ result: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Violet City scripts with the local ROM", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const ROUTE_31 = "26:2",
|
||||
KYLES_HOUSE = "10:11",
|
||||
SPEAROW = 21,
|
||||
BELLSPROUT = 69,
|
||||
CYNDAQUIL = 155,
|
||||
FLOWER_MAIL = 0x9e;
|
||||
const mon = (world: GameWorld, species: number, level = 10): PartyMon =>
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species, level, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
|
||||
});
|
||||
/** Advance to the next choice (or until the world is idle). */
|
||||
const toChoice = (world: GameWorld) => {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 3000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
if (!world.busy) return pages;
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (page && pages.at(-1) !== page) pages.push(page);
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) return pages;
|
||||
if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
throw new Error(`No choice appeared: ${JSON.stringify(world.dialogue)} ${world.diagnostic}`);
|
||||
};
|
||||
const place = (world: GameWorld, map: string, x: number, y: number) => {
|
||||
world.restore({ ...world.save(), map, x, y });
|
||||
settle(world);
|
||||
};
|
||||
/** From where the player stands, talk to the object facing `direction`,
|
||||
* answering in order. */
|
||||
const talk = (world: GameWorld, direction: "up" | "right", answers: number[]) => {
|
||||
world.move(direction);
|
||||
world.interact();
|
||||
const pages = toChoice(world);
|
||||
for (const answer of answers) {
|
||||
world.choose(answer);
|
||||
pages.push(...toChoice(world));
|
||||
}
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
return pages.join(" ");
|
||||
};
|
||||
|
||||
describe("Route 31's sleepy man and Kenya's MAIL", () => {
|
||||
const setup = (options: { message?: string; party?: (world: GameWorld) => PartyMon[] } = {}) => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
// Kenya's MAIL text is not one the mail entry could write, so it is
|
||||
// set after the save is restored.
|
||||
place(world, ROUTE_31, 17, 8);
|
||||
// Route31MailRecipientScript: faceplayer, opentext, checkevent
|
||||
// EVENT_GOT_TM50_NIGHTMARE, iftrue, checkevent EVENT_GOT_KENYA.
|
||||
const script = world.assets.map(ROUTE_31).objects[0].script,
|
||||
r = world.assets.reader;
|
||||
const gotKenya = r.word(script + 9);
|
||||
world.state.events.add(gotKenya);
|
||||
const spearow = mon(world, SPEAROW);
|
||||
spearow.item = FLOWER_MAIL;
|
||||
spearow.mail = {
|
||||
message: options.message ?? "DARK CAVE leads\nto another road",
|
||||
author: "RANDY",
|
||||
authorId: 1,
|
||||
species: SPEAROW,
|
||||
type: FLOWER_MAIL,
|
||||
};
|
||||
world.state.party.splice(0, world.state.party.length, ...(options.party?.(world) ?? [mon(world, CYNDAQUIL), spearow]));
|
||||
if (!options.party) world.state.party[1] = spearow;
|
||||
return { world, spearow };
|
||||
};
|
||||
it("takes Kenya and gives TM50", () => {
|
||||
const { world } = setup();
|
||||
const pages = talk(world, "up", [1]);
|
||||
expect(pages).toContain("NIGHTMARE");
|
||||
expect(world.state.party.map((each) => each.species)).toEqual([CYNDAQUIL]);
|
||||
expect(world.state.bag.tmsHms[tmhmNumber(tmhmItem(50)) - 1]).toBe(1);
|
||||
});
|
||||
it("keeps the Pokémon for the wrong MAIL, no MAIL, B or the last that can fight", () => {
|
||||
const wrong = setup({ message: "HELLO" });
|
||||
expect(talk(wrong.world, "up", [1])).toContain("This MAIL isn't for me.");
|
||||
expect(wrong.world.state.party).toHaveLength(2);
|
||||
const none = setup();
|
||||
talk(none.world, "up", [0]);
|
||||
expect(none.world.state.party).toHaveLength(2);
|
||||
expect(none.world.state.bag.tmsHms.every((count) => count === 0)).toBe(true);
|
||||
const refused = setup();
|
||||
talk(refused.world, "up", [-1]);
|
||||
expect(refused.world.state.party).toHaveLength(2);
|
||||
const last = setup({
|
||||
party: (world) => {
|
||||
const fainted = mon(world, CYNDAQUIL);
|
||||
fainted.hp = 0;
|
||||
return [fainted];
|
||||
},
|
||||
});
|
||||
last.world.state.party.push(last.spearow);
|
||||
talk(last.world, "up", [1]);
|
||||
expect(last.world.state.party).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kyle's trade", () => {
|
||||
/** Walk up beside Kyle, who walks up and down, and talk to him. */
|
||||
const kyle = (world: GameWorld, answers: number[]) => {
|
||||
const id = world.assets.map(KYLES_HOUSE).objects[1].id;
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const object = world.objects().find((each) => each.id === id)!;
|
||||
try {
|
||||
walkTo(world, object.x - 1, object.y);
|
||||
} catch {
|
||||
for (let tick = 0; tick < 16; tick++) world.tick();
|
||||
continue;
|
||||
}
|
||||
const now = world.objects().find((each) => each.id === id)!;
|
||||
if (now.x === world.x + 1 && now.y === world.y) return talk(world, "right", answers);
|
||||
}
|
||||
throw new Error("Kyle kept walking away.");
|
||||
};
|
||||
it("turns down the wrong Pokémon and a refusal, then trades once", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
world.state.party.push(mon(world, BELLSPROUT, 7));
|
||||
place(world, KYLES_HOUSE, 3, 6);
|
||||
const wrong = kyle(world, [1, 0]);
|
||||
expect(wrong).toContain("BELLSPROUT");
|
||||
expect(world.state.tradeFlags).toBe(0);
|
||||
kyle(world, [0]);
|
||||
expect(world.state.tradeFlags).toBe(0);
|
||||
kyle(world, [1, 1]);
|
||||
expect(world.state.tradeFlags).toBe(1 << 1);
|
||||
expect(world.state.party[1]).toMatchObject({ species: 95, level: 7, nickname: "ROCKY", ot: "KYLE" });
|
||||
// Afterwards Kyle only remembers the trade.
|
||||
const save = JSON.stringify(world.save().party);
|
||||
kyle(world, []);
|
||||
expect(JSON.stringify(world.save().party)).toBe(save);
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an Egg unhealed at the Pokémon Center", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
const egg = { ...mon(world, 175, 5), egg: true as const, nickname: "EGG", hp: 0 };
|
||||
world.state.party.push(egg);
|
||||
world.state.party[0].hp = 1;
|
||||
// Violet's nurse stands behind the counter at 3,1.
|
||||
place(world, "10:10", 3, 3);
|
||||
talk(world, "up", [1]);
|
||||
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
|
||||
expect(world.state.party[1]).toMatchObject({ egg: true, hp: 0 });
|
||||
});
|
||||
|
||||
it("teaches a TM from the pack, refusing incompatible Pokémon and known moves", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
const data = world.assets.pokemon,
|
||||
move = data.tmhmMove(31),
|
||||
CATERPIE = 10;
|
||||
world.state.bag.tmsHms[30] = 2; // TM31
|
||||
const lead = world.state.party[0];
|
||||
expect(data.canLearnTMHM(lead.species, move)).toBe(true);
|
||||
expect(data.canLearnTMHM(CATERPIE, move)).toBe(false);
|
||||
world.state.party.push(mon(world, CATERPIE));
|
||||
const choose = (label: string) => {
|
||||
toChoice(world);
|
||||
const choice = world.dialogue!.choice!;
|
||||
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||
const index = choice.items.indexOf(label);
|
||||
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||
world.choose(index);
|
||||
};
|
||||
/** PACK, TM31, USE, YES, then the party slot; back to the pack. */
|
||||
const teach = (slot: number) => {
|
||||
const pages = toChoice(world);
|
||||
const choice = world.dialogue!.choice!;
|
||||
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||
world.choosePackEntry(3, 0);
|
||||
choose("USE");
|
||||
pages.push(...toChoice(world));
|
||||
world.choose(1);
|
||||
pages.push(...toChoice(world));
|
||||
const party = world.dialogue!.choice!;
|
||||
expect(party.kind === "menu" && party.details).toEqual(["ABLE", "NOT ABLE"]);
|
||||
world.choose(slot);
|
||||
pages.push(...toChoice(world));
|
||||
return pages.join(" ");
|
||||
};
|
||||
const happiness = lead.happiness;
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
choose("PACK");
|
||||
expect(teach(1)).toContain("is not compatible with CATERPIE");
|
||||
expect(teach(0)).toContain("learned MUD-SLAP!");
|
||||
expect(teach(0)).toContain("knows MUD-SLAP.");
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
toChoice(world);
|
||||
}
|
||||
settle(world);
|
||||
expect(world.state.party[0].moves).toContain(move);
|
||||
expect(world.state.party[0].happiness).toBeGreaterThan(happiness);
|
||||
expect(world.state.bag.tmsHms[30]).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user