The player's name in the start menu opens TrainerCard: page 1 with the name, ID, money, Pokédex count and play time, page 2 with the Johto leaders and the badges owned, turned and closed with the original buttons. The card picture, leader faces and animated badges come from the ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time from the end of the introduction, capped at 999:59:59, and saves keep it. Closes #23 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
8.0 KiB
TypeScript
196 lines
8.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { CrystalAssets } from "../src/game/rom/assets";
|
|
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
|
|
import type { GameWorld } from "../src/game/world/world";
|
|
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
|
|
import { withoutPlayTime } from "./world-driver";
|
|
|
|
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const fixture = (scenario: BattleScenario = "win") =>
|
|
battleFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), scenario);
|
|
|
|
function encounter(world: GameWorld) {
|
|
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
|
expect(world.battle).toBeDefined();
|
|
}
|
|
|
|
function finish(world: GameWorld, action: "fight" | "run" = "fight") {
|
|
const messages = new Set<string>();
|
|
for (let tick = 0; tick < 10000; tick++) {
|
|
if (world.battle) {
|
|
const view = world.battle;
|
|
if (view.message) messages.add(view.message.text);
|
|
if (view.prompt?.kind === "action") world.battleAction({ action, move: 0 });
|
|
else if (view.prompt?.kind === "yesno") world.battleChoose(1);
|
|
else if (view.prompt?.kind === "forget") world.battleChoose(3);
|
|
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
|
|
else world.tick();
|
|
} else if (world.dialogue) {
|
|
messages.add(world.dialogue.pages[world.dialogue.index]);
|
|
world.advanceDialogue();
|
|
world.tick();
|
|
} else if (world.busy) world.tick();
|
|
else return [...messages].join("\n");
|
|
}
|
|
throw new Error(`Battle did not settle: ${world.diagnostic}`);
|
|
}
|
|
|
|
describe.skipIf(!existsSync(romPath))("world battle integration with the local ROM", () => {
|
|
it("commits a victory, level-up, move learning and Pokédex sighting", () => {
|
|
const world = fixture();
|
|
encounter(world);
|
|
const before = world.save();
|
|
expect(before.party[0].level).toBe(5);
|
|
expect(before.pokedex.seen).toEqual([155]);
|
|
const messages = finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toContain("SMOKESCREEN");
|
|
expect(world.state.party[0].level).toBe(6);
|
|
expect(world.state.party[0].moves).toContain(108);
|
|
expect(world.state.pokedex.seen.has(16)).toBe(true);
|
|
expect(world.state.pokedex.caught.has(16)).toBe(false);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
});
|
|
|
|
it("evolves a Pokémon that levelled up once the battle is won", () => {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
const mon = generatePartyMon(data, {
|
|
species: 155, level: 13, item: 0, otId: world.playerId,
|
|
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
|
});
|
|
mon.exp = data.expAtLevel(data.baseData(155).growthRate, 14) - 1;
|
|
world.state.party[0] = mon;
|
|
encounter(world);
|
|
const messages = finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toContain("What? CYNDAQUIL\nis evolving!");
|
|
expect(messages).toContain("evolved into\nQUILAVA!");
|
|
const evolved = world.state.party[0];
|
|
expect([evolved.species, evolved.nickname, evolved.level]).toEqual([156, "QUILAVA", 14]);
|
|
expect(evolved.stats).toEqual(calcStats(data, 156, evolved.dvs, 14, evolved.statExp));
|
|
expect(world.state.pokedex.caught.has(156)).toBe(true);
|
|
const saved = world.save();
|
|
world.restore(saved);
|
|
expect(world.save()).toEqual(saved);
|
|
});
|
|
|
|
it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => {
|
|
const world = fixture();
|
|
const data = world.assets.pokemon;
|
|
// A level-14 Cyndaquil that does not take part could evolve, but
|
|
// EvolveAfterBattle only checks the one that grew to level 6.
|
|
world.state.party.push(generatePartyMon(data, {
|
|
species: 155, level: 14, item: 0, otId: world.playerId,
|
|
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
|
}));
|
|
encounter(world);
|
|
const messages = finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.party[0].level).toBe(6);
|
|
expect(messages).not.toContain("evolving");
|
|
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 155]);
|
|
});
|
|
|
|
it("struggles with recoil once every move is out of PP", () => {
|
|
const world = fixture();
|
|
world.state.party[0].pp = [0, 0, 0, 0];
|
|
encounter(world);
|
|
const messages = finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toMatch(/STRUGGLE/);
|
|
expect(messages).toMatch(/recoil/i);
|
|
});
|
|
|
|
it("escapes without awarding experience and returns to walking", () => {
|
|
const world = fixture();
|
|
encounter(world);
|
|
expect(finish(world, "run")).toMatch(/Got away safely/);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.party[0].exp).toBe(178);
|
|
expect(world.state.pokedex.seen.has(16)).toBe(true);
|
|
expect(world.move("down")).toBe(true);
|
|
});
|
|
|
|
it("restores earned stat experience before the next stat recalculation", () => {
|
|
const world = fixture();
|
|
const mon = generatePartyMon(world.assets.pokemon, {
|
|
species: 155, level: 100, item: 0, otId: world.playerId,
|
|
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
|
});
|
|
mon.moves = [33, 0, 0, 0];
|
|
mon.pp = [35, 0, 0, 0];
|
|
world.state.party = [mon];
|
|
encounter(world);
|
|
finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
const saved = world.save();
|
|
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
|
|
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
|
|
expect(() => world.restore(saved)).not.toThrow();
|
|
expect(world.save()).toEqual(saved);
|
|
const corrupt = structuredClone(saved);
|
|
corrupt.party[0].stats[1] = 999;
|
|
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
|
|
});
|
|
|
|
it("whiteout heals the party, halves money and returns home", () => {
|
|
const world = fixture("loss");
|
|
encounter(world);
|
|
const messages = finish(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(messages).toMatch(/whited\s+out/i);
|
|
expect(world.save()).toMatchObject({ map: "24:7", x: 3, y: 3, money: 1500 });
|
|
const mon = world.state.party[0];
|
|
expect(mon.hp).toBe(mon.stats[0]);
|
|
expect(mon.pp[0]).toBe(35);
|
|
expect(mon.status).toBe(0);
|
|
});
|
|
|
|
it("a mid-battle save restores the committed party and position", () => {
|
|
const world = fixture();
|
|
encounter(world);
|
|
const committed = world.save();
|
|
for (let tick = 0; tick < 5000; tick++) {
|
|
if (world.battle?.prompt?.kind === "action") {
|
|
world.battleAction({ action: "fight", move: 0 });
|
|
break;
|
|
}
|
|
if (world.battle?.message?.prompt) world.interact();
|
|
else world.tick();
|
|
}
|
|
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
|
|
world.restore(committed);
|
|
expect(world.battle).toBeUndefined();
|
|
expect(world.save()).toEqual(committed);
|
|
expect(world.move("right")).toBe(true);
|
|
});
|
|
|
|
it("a failed whiteout map callback cannot commit healing or money loss", () => {
|
|
const world = fixture("loss");
|
|
encounter(world);
|
|
const before = world.save();
|
|
// A malformed callback models a destination whose logic is not supported.
|
|
world.assets.map("24:7").callbacks.push({ kind: 5, script: world.assets.reader.bytes.length });
|
|
finish(world);
|
|
expect(world.notice).toContain("not been ported");
|
|
expect(world.save().money).toBe(before.money);
|
|
expect(world.save().party).toEqual(before.party);
|
|
expect(world.save().pokedex).toEqual(before.pokedex);
|
|
});
|
|
|
|
it("unsupported effects discard all battle party and Pokédex changes", () => {
|
|
const world = fixture("unsupported");
|
|
encounter(world);
|
|
const before = world.save();
|
|
finish(world);
|
|
expect(world.notice).toContain("not been ported");
|
|
expect(world.save().party).toEqual(before.party);
|
|
expect(world.save().pokedex).toEqual(before.pokedex);
|
|
expect(world.save().money).toBe(before.money);
|
|
});
|
|
});
|