Open Route 30 with map trainers and multi-Pokémon trainer battles
Map trainers run the ROM's TalkToTrainerScript and SeenByTrainerScript. _CheckTrainerBattle runs before tile events at the end of each step and on idle ticks; TrainerWalkToPlayer builds wMovementBuffer natively, and writeobjectxy keeps the trainer where it stopped. The interpreter gains the trainer header commands, wRunningTrainerBattleScript and hidden items (HiddenItemScript). Trainers with several Pokémon send out the next one through EnemyPartyMonEntrance and FindMonInOTPartyToSwitchIntoBattle, including its BattleRandom fallback. AI_SwitchOrTryItem, CheckAbleToSwitch and AI_Switch are translated with their original bugs, and wPlayerUsedMoves is recorded. The Shift-mode switch offer, trainer items, Pursuit on a switch, Perish Song and Spikes remain refused. Route 30 and its Berry house are enabled; Mr. Pokémon's house and Route 31 stay closed. A refused trainer sighting does not restart until the player moves, so a rollback cannot trap the player in view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d1bedb3b1
commit
e00dfab42e
18 files changed
+1156
-62
No files matched your search
@@ -500,14 +500,14 @@ describe.skipIf(!existsSync(romPath))(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
const city = world.assets.map("26:3");
|
||||
const edge = Array.from({ length: city.width }, (_, x) => x).find(
|
||||
const route = world.assets.map("26:1");
|
||||
const edge = Array.from({ length: route.width }, (_, x) => x).find(
|
||||
(x) =>
|
||||
world.assets.permission(world.assets.collision(city, x, 0)) === 0,
|
||||
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
|
||||
)!;
|
||||
world.restore({ ...world.save(), map: "26:3", x: edge, y: 0 });
|
||||
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
|
||||
expect(world.move("up")).toBe(false);
|
||||
expect(world.notice).toBe("Route 30 is not ported yet.");
|
||||
expect(world.notice).toBe("Route 31 is not ported yet.");
|
||||
const {
|
||||
scenes,
|
||||
phone,
|
||||
@@ -831,6 +831,7 @@ describe.skipIf(!existsSync(romPath))(
|
||||
landmark: () => 0,
|
||||
facing: () => 0,
|
||||
position: () => ({ x: 0, y: 0 }),
|
||||
objectPosition: () => undefined,
|
||||
};
|
||||
// Runs the ROM's own battleWhiteoutScript end to end: HealParty,
|
||||
// GetWhiteoutSpawn and WarpToSpawnPoint all through the interpreter.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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 { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const ROUTE_30 = "26:1";
|
||||
const MIKEY = 4,
|
||||
DON = 5;
|
||||
const EVENT_BEAT_YOUNGSTER_MIKEY = 1450,
|
||||
EVENT_BEAT_BUG_CATCHER_DON = 1336;
|
||||
const POTION = 0x12;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", () => {
|
||||
const fixture = (position: { x: number; y: number; direction: "up" | "down" | "left" | "right" }) => {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
let seed = 7;
|
||||
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
});
|
||||
const mon = generatePartyMon(assets.pokemon, {
|
||||
species: 158, level: 20, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
||||
});
|
||||
mon.moves = [10, 0, 0, 0]; // Scratch
|
||||
mon.pp = [35, 0, 0, 0];
|
||||
world.state.party = [mon];
|
||||
world.restore({ ...world.save(), map: ROUTE_30, ...position });
|
||||
return world;
|
||||
};
|
||||
/** Play through scripts and battles, fighting with the first move. */
|
||||
const drive = (world: GameWorld) => {
|
||||
const shown: string[] = [];
|
||||
let emote = false;
|
||||
for (let tick = 0; tick < 50_000; tick++) {
|
||||
if (world.emote) emote = true;
|
||||
const view = world.battle;
|
||||
if (view) {
|
||||
if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text);
|
||||
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
|
||||
else if (view.prompt?.kind === "yesno") world.battleChoose(0);
|
||||
else if (view.prompt?.kind === "forget") world.battleChoose(4);
|
||||
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
shown.push(dialogue.pages[dialogue.index]);
|
||||
if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) return { text: shown.join("\n"), emote };
|
||||
world.tick();
|
||||
}
|
||||
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
||||
};
|
||||
const object = (world: GameWorld, id: number) =>
|
||||
world.objects().find((candidate) => candidate.id === id)!;
|
||||
|
||||
it("Bug Catcher Don sees the player, walks over, sends out both Caterpie and stays beaten", () => {
|
||||
const world = fixture({ x: 1, y: 12, direction: "up" });
|
||||
const money = world.state.money;
|
||||
expect(world.move("up")).toBe(true); // (1, 11): four tiles away
|
||||
expect(world.busy).toBe(false);
|
||||
expect(world.move("up")).toBe(true); // (1, 10): within his range of three
|
||||
expect(world.activity).toBe("scene");
|
||||
const { text, emote } = drive(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(emote).toBe(true);
|
||||
// TrainerWalkToPlayer stops beside the player; writeobjectxy keeps it there.
|
||||
expect(object(world, DON)).toMatchObject({ x: 1, y: 9, direction: "down" });
|
||||
expect(world.direction).toBe("up");
|
||||
expect(text.match(/sent out/g)?.length).toBe(2);
|
||||
expect(text).toContain("CATERPIE");
|
||||
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
|
||||
expect(world.state.money).toBe(money + 48);
|
||||
expect(world.state.pokedex.seen.has(10)).toBe(true);
|
||||
// endifjustbattled: no after-battle text straight after the battle.
|
||||
const after = text.slice(text.lastIndexOf("48"));
|
||||
expect(after.split("\n").length).toBeLessThanOrEqual(2);
|
||||
|
||||
// The position survives a save, and a beaten trainer no longer sees.
|
||||
const saved = world.save();
|
||||
expect(saved.objects).toContainEqual({ id: DON, x: 1, y: 9, direction: "down" });
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
world.move("down");
|
||||
world.move("up");
|
||||
world.tick();
|
||||
expect(world.busy).toBe(false);
|
||||
|
||||
// TalkToTrainer: already beaten, so only the after-battle text.
|
||||
world.interact();
|
||||
const talk = drive(world);
|
||||
expect(talk.text.length).toBeGreaterThan(0);
|
||||
expect(talk.text).not.toMatch(/sent out/);
|
||||
expect(world.state.money).toBe(money + 48);
|
||||
});
|
||||
|
||||
it("replays the whole sighting from committed state after a mid-battle reload", () => {
|
||||
const world = fixture({ x: 1, y: 11, direction: "up" });
|
||||
const before = world.save();
|
||||
world.move("up");
|
||||
for (let tick = 0; tick < 5000 && !world.battle?.prompt; tick++) {
|
||||
if (world.dialogue) world.advanceDialogue();
|
||||
else if (world.battle?.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
expect(world.battle?.prompt?.kind).toBe("action");
|
||||
const midBattle = world.save();
|
||||
expect(midBattle.events).toEqual(before.events);
|
||||
expect(midBattle.objects).toEqual(before.objects);
|
||||
expect(midBattle).toMatchObject({ x: 1, y: 10 });
|
||||
world.restore(midBattle);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(object(world, DON)).toMatchObject({ x: 1, y: 7 });
|
||||
// The first idle check sees the player again.
|
||||
world.tick();
|
||||
expect(world.activity).toBe("scene");
|
||||
drive(world);
|
||||
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-trigger a refused sighting until the player moves", () => {
|
||||
const world = fixture({ x: 1, y: 11, direction: "up" });
|
||||
world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused
|
||||
world.state.party[0].pp = [10, 0, 0, 0];
|
||||
const before = world.save();
|
||||
world.move("up");
|
||||
drive(world);
|
||||
expect(world.notice).toContain("not been ported");
|
||||
expect(world.state.events.has(EVENT_BEAT_BUG_CATCHER_DON)).toBe(false);
|
||||
expect(world.save().objects).toEqual(before.objects);
|
||||
for (let tick = 0; tick < 10; tick++) world.tick();
|
||||
expect(world.activity).toBe("idle");
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(world.move("up")).toBe(true);
|
||||
expect(world.activity).toBe("scene");
|
||||
});
|
||||
|
||||
it("battles Youngster Mikey when spoken to, then gives his after-battle text", () => {
|
||||
const world = fixture({ x: 5, y: 22, direction: "down" });
|
||||
expect(world.busy).toBe(false); // Mikey faces his Rattata, not the player.
|
||||
world.interact();
|
||||
const { text } = drive(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(object(world, MIKEY).direction).toBe("up"); // faceplayer
|
||||
expect(text).toContain("PIDGEY");
|
||||
expect(text).toContain("RATTATA");
|
||||
expect(world.state.events.has(EVENT_BEAT_YOUNGSTER_MIKEY)).toBe(true);
|
||||
world.interact();
|
||||
expect(drive(world).text).not.toMatch(/sent out/);
|
||||
});
|
||||
|
||||
it("finds the hidden Potion once", () => {
|
||||
const world = fixture({ x: 14, y: 10, direction: "up" });
|
||||
world.interact();
|
||||
expect(settle(world).join("\n")).toContain("POTION");
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
const potions = () =>
|
||||
world.state.bag.items.find((slot) => slot.item === POTION)?.quantity ?? 0;
|
||||
expect(potions()).toBe(1);
|
||||
world.interact();
|
||||
expect(settle(world)).toEqual([]);
|
||||
expect(potions()).toBe(1);
|
||||
});
|
||||
|
||||
it("runs the Route 30 Pokémon battle scene and the Berry house, but keeps Mr. Pokémon's house closed", () => {
|
||||
const world = fixture({ x: 5, y: 27, direction: "up" });
|
||||
world.interact();
|
||||
expect(settle(world).length).toBeGreaterThan(1);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
|
||||
world.restore({ ...world.save(), x: 7, y: 40, direction: "up" });
|
||||
expect(world.move("up")).toBe(true);
|
||||
settle(world);
|
||||
expect(world.mapId).toBe("26:9");
|
||||
const berry = world.state.bag.items.length;
|
||||
// Facing the Pokéfan M, who gives a Berry once.
|
||||
const pokefan = world.objects()[0];
|
||||
world.restore({ ...world.save(), x: pokefan.x, y: pokefan.y + 1, direction: "up" });
|
||||
world.interact();
|
||||
expect(settle(world).join("\n")).toMatch(/BERRY/);
|
||||
expect(world.state.bag.items.length).toBe(berry + 1);
|
||||
|
||||
world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" });
|
||||
expect(world.move("up")).toBe(false);
|
||||
expect(world.notice).toMatch(/not ported/);
|
||||
});
|
||||
});
|
||||
@@ -15,16 +15,17 @@ import { defaultTextContext } from "../src/game/scripts/text";
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
|
||||
describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
let assets: CrystalAssets, trainers: TrainerData;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
trainers = new TrainerData(assets.reader);
|
||||
});
|
||||
const setup = (level = 15) => {
|
||||
const trainer = trainers.trainer(22, 1); // Youngster Joey, level 4 Rattata.
|
||||
const setup = (level = 15, [classId, id] = [22, 1], species = 155) => {
|
||||
// Youngster Joey, level 4 Rattata, unless another trainer is named.
|
||||
const trainer = trainers.trainer(classId, id);
|
||||
const mon = generatePartyMon(assets.pokemon, {
|
||||
species: 155,
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: 1234,
|
||||
@@ -50,6 +51,7 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
|
||||
className: trainer.className,
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
@@ -171,11 +173,92 @@ describe.skipIf(!existsSync(romPath))("single-Pokémon trainer battles", () => {
|
||||
expect(mon.status).toBe(0);
|
||||
expect(mon.pp[0]).toBe(assets.pokemon.move(mon.moves[0]).pp);
|
||||
});
|
||||
it("refuses unported party switching and AI before mutating the party or Pokédex", () => {
|
||||
for (const kind of ["party", "ai"] as const) {
|
||||
/** Run to the first battle menu; the battle's internals for AI checks. */
|
||||
const atMenu = (battle: Battle) => {
|
||||
const run = battle.run();
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const next = run.next();
|
||||
if (!next.done && next.value.kind === "action") break;
|
||||
}
|
||||
return {
|
||||
run,
|
||||
internals: battle as unknown as {
|
||||
curOTMon: number;
|
||||
playerUsedMoves: number[];
|
||||
findMonInOTPartyToSwitchIntoBattle(): number;
|
||||
checkAbleToSwitch(): number;
|
||||
aiSwitchOrTryItem(): Generator<{ kind: string; text?: string }, boolean>;
|
||||
},
|
||||
};
|
||||
};
|
||||
it("sends out a trainer's next Pokémon with no random draw when a candidate is unthreatened", () => {
|
||||
// Totodile's Water type is neutral against Bug Catcher Don's Caterpie.
|
||||
const { options } = setup(15, [36, 1], 158);
|
||||
const battle = new Battle(options),
|
||||
{ internals } = atMenu(battle);
|
||||
options.trainer!.party[0].hp = 0;
|
||||
options.random = () => {
|
||||
throw new Error("Unexpected random draw");
|
||||
};
|
||||
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
|
||||
});
|
||||
it("draws BattleRandom & 7 when the player's types threaten every candidate", () => {
|
||||
// Cyndaquil's Fire type is super effective against the other Caterpie.
|
||||
const { options } = setup(15, [36, 1], 155);
|
||||
const battle = new Battle(options),
|
||||
{ internals } = atMenu(battle);
|
||||
options.trainer!.party[0].hp = 0;
|
||||
const draws = [0x0d, 0x08, 0x02, 0x09];
|
||||
options.random = () => draws.shift()!;
|
||||
// 5 and 0 (the fainted current Pokémon) and 2 (past the party) retry.
|
||||
expect(internals.findMonInOTPartyToSwitchIntoBattle()).toBe(1);
|
||||
expect(draws).toEqual([]);
|
||||
});
|
||||
it("switches out under SWITCH_SOMETIMES using the enemy move type for every matchup", () => {
|
||||
const { options } = setup(15, [22, 2]); // Youngster Mikey: Pidgey, Rattata
|
||||
const battle = new Battle(options),
|
||||
{ internals } = atMenu(battle);
|
||||
const WATER = 0x15, GROUND = 0x04, GRASS = 0x16;
|
||||
// CheckTypeMatchup reads the enemy's move type (here Grass), so it is
|
||||
// super effective against the enemy's Water type and the player's Water/Ground.
|
||||
battle.player.mon.types = [WATER, GROUND];
|
||||
battle.enemy.mon.types = [WATER, WATER];
|
||||
battle.enemy.move = { ...battle.enemy.move, type: GRASS };
|
||||
const rattata = options.trainer!.party[1];
|
||||
// FindEnemyMonsWithAtLeastQuarterMaxHP's rotation: 16 HP gives 4, never
|
||||
// above the maximum, while 1 HP gives $200.
|
||||
rattata.hp = 16;
|
||||
expect(internals.checkAbleToSwitch()).toBe(0);
|
||||
rattata.hp = 1;
|
||||
expect(internals.checkAbleToSwitch()).toBe(0x11);
|
||||
|
||||
battle.enemy.mon.hp = 3;
|
||||
battle.enemy.mon.status = 1 << 3; // poisoned
|
||||
const draws = [49];
|
||||
options.random = () => draws.shift()!;
|
||||
const texts: string[] = [];
|
||||
const run = internals.aiSwitchOrTryItem();
|
||||
let next = run.next();
|
||||
for (; !next.done; next = run.next()) if (next.value.text) texts.push(next.value.text);
|
||||
expect(next.value).toBe(true);
|
||||
expect(texts.join(" ").replace(/\n/g, " ")).toMatch(/withdrew PIDGEY.*sent out RATTATA/);
|
||||
expect(internals.curOTMon).toBe(1);
|
||||
expect(battle.enemy.mon.hp).toBe(1);
|
||||
// AI_Switch keeps the withdrawn Pokémon's HP and status in the party.
|
||||
expect(options.trainer!.party[0]).toMatchObject({ hp: 3, status: 1 << 3 });
|
||||
});
|
||||
it("records the player's used moves for the switch AI", () => {
|
||||
const { options } = setup(15, [22, 2]);
|
||||
const battle = new Battle(options),
|
||||
{ run, internals } = atMenu(battle);
|
||||
expect(internals.playerUsedMoves).toEqual([0, 0, 0, 0]);
|
||||
run.next({ action: "fight", move: 0 });
|
||||
expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 0]);
|
||||
});
|
||||
it("refuses the untranslated Smart AI and empty parties before mutating the party or Pokédex", () => {
|
||||
for (const kind of ["empty", "ai"] as const) {
|
||||
const { options } = setup();
|
||||
if (kind === "party")
|
||||
options.trainer!.party.push(structuredClone(options.trainer!.party[0]));
|
||||
if (kind === "empty") options.trainer!.party = [];
|
||||
else options.trainer!.aiFlags |= 1 << 4;
|
||||
const before = structuredClone(options.party);
|
||||
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
|
||||
|
||||
@@ -61,3 +61,68 @@ test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({
|
||||
expect(state.pokedex.seen).toContain(19);
|
||||
expect(state.events).toContain(256);
|
||||
});
|
||||
|
||||
test("Route 30: Bug Catcher Don spots the player, walks over and sends out both Caterpie", async ({ page }) => {
|
||||
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const source = await response.text();
|
||||
const needle = "const world = new GameWorld(assets);";
|
||||
expect(source).toContain(needle);
|
||||
await route.fulfill({ response, body: source.replace(needle,
|
||||
`const { route30FixtureWorld } = await import('/tests/trainer-fixture.ts');
|
||||
const world = route30FixtureWorld(assets);`) });
|
||||
});
|
||||
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
|
||||
await page.goto("/");
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("verified");
|
||||
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
|
||||
await page.locator("#play").click();
|
||||
await page.keyboard.down("ArrowUp");
|
||||
await page.clock.runFor(70);
|
||||
await page.keyboard.up("ArrowUp");
|
||||
// Both send-outs print the same text, so only repeats in a row are dropped.
|
||||
const messages: string[] = [];
|
||||
const add = (text: string) => {
|
||||
if (messages.at(-1) !== text) messages.push(text);
|
||||
};
|
||||
let dialogues = 0;
|
||||
for (let guard = 0; guard < 120; guard++) {
|
||||
if (await page.locator("#battle").isVisible()) {
|
||||
add(await page.locator("#battle-text").innerText());
|
||||
const menu = page.locator("#battle-menu");
|
||||
const fight = menu.getByRole("button", { name: "FIGHT", exact: true });
|
||||
if (await fight.count()) {
|
||||
await fight.click();
|
||||
await menu.getByRole("button", { name: /SCRATCH/ }).click();
|
||||
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
|
||||
await menu.getByRole("button", { name: /Continue/ }).click();
|
||||
} else await page.clock.runFor(1200);
|
||||
} else if (await page.locator("#native-dialogue").isVisible()) {
|
||||
dialogues++;
|
||||
add(await page.locator("#dialogue-text").innerText());
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(500);
|
||||
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
|
||||
await page.clock.runFor(500);
|
||||
} else break;
|
||||
}
|
||||
expect(dialogues).toBeGreaterThan(0);
|
||||
await expect(page.locator("#battle")).toBeHidden();
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "10");
|
||||
const shown = messages.join("\n");
|
||||
expect(shown).toContain("I found");
|
||||
expect(shown.match(/sent out/g)?.length).toBe(2);
|
||||
expect(shown).toContain("48");
|
||||
await page.locator("#tab-saves").click();
|
||||
const download = page.waitForEvent("download");
|
||||
await page.locator("#backup").click();
|
||||
const path = (await (await download).path())!;
|
||||
const record = JSON.parse(readFileSync(path, "utf8"));
|
||||
const state = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
|
||||
expect(state).toMatchObject({ map: "26:1", x: 1, y: 10 });
|
||||
expect(state.events).toContain(1336);
|
||||
expect(state.objects).toContainEqual({ id: 5, x: 1, y: 9, direction: "down" });
|
||||
expect(state.pokedex.seen).toContain(10);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { martFixtureWorld } from "./mart-fixture";
|
||||
|
||||
// Test-only script on an already accessible clerk. The actual trainer party,
|
||||
@@ -28,3 +29,24 @@ export function trainerFixtureWorld(assets: CrystalAssets, tail = [0x60, 0x33, 0
|
||||
return world;
|
||||
}
|
||||
|
||||
|
||||
/** Route 30 is enabled in production; only the start (below Bug Catcher
|
||||
* Don's sight line, with a level 20 Totodile) is a test fixture. */
|
||||
export function route30FixtureWorld(assets: CrystalAssets) {
|
||||
let seed = 7;
|
||||
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
});
|
||||
const mon = generatePartyMon(assets.pokemon, {
|
||||
species: 158, level: 20, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
||||
});
|
||||
mon.moves = [10, 0, 0, 0]; // Scratch
|
||||
mon.pp = [35, 0, 0, 0];
|
||||
world.state.party = [mon];
|
||||
world.state.pokedex.seen.add(158);
|
||||
world.state.pokedex.caught.add(158);
|
||||
world.restore({ ...world.save(), map: "26:1", x: 1, y: 11, direction: "up" });
|
||||
return world;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { trainerWalkBuffer } from "../src/game/scripts/interpreter";
|
||||
import { readMovement } from "../src/game/scripts/movement";
|
||||
|
||||
describe("TrainerWalkToPlayer", () => {
|
||||
const decode = (buffer: number[]) => readMovement({ byte: (index) => buffer[index] }, 0);
|
||||
it("only pauses when the player is adjacent", () => {
|
||||
expect(trainerWalkBuffer({ x: 5, y: 5 }, { x: 5, y: 6 }, 1)).toEqual([0x3e, 0x47]);
|
||||
expect(decode([0x3e, 0x47])).toEqual([{ kind: "sleep", ticks: 1 }]);
|
||||
});
|
||||
it("walks all but the last tile toward the player at normal speed", () => {
|
||||
// step DOWN is movement_step | DOWN.
|
||||
expect(trainerWalkBuffer({ x: 1, y: 7 }, { x: 1, y: 10 }, 3)).toEqual([0x3e, 0x0c, 0x0c, 0x47]);
|
||||
expect(trainerWalkBuffer({ x: 9, y: 4 }, { x: 4, y: 4 }, 5)).toEqual([0x3e, 0x0e, 0x0e, 0x0e, 0x0e, 0x47]);
|
||||
expect(trainerWalkBuffer({ x: 2, y: 4 }, { x: 4, y: 4 }, 2)).toEqual([0x3e, 0x0f, 0x47]);
|
||||
expect(decode([0x3e, 0x0d, 0x47])).toEqual([
|
||||
{ kind: "sleep", ticks: 1 },
|
||||
{ kind: "step", direction: "up", ticks: 8, animate: true },
|
||||
]);
|
||||
});
|
||||
it("follows ComputePathToWalkToPlayer off a straight line: the shorter axis first", () => {
|
||||
expect(trainerWalkBuffer({ x: 0, y: 0 }, { x: 3, y: 1 }, 4)).toEqual([0x3e, 0x0c, 0x0f, 0x0f, 0x47]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user