Files
Crystal/tests/trainer-items.test.ts
kawaandClaude Opus 5.5 b5c2b19094
Checks / web (push) Successful in 1m46s
Private ROM checks / private-rom (push) Failing after 39m32s
Open Routes 38 to 41, Olivine and Cianwood to the Mineral Badge (#32)
Enable Route 38 and its Ecruteak gate, Route 39 with the MooMoo farm,
Olivine City and its buildings, the Lighthouse's six floors, Routes 40
and 41, and Cianwood City with its gym, pharmacy, Photo Studio, Mania's
house, the Poke Seer and the Lugia speech house. Olivine's port, the
Battle Tower gate and Whirl Islands stay closed. The story runs from the
rival in Olivine through Jasmine and Amphy, Suicune and Eusine, the
SecretPotion, Chuck's boulders, the STORMBADGE, TM01 and HM02, back to
Amphy's cure, Jasmine, the MINERALBADGE and TM23.

Port GiveShuckle and ReturnShuckie with every return code and Mania's
wrong last-Pokemon speech, PokeSeer with its high-byte-only OT check and
reused caught level, PhotoStudio's no-printer path (Printer Error 2),
and PlaySlowCry. wMooMooBerries joins the saved script bytes.

Port trainer item AI: the 13 healing, status and X items, the
highest-level gate, item use while Mean Look or Wrap prevents
switching, and the original's random and state quirks, checked against
the ROM over 5,632 states. Leaders with items are no longer refused.

Game Corner sprites match the original frame by frame: cursor and reel
positions, Golem's whole-frame flips, frame timing, the frame a removed
sprite is still drawn, and the Golem roll's screen shake. The Burned
Tower rival's garbage trainer header is documented as a boundary: its
seen text dispatches text command $7f into home code before any battle.

The playthrough continues for six stages to Jasmine; its driver can
Surf, fish and travel through floors joined by holes. New tests cover
trainer items, Olivine and Cianwood side content, and reference checks
for trainer items, trainer sight and Game Corner animations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:09:47 +02:00

363 lines
15 KiB
TypeScript

import { beforeAll, describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { Battle, type BattleEffect, type BattleMon, type BattleTrainer } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { partyMon } from "./battle-driver";
import { defaultTextContext } from "../src/game/scripts/text";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const RATTATA = 19,
PIDGEY = 16;
// TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm).
const SWITCH_SOMETIMES = 1 << 2,
ALWAYS_USE = 1 << 4,
UNKNOWN_USE = 1 << 5,
CONTEXT_USE = 1 << 6;
describe.skipIf(!existsSync(romPath))("trainer item AI (AI_TryItem)", () => {
let assets: CrystalAssets;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
});
const enemyMon = (
level: number,
hp: number,
maxHp: number,
status = 0,
): BattleMon => ({
species: RATTATA,
item: 0,
moves: [33, 0, 0, 0],
pp: [35, 0, 0, 0],
dvs: 0,
happiness: 70,
level,
status,
hp,
maxHp,
stats: [30, 30, 30, 30, 30],
types: [0, 0],
nickname: "RATTATA",
});
const makeTrainer = (
overrides: Partial<BattleTrainer> = {},
levels: number[] = [30],
): BattleTrainer => ({
name: "",
className: "CLAIR",
party: levels.map((level) => partyMon(assets, RATTATA, level)),
aiFlags: 0,
switchFlags: CONTEXT_USE | SWITCH_SOMETIMES,
items: [0, 0],
baseReward: 25,
reward: 100,
...overrides,
});
/** Builds a Battle with the enemy's battle Pokémon and curOTMon set
* directly (as the reference specs do), skipping the opening send-out,
* and exposes the private AI_TryItem for direct calls. */
const rig = (
trainer: BattleTrainer,
enemy: BattleMon,
{ curOTMon = 0, random = () => 0 }: { curOTMon?: number; random?: () => number } = {},
) => {
const battle = new Battle({
data: assets.pokemon,
party: [partyMon(assets, PIDGEY, 10)],
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1234,
playerName: "CHRIS",
timeOfDay: 1,
random,
trainer,
text: () => defaultTextContext("CHRIS"),
});
battle.turn = 1;
battle.curOTMon = curOTMon;
battle.enemy.mon = enemy;
const internal = battle as unknown as {
aiTryItem(): Generator<BattleEffect, boolean, unknown>;
aiSwitchOrTryItem(): Generator<BattleEffect, boolean, unknown>;
};
return { battle, internal };
};
const run = (gen: Generator<BattleEffect, boolean, unknown>) => {
const texts: string[] = [];
let step = gen.next();
while (!step.done) {
if (step.value.kind === "text") texts.push(step.value.text.replace(/\n/g, " "));
step = gen.next();
}
return { used: step.value, texts };
};
it("does nothing with no items held", () => {
const trainer = makeTrainer({ items: [0, 0] });
const enemy = enemyMon(30, 10, 100, 1 << C.FRZ);
const { internal } = rig(trainer, enemy);
const { used, texts } = run(internal.aiTryItem());
expect(used).toBe(false);
expect(texts).toEqual([]);
expect(enemy.status).toBe(1 << C.FRZ);
});
it.each(["mean-look", "wrap"])("a trainer blocked from switching by %s can still heal", (trap) => {
const trainer = makeTrainer({ items: [C.POTION, 0] });
const enemy = enemyMon(30, 10, 100);
const { battle, internal } = rig(trainer, enemy, {
random: () => { throw new Error("Trapped trainers must skip the switch roll"); },
});
if (trap === "mean-look") battle.player.set(5, C.SUBSTATUS_CANT_RUN);
else battle.enemy.wrapCount = 2;
expect(run(internal.aiSwitchOrTryItem()).used).toBe(true);
expect(enemy.hp).toBe(30);
expect(trainer.items).toEqual([0, 0]);
});
it.each([
[C.POTION, 20],
[C.SUPER_POTION, 50],
[C.HYPER_POTION, 200],
])("item %i heals %i HP, clamped to the maximum, and is consumed", (item, amount) => {
const trainer = makeTrainer({ items: [item, 0] });
const enemy = enemyMon(30, 10, 100);
const { internal } = rig(trainer, enemy);
const { used, texts } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(enemy.hp).toBe(Math.min(10 + amount, 100));
expect(trainer.items).toEqual([0, 0]);
expect(texts).toEqual([expect.stringMatching(/^CLAIR used .* on RATTATA!$/)]);
});
it("Max Potion and Full Restore fill to the maximum HP", () => {
for (const item of [C.MAX_POTION, C.FULL_RESTORE]) {
const trainer = makeTrainer({ items: [item, 0] });
const enemy = enemyMon(30, 1, 100);
const { internal } = rig(trainer, enemy);
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(enemy.hp).toBe(100);
}
});
it("Full Restore clears confusion, but Full Heal keeps it (a kept bug)", () => {
for (const [item, staysConfused] of [[C.FULL_RESTORE, false], [C.FULL_HEAL, true]] as const) {
const trainer = makeTrainer({ items: [item, 0] });
const enemy = enemyMon(30, item === C.FULL_HEAL ? 100 : 1, 100, 1 << C.FRZ);
const { battle, internal } = rig(trainer, enemy);
battle.enemy.set(3, C.SUBSTATUS_CONFUSED);
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(enemy.status).toBe(0);
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(staysConfused);
}
});
it("Full Heal only cures Toxic, Freeze or Sleep under CONTEXT_USE, not plain status", () => {
const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] });
const enemy = enemyMon(30, 100, 100, 1 << C.PAR);
const { internal } = rig(trainer, enemy);
const { used } = run(internal.aiTryItem());
expect(used).toBe(false);
expect(enemy.status).toBe(1 << C.PAR);
});
it("X Accuracy, Guard Spec and Dire Hit set their SubStatus4 flag", () => {
const cases = [
[C.X_ACCURACY, C.SUBSTATUS_X_ACCURACY],
[C.GUARD_SPEC, C.SUBSTATUS_MIST],
[C.DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY],
] as const;
for (const [item, bit] of cases) {
const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE });
const enemy = enemyMon(30, 100, 100);
// .XItem's first-turn-out gate (`cp 128; jp c, DontUse`) declines
// below 128, so a high roll is the one that lets CONTEXT_USE through.
const { battle, internal } = rig(trainer, enemy, { random: () => 254 });
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(battle.enemy.has(4, bit)).toBe(true);
}
});
it.each([
[C.X_ATTACK, C.ATTACK],
[C.X_DEFEND, C.DEFENSE],
[C.X_SPEED, C.SPEED],
[C.X_SPECIAL, C.SP_ATTACK],
])("item %i raises the matching stat", (item, stat) => {
const trainer = makeTrainer({ items: [item, 0], switchFlags: CONTEXT_USE });
const enemy = enemyMon(30, 100, 100);
const { battle, internal } = rig(trainer, enemy, { random: () => 254 });
const before = battle.enemy.levels[stat];
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(battle.enemy.levels[stat]).toBe(before + 1);
});
it("consumes whichever of the two slots the match came from", () => {
// item2 (Hyper Potion) is tried before item1 (Potion) is ever reached,
// since AI_Items lists Hyper Potion first.
const trainer = makeTrainer({ items: [C.POTION, C.HYPER_POTION] });
const enemy = enemyMon(30, 10, 100);
const { internal } = rig(trainer, enemy);
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(enemy.hp).toBe(100); // Hyper Potion's 200 HP, clamped
expect(trainer.items).toEqual([C.POTION, 0]);
});
it("BUG: a declined second item strands the first for the rest of that scan", () => {
// Clair: FULL_HEAL, HYPER_POTION. Full HP declines Hyper Potion, after
// which the scan can never see item1 (Full Heal) again this call, even
// though a frozen status would otherwise ask for it.
const stuck = makeTrainer({ items: [C.FULL_HEAL, C.HYPER_POTION] });
const frozenStuck = enemyMon(30, 100, 100, 1 << C.FRZ);
const { internal: stuckInternal } = rig(stuck, frozenStuck);
const { used: stuckUsed } = run(stuckInternal.aiTryItem());
expect(stuckUsed).toBe(false);
expect(frozenStuck.status).toBe(1 << C.FRZ);
expect(stuck.items).toEqual([C.FULL_HEAL, C.HYPER_POTION]);
// The same Full Heal works fine once nothing else occupies the scan.
const clear = makeTrainer({ items: [C.FULL_HEAL, 0] });
const frozenClear = enemyMon(30, 100, 100, 1 << C.FRZ);
const { internal: clearInternal } = rig(clear, frozenClear);
const { used: clearUsed } = run(clearInternal.aiTryItem());
expect(clearUsed).toBe(true);
expect(frozenClear.status).toBe(0);
});
it("BUG: the stuck pointer can drift into the base reward and misread it as a third item", () => {
// No real item1; item2 (Hyper Potion) is declined at full HP, which
// strands the scan at item2. It then reaches Full Heal's table entry
// with the stuck pointer one past item2, i.e. wEnemyTrainerBaseReward:
// a base reward that happens to equal FULL_HEAL's id is misread as
// that item and "used", simply zeroing the reward (docs/bugs_and_glitches.md
// "AI might use its base reward value as an item"). No real trainer's
// base reward aliases an item id, so this needs an explicit setup.
const trainer = makeTrainer({ items: [0, C.HYPER_POTION], baseReward: C.FULL_HEAL });
const enemy = enemyMon(30, 100, 100, 1 << C.FRZ);
const { internal } = rig(trainer, enemy);
const { used } = run(internal.aiTryItem());
expect(used).toBe(true);
expect(enemy.status).toBe(0);
expect(trainer.baseReward).toBe(0);
expect(trainer.items).toEqual([0, C.HYPER_POTION]);
});
it("only the trainer's highest-level Pokémon (ties included) may use an item", () => {
const trainer = makeTrainer({ items: [C.FULL_HEAL, 0] }, [20, 30]);
const enemy = enemyMon(20, 100, 100, 1 << C.FRZ);
const { internal } = rig(trainer, enemy, { curOTMon: 0 });
expect(run(internal.aiTryItem()).used).toBe(false);
expect(enemy.status).toBe(1 << C.FRZ);
// A tie at the trainer's highest level still counts.
trainer.party[0].level = 30;
const tied = enemyMon(30, 100, 100, 1 << C.FRZ);
const { internal: tiedInternal } = rig(trainer, tied, { curOTMon: 0 });
expect(run(tiedInternal.aiTryItem()).used).toBe(true);
expect(tied.status).toBe(0);
});
it("HealItem's random gate: above a quarter but at or below half HP, under CONTEXT_USE", () => {
const trainer = makeTrainer({ items: [C.POTION, 0] });
const enemy = () => enemyMon(30, 30, 100); // 30% of max: below half, above a quarter
for (const [roll, expected] of [[0, true], [254, false]] as const) {
trainer.items = [C.POTION, 0];
const mon = enemy();
const { internal } = rig(trainer, mon, { random: () => roll });
expect(run(internal.aiTryItem()).used).toBe(expected);
}
});
it("HealItem's UNKNOWN_USE path (dead in this game, but ported)", () => {
const trainer = makeTrainer({ items: [C.POTION, 0], switchFlags: UNKNOWN_USE });
// At or below a quarter: a roll below 50/255 declines; otherwise uses.
for (const [roll, expected] of [[0, false], [254, true]] as const) {
const mon = enemyMon(30, 10, 100);
const { internal } = rig(trainer, mon, { random: () => roll });
expect(run(internal.aiTryItem()).used).toBe(expected);
}
});
it("Status's ALWAYS_USE and plain-random paths (dead in this game, but ported)", () => {
const always = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: ALWAYS_USE });
const alwaysMon = enemyMon(30, 100, 100, 1 << C.PAR);
const { internal: alwaysInternal } = rig(always, alwaysMon);
expect(run(alwaysInternal.aiTryItem()).used).toBe(true);
const plain = makeTrainer({ items: [C.FULL_HEAL, 0], switchFlags: 0 });
for (const [roll, expected] of [[0, true], [254, false]] as const) {
plain.items = [C.FULL_HEAL, 0];
const mon = enemyMon(30, 100, 100, 1 << C.PAR);
const { internal } = rig(plain, mon, { random: () => roll });
expect(run(internal.aiTryItem()).used).toBe(expected);
}
});
it("XItem is more eager on the enemy's first turn out than later", () => {
const trainer = makeTrainer({ items: [C.X_SPEED, 0], switchFlags: CONTEXT_USE });
// First turn out (turnsTaken 0): the first random draw declines below
// 128 (`cp 128; jp c, DontUse`); CONTEXT_USE then needs no second draw.
{
const mon = enemyMon(30, 100, 100);
const { battle, internal } = rig(trainer, mon, { random: () => 254 });
battle.enemy.turnsTaken = 0;
expect(run(internal.aiTryItem()).used).toBe(true);
}
{
trainer.items = [C.X_SPEED, 0];
const mon = enemyMon(30, 100, 100);
const { battle, internal } = rig(trainer, mon, { random: () => 0 });
battle.enemy.turnsTaken = 0;
expect(run(internal.aiTryItem()).used).toBe(false);
}
// Not the first turn out: ALWAYS_USE_F is required (CONTEXT_USE alone
// is not enough), so this trainer never uses it again this way.
{
trainer.items = [C.X_SPEED, 0];
const mon = enemyMon(30, 100, 100);
const { battle, internal } = rig(trainer, mon, { random: () => 254 });
battle.enemy.turnsTaken = 1;
expect(run(internal.aiTryItem()).used).toBe(false);
}
});
it("clears Bide, Fury Cutter, Protect, Rage and the last counter move only when an item is used", () => {
const trainer = makeTrainer({ items: [C.POTION, 0] });
const enemy = enemyMon(30, 10, 100);
const { battle, internal } = rig(trainer, enemy);
battle.enemy.set(3, C.SUBSTATUS_BIDE);
battle.enemy.set(4, C.SUBSTATUS_RAGE);
battle.enemy.furyCutterCount = 3;
battle.enemy.protectCount = 2;
battle.enemy.rageCounter = 4;
battle.enemy.lastCounterMove = 33;
expect(run(internal.aiTryItem()).used).toBe(true);
expect(battle.enemyGoesFirst).toBe(1);
expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(false);
expect(battle.enemy.has(4, C.SUBSTATUS_RAGE)).toBe(false);
expect(battle.enemy.furyCutterCount).toBe(0);
expect(battle.enemy.protectCount).toBe(0);
expect(battle.enemy.rageCounter).toBe(0);
expect(battle.enemy.lastCounterMove).toBe(0);
});
it("leaves those substatuses untouched when nothing is used", () => {
const trainer = makeTrainer({ items: [0, 0] });
const enemy = enemyMon(30, 10, 100);
const { battle, internal } = rig(trainer, enemy);
battle.enemy.set(3, C.SUBSTATUS_BIDE);
battle.enemy.furyCutterCount = 3;
expect(run(internal.aiTryItem()).used).toBe(false);
expect(battle.enemy.has(3, C.SUBSTATUS_BIDE)).toBe(true);
expect(battle.enemy.furyCutterCount).toBe(3);
});
});