This commit is contained in:
1 parent
6a74f59e46
commit
3e92936cb9
20 files changed
+1417
-32
No files matched your search
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
chooseTrainerMove,
|
||||
validateTrainerAI,
|
||||
type TrainerAIInput,
|
||||
type TrainerAISide,
|
||||
} from "../src/game/battle/ai";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { SYMBOLS } from "../src/game/rom/profile";
|
||||
import type { MoveData } from "../src/game/rom/pokemon";
|
||||
|
||||
const move = (
|
||||
id: number,
|
||||
effect = C.EFFECT_NORMAL_HIT,
|
||||
power = 40,
|
||||
type = 0,
|
||||
): MoveData => ({ id, effect, power, type, accuracy: 255, pp: 30, chance: 0 });
|
||||
const side = (): TrainerAISide => ({
|
||||
mon: {
|
||||
moves: [1, 2, 0, 0],
|
||||
pp: [30, 30, 0, 0],
|
||||
status: 0,
|
||||
hp: 20,
|
||||
maxHp: 20,
|
||||
types: [0, 0],
|
||||
species: 1,
|
||||
dvs: 0x8888,
|
||||
},
|
||||
sub: [0, 0, 0, 0, 0, 0],
|
||||
screens: 0,
|
||||
turnsTaken: 0,
|
||||
disableCount: 0,
|
||||
disabledMove: 0,
|
||||
});
|
||||
function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
|
||||
let cursor = 0;
|
||||
return {
|
||||
weights: 0,
|
||||
player: side(),
|
||||
enemy: side(),
|
||||
weather: 0,
|
||||
random: () => {
|
||||
if (cursor >= bytes.length)
|
||||
throw new Error("Unexpected extra random call");
|
||||
return bytes[cursor++];
|
||||
},
|
||||
data: {
|
||||
move: (id) => moves.find((m) => m.id === id)!,
|
||||
baseData: () => {
|
||||
throw new Error("Unexpected base data lookup");
|
||||
},
|
||||
},
|
||||
battleData: {
|
||||
byteList: (address) =>
|
||||
address === SYMBOLS.statusOnlyEffects
|
||||
? [C.EFFECT_SLEEP, C.EFFECT_TOXIC, C.EFFECT_POISON, C.EFFECT_PARALYZE]
|
||||
: [1, 2],
|
||||
typeMatchups: () => [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("trainer AI native scoring", () => {
|
||||
it("rejects untranslated layers, including Rival1, before consuming randomness", () => {
|
||||
for (const weights of [16, 32, 64, 512, 0x3d3, -1, 65536])
|
||||
expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
|
||||
expect(() => validateTrainerAI(0xfd8f)).not.toThrow();
|
||||
});
|
||||
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
|
||||
const input = fixture(undefined, [3, 6, 253]);
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 2,
|
||||
index: 1,
|
||||
scores: [20, 20, 80, 80],
|
||||
});
|
||||
});
|
||||
it("refuses a random source that never produces a candidate", () => {
|
||||
const input = fixture();
|
||||
input.random = () => 3;
|
||||
expect(() => chooseTrainerMove(input)).toThrow(/random source/);
|
||||
});
|
||||
it("masks PP Ups and discourages disabled/exhausted moves", () => {
|
||||
const input = fixture([move(1), move(2), move(3)], [0, 1, 2]);
|
||||
input.enemy.mon.moves = [1, 2, 3, 0];
|
||||
input.enemy.mon.pp = [30, 0xc0, 0xc1, 0];
|
||||
input.enemy.disabledMove = 1;
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 3,
|
||||
index: 2,
|
||||
scores: [80, 80, 20, 80],
|
||||
});
|
||||
});
|
||||
it("still scores exhausted moves and consumes randomness before Struggle", () => {
|
||||
const input = fixture(
|
||||
[move(1, C.EFFECT_ATTACK_UP, 0), move(2)],
|
||||
[128, 3, 0],
|
||||
);
|
||||
input.weights = 2;
|
||||
input.enemy.mon.pp.fill(0);
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [78, 80, 80, 80],
|
||||
});
|
||||
});
|
||||
it("still selects when the only move is disabled", () => {
|
||||
const input = fixture([move(1)], [3, 0]);
|
||||
input.enemy.mon.moves = [1, 0, 0, 0];
|
||||
input.enemy.disabledMove = 1;
|
||||
expect(chooseTrainerMove(input)).toEqual({
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [80, 20, 80, 80],
|
||||
});
|
||||
});
|
||||
it("refuses empty move lists instead of the cartridge's infinite loop", () => {
|
||||
const input = fixture();
|
||||
input.enemy.mon.moves.fill(0);
|
||||
expect(() => chooseTrainerMove(input)).toThrow(/nonzero move/);
|
||||
});
|
||||
it("Basic discourages status on a statused or safeguarded target exactly once", () => {
|
||||
const input = fixture([move(1, C.EFFECT_SLEEP, 0), move(2)], [1]);
|
||||
input.weights = 1;
|
||||
input.player.mon.status = 8;
|
||||
input.player.screens = 1 << C.SCREENS_SAFEGUARD;
|
||||
expect(chooseTrainerMove(input).scores).toEqual([30, 20, 80, 80]);
|
||||
});
|
||||
it("Basic preserves Nightmare and Future Sight's cartridge redundancy bugs", () => {
|
||||
const input = fixture(
|
||||
[move(1, C.EFFECT_NIGHTMARE, 0), move(2, C.EFFECT_FUTURE_SIGHT)],
|
||||
[0],
|
||||
);
|
||||
input.weights = 1;
|
||||
input.player.mon.status = 8;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
||||
input.random = () => 1;
|
||||
input.enemy.screens = 1 << C.SCREENS_UNUSED;
|
||||
input.player.mon.status = 0;
|
||||
input.random = () => 0;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]);
|
||||
});
|
||||
it("Setup uses each side's first turn and exact 128/30 byte thresholds", () => {
|
||||
const input = fixture(
|
||||
[move(1, C.EFFECT_ATTACK_UP, 0), move(2, C.EFFECT_ATTACK_DOWN, 0)],
|
||||
[128, 30, 0],
|
||||
);
|
||||
input.weights = 2;
|
||||
input.player.turnsTaken = 1;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([18, 22]);
|
||||
input.random = (() => {
|
||||
const bytes = [127, 29, 1];
|
||||
return () => bytes.shift()!;
|
||||
})();
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
||||
});
|
||||
it("Types discourages resistance only when another damaging type exists", () => {
|
||||
const input = fixture([move(1, 0, 40, 20), move(2, 0, 0, 21)], [0]);
|
||||
input.weights = 4;
|
||||
input.battleData.typeMatchups = () => [
|
||||
{ attacker: 20, defender: 0, multiplier: 5 },
|
||||
];
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
||||
input.data.move = (id) =>
|
||||
id === 1 ? move(1, 0, 40, 20) : move(2, 0, 40, 21);
|
||||
input.random = () => 1;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]);
|
||||
});
|
||||
it("Types encourages damaging super-effective moves and respects Foresight", () => {
|
||||
const input = fixture([move(1, 0, 40, 0), move(2, 0, 40, 20)], [1]);
|
||||
input.weights = 4;
|
||||
input.player.mon.types = [8, 8];
|
||||
input.battleData.typeMatchups = () => [
|
||||
{ attacker: 20, defender: 8, multiplier: 20 },
|
||||
{ attacker: 0xfe, defender: 0, multiplier: 0 },
|
||||
{ attacker: 0, defender: 8, multiplier: 0 },
|
||||
];
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 19]);
|
||||
input.player.sub = [0, 1 << C.SUBSTATUS_IDENTIFIED, 0, 0, 0, 0];
|
||||
input.random = () => 1;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 19]);
|
||||
});
|
||||
it("Status discourages poison against Poison and damaging immunities", () => {
|
||||
const input = fixture([move(1, C.EFFECT_POISON, 0), move(2)], [0]);
|
||||
input.weights = 256;
|
||||
input.player.mon.types = [3, 3];
|
||||
input.battleData.typeMatchups = () => [
|
||||
{ attacker: 0, defender: 3, multiplier: 0 },
|
||||
];
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([30, 30]);
|
||||
});
|
||||
it("Cautious stops the entire layer on its first failed random check", () => {
|
||||
const input = fixture(undefined, [230, 1]);
|
||||
input.weights = 128;
|
||||
input.enemy.turnsTaken = 1;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([20, 20]);
|
||||
input.random = (() => {
|
||||
const bytes = [229, 230, 1];
|
||||
return () => bytes.shift()!;
|
||||
})();
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([21, 20]);
|
||||
});
|
||||
it("Offensive penalizes non-damaging moves by two", () => {
|
||||
const input = fixture([move(1, C.EFFECT_ATTACK_DOWN, 0), move(2)], [1]);
|
||||
input.weights = 8;
|
||||
expect(chooseTrainerMove(input).scores.slice(0, 2)).toEqual([22, 20]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { TrainerData } from "../src/game/rom/trainers";
|
||||
import {
|
||||
Battle,
|
||||
UnsupportedBattle,
|
||||
type BattleInput,
|
||||
type BattleOptions,
|
||||
} from "../src/game/battle/battle";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
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", () => {
|
||||
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 mon = generatePartyMon(assets.pokemon, {
|
||||
species: 155,
|
||||
level,
|
||||
item: 0,
|
||||
otId: 1234,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
});
|
||||
let seed = 1;
|
||||
const options: BattleOptions = {
|
||||
data: assets.pokemon,
|
||||
party: [mon],
|
||||
playerId: 1234,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
random: () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
trainer: {
|
||||
name: trainer.name,
|
||||
className: trainer.className,
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
return { mon, options };
|
||||
};
|
||||
const finish = (battle: Battle, runFirst = false) => {
|
||||
const gen = battle.run();
|
||||
const texts: string[] = [];
|
||||
let input: BattleInput,
|
||||
triedRun = false;
|
||||
for (let guard = 0; guard < 2000; guard++) {
|
||||
const next = gen.next(input);
|
||||
if (next.done)
|
||||
return { ...next.value, texts: texts.join(" ").replace(/\n/g, " ") };
|
||||
const effect = next.value;
|
||||
input = undefined;
|
||||
if (effect.kind === "text") texts.push(effect.text);
|
||||
if (effect.kind === "action") {
|
||||
input =
|
||||
runFirst && !triedRun
|
||||
? { action: "run" }
|
||||
: { action: "fight", move: 0 };
|
||||
triedRun = true;
|
||||
}
|
||||
if (effect.kind === "yesno") input = 0;
|
||||
if (effect.kind === "forget") input = 4;
|
||||
}
|
||||
throw new Error("Battle did not finish.");
|
||||
};
|
||||
it("announces the trainer, refuses escape without spending a turn, and awards trainer experience and money", () => {
|
||||
const direct = setup(),
|
||||
escape = setup();
|
||||
const before = direct.mon.exp;
|
||||
const won = finish(new Battle(direct.options));
|
||||
const escaped = finish(new Battle(escape.options), true);
|
||||
expect(won.result).toBe(0);
|
||||
expect(won.prizeMoney).toBe(64);
|
||||
expect(won.texts).toContain("YOUNGSTER JOEY wants to battle!");
|
||||
expect(won.texts).toContain("sent out RATTATA!");
|
||||
expect(won.texts).toContain("was defeated!");
|
||||
expect(won.texts).toContain("CHRIS got ¥64");
|
||||
expect(escaped.texts).toContain("trainer battle");
|
||||
expect(escape.mon).toEqual(direct.mon);
|
||||
expect(direct.mon.exp - before).toBe(48); // floor(57 * 4 / 7), then 1.5x.
|
||||
expect(direct.options.pokedex.seen.has(19)).toBe(true);
|
||||
});
|
||||
it("doubles prize money when the active Pokémon holds an Amulet Coin", () => {
|
||||
const { mon, options } = setup();
|
||||
mon.item = 0x5b;
|
||||
const result = finish(new Battle(options));
|
||||
expect(result.prizeMoney).toBe(128);
|
||||
expect(result.texts).toContain("CHRIS got ¥128");
|
||||
});
|
||||
it("scores exhausted trainer moves before the menu, then uses Struggle", () => {
|
||||
const { options } = setup();
|
||||
options.trainer!.party[0].pp.fill(0);
|
||||
let draws = 0;
|
||||
options.random = () => { draws++; return 0; };
|
||||
const battle = new Battle(options), run = battle.run();
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const next = run.next();
|
||||
if (!next.done && next.value.kind === "action") break;
|
||||
}
|
||||
expect(draws).toBeGreaterThan(0);
|
||||
// The AI selected a normal move despite exhausted PP; ParseEnemyAction
|
||||
// replaces it before the first attack, as the cartridge does.
|
||||
expect(battle.enemy.curMove).toBe(33);
|
||||
run.next({ action: "fight", move: 0 });
|
||||
expect(battle.enemy.curMove).toBe(165);
|
||||
});
|
||||
it("loses without receiving prize money", () => {
|
||||
const { mon, options } = setup(2);
|
||||
mon.hp = 1;
|
||||
const lost = finish(new Battle(options));
|
||||
expect(lost.result).toBe(1);
|
||||
expect(lost.prizeMoney).toBeUndefined();
|
||||
expect(mon.hp).toBe(0);
|
||||
});
|
||||
it("heals the party after a CANLOSE victory", () => {
|
||||
const { mon, options } = setup();
|
||||
options.canLose = true;
|
||||
mon.hp--;
|
||||
finish(new Battle(options));
|
||||
expect(mon.hp).toBe(mon.stats[0]);
|
||||
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) {
|
||||
const { options } = setup();
|
||||
if (kind === "party")
|
||||
options.trainer!.party.push(structuredClone(options.trainer!.party[0]));
|
||||
else options.trainer!.aiFlags |= 1 << 4;
|
||||
const before = structuredClone(options.party);
|
||||
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
|
||||
expect(options.party).toEqual(before);
|
||||
expect(options.pokedex.seen.size).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
|
||||
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM; trainer script is a test fixture."));
|
||||
|
||||
test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({ page }) => {
|
||||
// Route substitution stays in the test harness; production imports no fixture.
|
||||
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 { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts');
|
||||
const world = trainerFixtureWorld(assets, undefined, false, true);`) });
|
||||
});
|
||||
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.press("KeyX");
|
||||
await expect(page.locator("#battle")).toBeVisible();
|
||||
await expect(page.locator("#battle-foe-name")).toHaveText("RATTATA");
|
||||
let triedRun = false;
|
||||
const messages = new Set<string>();
|
||||
for (let guard = 0; guard < 60; guard++) {
|
||||
if (!(await page.locator("#battle").isVisible())) break;
|
||||
messages.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()) {
|
||||
if (!triedRun) {
|
||||
triedRun = true;
|
||||
await menu.getByRole("button", { name: "RUN", exact: true }).click();
|
||||
} else {
|
||||
await fight.click();
|
||||
await menu.getByRole("button", { name: /TACKLE/ }).click();
|
||||
}
|
||||
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
|
||||
await menu.getByRole("button", { name: /Continue/ }).click();
|
||||
} else await page.clock.runFor(1200);
|
||||
}
|
||||
await expect(page.locator("#battle")).toBeHidden();
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
const shown = [...messages].join("\n");
|
||||
expect(shown).toContain("JOEY");
|
||||
expect(shown).toMatch(/No!.*running/s);
|
||||
expect(shown).toContain("Ack! I lost again!");
|
||||
expect(shown).toContain("64");
|
||||
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.money).toBe(3064);
|
||||
expect(state.pokedex.seen).toContain(19);
|
||||
expect(state.events).toContain(256);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { martFixtureWorld } from "./mart-fixture";
|
||||
|
||||
// Test-only script on an already accessible clerk. The actual trainer party,
|
||||
// class attributes, moves and battle text are read from the local ROM.
|
||||
export function trainerFixtureWorld(assets: CrystalAssets, tail = [0x60, 0x33, 0x00, 0x01, 0x91], lose = false, winText = false) {
|
||||
const clerk = assets.map("26:4").objects[0];
|
||||
// Place the synthetic script in Joey's bank to test winlosstext's near pointer.
|
||||
if (winText) clerk.script = 0x68 * 0x4000 + 0x3f00;
|
||||
assets.reader.bytes.set([
|
||||
0x64, ...(winText ? [0xa2, 0x58] : [0, 0]), 0, 0, // Joey beaten text or default
|
||||
0x5e, 22, 1, // loadtrainer YOUNGSTER, JOEY1
|
||||
...(lose ? [0x1e, 3, 1] : []), // loadvar VAR_BATTLETYPE, BATTLETYPE_CANLOSE
|
||||
0x5f, ...tail,
|
||||
], clerk.script);
|
||||
const world = martFixtureWorld(assets);
|
||||
const mon = generatePartyMon(assets.pokemon, {
|
||||
species: 155, level: lose ? 2 : 50, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
mon.moves = [33, 0, 0, 0];
|
||||
mon.pp = [35, 0, 0, 0];
|
||||
if (lose) mon.hp = 1;
|
||||
world.state.party = [mon];
|
||||
world.state.pokedex.seen.add(155);
|
||||
world.state.pokedex.caught.add(155);
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { trainerFixtureWorld } from "./trainer-fixture";
|
||||
import type { GameWorld } from "../src/game/world/world";
|
||||
|
||||
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
const fixture = (tail?: number[], lose = false, winText = false) =>
|
||||
trainerFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), tail, lose, winText);
|
||||
|
||||
function finish(world: GameWorld) {
|
||||
const messages: string[] = [];
|
||||
for (let tick = 0; tick < 10000; tick++) {
|
||||
const view = world.battle;
|
||||
if (view) {
|
||||
if (view.message) messages.push(view.message.text);
|
||||
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", 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) world.advanceDialogue();
|
||||
else if (world.busy) world.tick();
|
||||
else return messages.join("\n");
|
||||
}
|
||||
throw new Error(`Trainer battle did not settle: ${world.diagnostic}`);
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", () => {
|
||||
it("commits Joey's ROM reward, party experience, sighting and postbattle event together", () => {
|
||||
const world = fixture();
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
expect(world.battle).toBeDefined();
|
||||
expect(world.save()).toEqual(before);
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("JOEY");
|
||||
expect(world.state.money).toBe(before.money + 64);
|
||||
expect(world.state.party[0].exp).toBeGreaterThan(before.party[0].exp);
|
||||
expect(world.state.pokedex.seen.has(19)).toBe(true);
|
||||
expect(world.state.events.has(256)).toBe(true);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("reads scripted win text from the script bank's ROM pointer", () => {
|
||||
const world = fixture(undefined, false, true);
|
||||
world.interact();
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("Ack! I lost again!");
|
||||
expect(messages.indexOf("Ack! I lost again!")).toBeLessThan(messages.lastIndexOf("64"));
|
||||
});
|
||||
|
||||
it("caps trainer rewards at the money limit", () => {
|
||||
const world = fixture();
|
||||
world.state.money = C.MAX_MONEY - 1;
|
||||
world.interact();
|
||||
finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.money).toBe(C.MAX_MONEY);
|
||||
});
|
||||
|
||||
it("rolls back reward, experience and sightings if the following script is unsupported", () => {
|
||||
const world = fixture([0xff]);
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
finish(world);
|
||||
expect(world.notice).toContain("not been ported");
|
||||
expect(world.save()).toEqual(before);
|
||||
});
|
||||
|
||||
it("restores a midbattle save without partially paying the reward", () => {
|
||||
const world = fixture();
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
for (let tick = 0; tick < 1000 && !world.battle?.prompt; tick++) {
|
||||
if (world.battle?.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
expect(world.save()).toEqual(before);
|
||||
world.restore(before);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.save()).toEqual(before);
|
||||
});
|
||||
|
||||
it("CANLOSE scripts receive LOSE in wScriptVar without forcing a whiteout", () => {
|
||||
// ifequal LOSE, .lost; invalid; .lost: setevent 256; end
|
||||
const world = fixture([0x91], true);
|
||||
const clerk = world.assets.map("26:4").objects[0];
|
||||
const start = clerk.script + 12; // after winlosstext/loadtrainer/loadvar/startbattle
|
||||
const target = ((start + 5) % 0x4000) + 0x4000;
|
||||
world.assets.reader.bytes.set([0x06, 1, target & 255, target >> 8, 0xff, 0x33, 0, 1, 0x91], start);
|
||||
const before = world.save();
|
||||
world.interact();
|
||||
const messages = finish(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.events.has(256)).toBe(true);
|
||||
expect(world.state.money).toBe(before.money);
|
||||
expect(world.save().map).toBe(before.map);
|
||||
expect(messages).not.toMatch(/whited\s+out/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { TrainerData } from "../src/game/rom/trainers";
|
||||
import { RomReader } from "../src/game/rom/reader";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { NUM_TRAINER_CLASSES } from "../src/game/rom/profile";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
describe.skipIf(!existsSync(romPath))("ROM trainer decoder", () => {
|
||||
const bytes = existsSync(romPath)
|
||||
? new Uint8Array(readFileSync(romPath))
|
||||
: new Uint8Array();
|
||||
const data = new TrainerData(new RomReader(bytes));
|
||||
it("reads Joey and Mikey, including class DVs and actual payout", () => {
|
||||
const joey = data.trainer(22, 1),
|
||||
mikey = data.trainer(22, 2);
|
||||
expect(joey).toMatchObject({
|
||||
name: "JOEY",
|
||||
className: "YOUNGSTER",
|
||||
dvs: 0x9888,
|
||||
reward: 64,
|
||||
partyType: 0,
|
||||
});
|
||||
expect(joey.party).toEqual([{ species: 19, level: 4, item: 0 }]);
|
||||
expect(mikey.party.map((mon) => [mon.species, mon.level])).toEqual([
|
||||
[16, 2],
|
||||
[19, 4],
|
||||
]);
|
||||
expect(data.createParty(joey)[0]).toMatchObject({
|
||||
moves: [33, 39, 0, 0],
|
||||
dvs: 0x9888,
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
});
|
||||
});
|
||||
it("reads Falkner's explicit moves and the rival starter variants", () => {
|
||||
const falkner = data.trainer(1, 1);
|
||||
expect(falkner).toMatchObject({
|
||||
name: "FALKNER",
|
||||
partyType: 1,
|
||||
dvs: 0x9a77,
|
||||
reward: 900,
|
||||
});
|
||||
const party = data.createParty(falkner, { ot: "CHRIS", otId: 123 });
|
||||
expect(party.map((mon) => mon.moves)).toEqual([
|
||||
[33, 189, 0, 0],
|
||||
[33, 189, 16, 0],
|
||||
]);
|
||||
expect(party[0].pp.slice(0, 2)).toEqual([35, 10]);
|
||||
expect(
|
||||
party.every((mon) => mon.hp === mon.stats[0] && mon.otId === 123),
|
||||
).toBe(true);
|
||||
expect([1, 2, 3].map((id) => data.trainer(9, id).party[0].species)).toEqual(
|
||||
[152, 155, 158],
|
||||
);
|
||||
expect(data.trainer(9, 1).dvs).toBe(0xdddd);
|
||||
});
|
||||
it("reads held items with and without explicit moves", () => {
|
||||
expect(data.trainer(59, 1).partyType).toBe(2);
|
||||
const gaven = data.trainer(27, 7);
|
||||
expect(gaven.partyType).toBe(3);
|
||||
expect(gaven.party[1].item).toBe(82);
|
||||
expect(data.createParty(gaven)[1].moves).toEqual(gaven.party[1].moves);
|
||||
});
|
||||
it.skipIf(!existsSync(".reference/pokecrystal/data/trainers/parties.asm"))(
|
||||
"sweeps every party listed in the pinned source",
|
||||
() => {
|
||||
const source = readFileSync(
|
||||
".reference/pokecrystal/data/trainers/parties.asm",
|
||||
"utf8",
|
||||
);
|
||||
const groups = source.split(/^\w+Group:\s*$/m).slice(1);
|
||||
expect(groups).toHaveLength(NUM_TRAINER_CLASSES);
|
||||
const formats = new Set<number>();
|
||||
let total = 0;
|
||||
groups.forEach((group, index) => {
|
||||
const count = [...group.matchAll(/db -1 ; end/g)].length;
|
||||
for (let id = 1; id <= count; id++) {
|
||||
const trainer = data.trainer(index + 1, id);
|
||||
formats.add(trainer.partyType);
|
||||
total++;
|
||||
if (index + 1 === 12 && id === 2) {
|
||||
expect(() => data.createParty(trainer)).toThrow(/Mystery Gift/);
|
||||
continue;
|
||||
}
|
||||
const party = data.createParty(trainer);
|
||||
expect(party.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
party.every((mon) => mon.hp > 0 && mon.hp === mon.stats[0]),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(() => data.trainer(index + 1, count + 1)).toThrow();
|
||||
});
|
||||
expect(formats).toEqual(new Set([0, 1, 2, 3]));
|
||||
expect(total).toBeGreaterThan(500);
|
||||
},
|
||||
);
|
||||
it("reads map trainer headers and vision range", () => {
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const route = assets.map("26:1");
|
||||
const trainers = route.objects.filter((object) => object.kind === 2);
|
||||
expect(trainers.length).toBeGreaterThan(0);
|
||||
for (const object of trainers) {
|
||||
expect(object.sightRange).toBeGreaterThan(0);
|
||||
const header = data.header(object.script);
|
||||
expect(header.seenText).toBeGreaterThan(0);
|
||||
expect(header.beatenText).toBeGreaterThan(0);
|
||||
expect(
|
||||
data.trainer(header.classId, header.id).party.length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it("rejects invalid class and trainer IDs", () => {
|
||||
for (const classId of [0, -1, 68, 1.5])
|
||||
expect(() => data.trainer(classId, 1)).toThrow();
|
||||
for (const id of [0, -1, 256, 1.5, 2])
|
||||
expect(() => data.trainer(1, id)).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user