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>
This commit is contained in:
1 parent
7c4e8521d4
commit
b5c2b19094
29 files changed
+2708
-139
No files matched your search
@@ -60,6 +60,8 @@ export function battleOptions(
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
items: [...trainer.attributes.items] as [number, number],
|
||||
baseReward: trainer.attributes.baseReward,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ describe.skipIf(!existsSync(romPath))("held items and obedience with the local R
|
||||
const duel = (party: PartyMon[], enemy = [mon()], extra: Partial<BattleOptions> = {}) => new Battle({
|
||||
...battleOptions(assets, trainers, party, { wild: { species: 129, level: 10 } }),
|
||||
wild: undefined,
|
||||
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, reward: 40 },
|
||||
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, items: [0, 0], baseReward: 10, reward: 40 },
|
||||
...extra,
|
||||
});
|
||||
const drain = (run: Generator<BattleEffect, unknown, unknown>) => {
|
||||
|
||||
@@ -274,7 +274,7 @@ describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => {
|
||||
});
|
||||
const { battle, bag, party } = setup({
|
||||
trainer: {
|
||||
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, reward: 10,
|
||||
name: "JOEY", className: "YOUNGSTER", party: [joey], aiFlags: 0, switchFlags: 0, items: [0, 0], baseReward: 10, reward: 10,
|
||||
},
|
||||
});
|
||||
const { texts } = play(battle, [ball()], [], () => bag.balls[0]?.quantity === 4);
|
||||
|
||||
@@ -270,7 +270,8 @@ describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", ()
|
||||
// by exactly that one hop for real.
|
||||
expect(reel3.position).toBe((startPosition + 1) % 15);
|
||||
expect(session.matched).toBe(SLOTS_SEVEN);
|
||||
expect(session.golem).toBeUndefined();
|
||||
// Removed (wSlotsDelay 2); a removed struct is still drawn that tick.
|
||||
expect(session.golem?.removed ?? true).toBe(true);
|
||||
});
|
||||
|
||||
it("Chansey's replay walks in, lays the egg and lets DropReel stop reel 3 on seven", () => {
|
||||
@@ -282,7 +283,7 @@ describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", ()
|
||||
// The egg always finishes its bounce (and clears itself) before
|
||||
// DropReel's own match check runs, whether that check succeeds or
|
||||
// (EggAgain) loops back through Chansey for another egg.
|
||||
expect(session.egg).toBeUndefined();
|
||||
expect(session.egg?.removed ?? true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,6 +203,8 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
||||
party: enemy,
|
||||
aiFlags: 0,
|
||||
switchFlags: 0,
|
||||
items: [0, 0],
|
||||
baseReward: 10,
|
||||
reward: 40,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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 { caughtData, 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";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Olivine and Cianwood side content with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
|
||||
const ROUTE_39_BARN = "1:10",
|
||||
MANIAS_HOUSE = "22:4",
|
||||
PHOTO_STUDIO = "22:8",
|
||||
SEERS_HOUSE = "22:10";
|
||||
const SHUCKLE = 213,
|
||||
BERRY = 0xad,
|
||||
MANIA_OT_ID = 518,
|
||||
LANDMARK_NEW_BARK_TOWN = 1,
|
||||
LANDMARK_GIFT = 0x7e,
|
||||
LANDMARK_EVENT = 0x7f,
|
||||
DAY = 1; // DAY_F
|
||||
const EVENT_GOT_SHUCKIE = 0x45,
|
||||
EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM = 0x46,
|
||||
ENGINE_GOT_SHUCKIE_TODAY = 0x55,
|
||||
EVENT_HEALED_MOOMOO = 0x3d,
|
||||
EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO = 0x3f;
|
||||
|
||||
/** A world whose clock `now` moves. */
|
||||
const world = (date = "2026-09-22T12:00:00") => {
|
||||
let seed = 13;
|
||||
const clock = { now: new Date(date) };
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => clock.now,
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
return { game, clock };
|
||||
};
|
||||
const mon = (
|
||||
game: GameWorld,
|
||||
species: number,
|
||||
level: number,
|
||||
extra: Partial<ReturnType<typeof generatePartyMon>> = {},
|
||||
) =>
|
||||
Object.assign(
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: game.playerId,
|
||||
ot: "KAWA",
|
||||
dvs: 0x8888,
|
||||
caughtData: 0,
|
||||
}),
|
||||
extra,
|
||||
);
|
||||
/** Enter `map`, stand at (x, y) facing `face`, press A and answer. */
|
||||
const talk = (
|
||||
game: GameWorld,
|
||||
map: string,
|
||||
x: number,
|
||||
y: number,
|
||||
face: "up" | "down" | "left" | "right",
|
||||
answers: number[] = [],
|
||||
) => {
|
||||
game.warpTo(map, x, y);
|
||||
settle(game);
|
||||
game.restore({ ...game.save(), x, y, direction: face });
|
||||
game.interact();
|
||||
const shown = settle(game, answers).join(" ");
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return shown;
|
||||
};
|
||||
/** Mania stands at (2, 4); talk to him from below. */
|
||||
const mania = (game: GameWorld, answers: number[] = []) =>
|
||||
talk(game, MANIAS_HOUSE, 2, 5, "up", answers);
|
||||
|
||||
it("nurses Route 39's Miltank back to health with seven Berries", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
// MooMoo (3, 3) only asks for Berries once the farmer has explained.
|
||||
const moomoo = (answers: number[] = []) => talk(game, ROUTE_39_BARN, 3, 4, "up", answers);
|
||||
const before = moomoo();
|
||||
expect(before).toContain("MILTANK: …Moo…");
|
||||
expect(before).not.toContain("Give a BERRY");
|
||||
game.state.events.add(EVENT_TALKED_TO_FARMER_ABOUT_MOOMOO);
|
||||
expect(moomoo()).toContain("has no\nBERRIES…");
|
||||
game.state.bag.items = [{ item: BERRY, quantity: 7 }];
|
||||
expect(moomoo([0])).toContain("wouldn't\ngive a BERRY.");
|
||||
const fed = Array.from({ length: 7 }, () => moomoo([1]));
|
||||
expect(fed[2]).toContain("a\nlittle healthier!");
|
||||
expect(fed[4]).toContain("quite healthy!");
|
||||
expect(fed[6]).toContain("totally healthy!");
|
||||
expect(fed[0]).not.toContain("healthier");
|
||||
// wMooMooBerries is saved with the other script bytes.
|
||||
expect(game.save().memory).toMatchObject({ wMooMooBerries: 7 });
|
||||
expect(game.state.bag.items).toEqual([]);
|
||||
expect(game.state.events.has(EVENT_HEALED_MOOMOO)).toBe(true);
|
||||
expect(moomoo()).toContain("MILTANK: Mooo!");
|
||||
});
|
||||
|
||||
describe("Mania's Shuckie (GiveShuckle, ReturnShuckie)", () => {
|
||||
it("lends Shuckie with its own OT, ID, nickname, Berry and gift caught data", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
const shown = mania(game);
|
||||
expect(shown).toContain("after my #MON".replace("#", "POKé"));
|
||||
expect(shown).toContain("received a\nPOKéMON.");
|
||||
const shuckie = game.state.party[1];
|
||||
expect(shuckie).toMatchObject({
|
||||
species: SHUCKLE,
|
||||
level: 15,
|
||||
item: BERRY,
|
||||
otId: MANIA_OT_ID,
|
||||
ot: "MANIA",
|
||||
nickname: "SHUCKIE",
|
||||
caughtData: LANDMARK_GIFT,
|
||||
});
|
||||
expect(game.state.pokedex.caught.has(SHUCKLE)).toBe(true);
|
||||
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(true);
|
||||
expect(game.state.flags.has(ENGINE_GOT_SHUCKIE_TODAY)).toBe(true);
|
||||
// The same day he only asks the player to look after it.
|
||||
expect(mania(game)).toContain("Take good care of");
|
||||
expect(game.state.party).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps Shuckie when the party is full, or when the player says no", () => {
|
||||
const { game } = world();
|
||||
game.state.party = Array.from({ length: 6 }, () => mon(game, 155, 20));
|
||||
expect(mania(game)).toContain("Your POKéMON party\nis full.");
|
||||
expect(game.state.events.has(EVENT_GOT_SHUCKIE)).toBe(false);
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
expect(mania(game, [0])).toContain("What'll\nI do if he comes");
|
||||
expect(game.state.party).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** Shuckie lent yesterday; today Mania asks for it back. */
|
||||
const lent = () => {
|
||||
const { game, clock } = world();
|
||||
game.state.party = [mon(game, 155, 20)];
|
||||
mania(game);
|
||||
clock.now = new Date("2026-09-23T12:00:00");
|
||||
return game;
|
||||
};
|
||||
|
||||
it("takes Shuckie back the next day", () => {
|
||||
const game = lent();
|
||||
const shown = mania(game, [1, 1]);
|
||||
expect(shown).toContain("may I have\nit back?");
|
||||
expect(shown).toContain("Thank you!");
|
||||
expect(game.state.party.map((m) => m.species)).toEqual([155]);
|
||||
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
|
||||
expect(mania(game)).toContain("piness is being");
|
||||
});
|
||||
|
||||
it("lets a happy Shuckie (150 or more) stay, and refuses another Pokémon", () => {
|
||||
const wrong = lent();
|
||||
expect(mania(wrong, [1, 0])).toContain("you don't\nhave my POKéMON");
|
||||
expect(wrong.state.party).toHaveLength(2);
|
||||
// Declining his question, or backing out of the party menu, is "robbed".
|
||||
expect(mania(wrong, [0])).toContain("same as being\nrobbed.");
|
||||
expect(mania(wrong, [1, -1])).toContain("same as being\nrobbed.");
|
||||
const happy = lent();
|
||||
happy.state.party[1].happiness = 150;
|
||||
expect(mania(happy, [1, 1])).toContain("come to like you.");
|
||||
expect(happy.state.party).toHaveLength(2);
|
||||
expect(happy.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Shuckie when every other Pokémon has fainted, with the wrong speech", () => {
|
||||
// CheckCurPartyMonFainted checks the party apart from Shuckie;
|
||||
// Mania then answers SHUCKIE_FAINTED with his happiness speech
|
||||
// (docs/bugs_and_glitches.md: the "last Pokémon" text is unused).
|
||||
const game = lent();
|
||||
game.state.party[0].hp = 0;
|
||||
const shown = mania(game, [1, 1]);
|
||||
expect(shown).toContain("piness is being");
|
||||
expect(shown).not.toContain("If I take my");
|
||||
expect(game.state.party).toHaveLength(2);
|
||||
expect(game.state.events.has(EVENT_MANIA_TOOK_SHUCKIE_OR_LET_YOU_KEEP_HIM)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Poké Seer (PokeSeer)", () => {
|
||||
/** The Seer stands at (2, 3); choose party slot `slot`. */
|
||||
const seer = (game: GameWorld, slot: number) =>
|
||||
talk(game, SEERS_HOUSE, 2, 4, "up", [slot]);
|
||||
|
||||
it("reads where, when and at what level the player met a Pokémon", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 40, {
|
||||
nickname: "EMBER",
|
||||
caughtData: caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN),
|
||||
}),
|
||||
];
|
||||
const shown = seer(game, 0);
|
||||
expect(shown).toContain("I see all.");
|
||||
expect(shown).toMatch(/I see you met\nEMBER here:\nNEW BARK/);
|
||||
expect(shown).toContain("The time was\nDay!");
|
||||
expect(shown).toContain("Its level was 10!");
|
||||
// Level 40 now, 30 levels on: SeerAdviceTexts' 59 row.
|
||||
expect(shown).toContain("EMBER has\ngrown. It's gained\nmuch strength.");
|
||||
});
|
||||
|
||||
it("compares only the high byte of the OT ID, so some trades read as met", () => {
|
||||
const { game } = world();
|
||||
const caught = caughtData(DAY, 10, 0, LANDMARK_NEW_BARK_TOWN);
|
||||
game.state.party = [
|
||||
mon(game, 155, 12, { otId: game.playerId ^ 0x0100, ot: "RED", caughtData: caught }),
|
||||
mon(game, 155, 12, { otId: game.playerId ^ 0x0001, ot: "RED", caughtData: caught }),
|
||||
];
|
||||
expect(seer(game, 0)).toContain("came from RED\nin a trade?");
|
||||
expect(seer(game, 1)).toContain("I see you met");
|
||||
});
|
||||
|
||||
it("cannot read gifts or blank data, reads only levels for events, and refuses Eggs", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_GIFT }),
|
||||
mon(game, 155, 20, { caughtData: 0 }),
|
||||
mon(game, 155, 20, { caughtData: (15 << 8) | LANDMARK_EVENT }),
|
||||
mon(game, 175, 5, { egg: true }),
|
||||
];
|
||||
expect(seer(game, 0)).toContain("I can't\ntell a thing!");
|
||||
expect(seer(game, 1)).toContain("I can't\ntell a thing!");
|
||||
const event = seer(game, 2);
|
||||
expect(event).toContain("What!? Incredible!");
|
||||
expect(event).not.toContain("The time was");
|
||||
expect(seer(game, 3)).toContain("That's an EGG!");
|
||||
expect(seer(game, -1)).toContain("you'd do nothing!");
|
||||
});
|
||||
|
||||
it("keeps the last caught level when a Pokémon's is unknown", () => {
|
||||
// GetCaughtLevel prints ??? and leaves wSeerCaughtLevel as it was,
|
||||
// which SeerAdvice still subtracts from the level. The port keeps the
|
||||
// Seer's own last reading until the game is loaded again.
|
||||
const { game } = world();
|
||||
game.state.party = [
|
||||
mon(game, 155, 40, { caughtData: caughtData(DAY, 30, 0, LANDMARK_NEW_BARK_TOWN) }),
|
||||
mon(game, 155, 35, { nickname: "SMOKE", caughtData: LANDMARK_NEW_BARK_TOWN }),
|
||||
];
|
||||
seer(game, 0);
|
||||
game.interact(); // still facing the Seer
|
||||
const shown = settle(game, [1]).join(" ");
|
||||
expect(shown).toContain("Its level was ???!");
|
||||
expect(shown).toContain("The time was\nUnknown!");
|
||||
// 35 - 30 = 5: the first row (9), not 35 - 0.
|
||||
expect(shown).toContain("little more care.");
|
||||
// After a load the scratch byte starts at 0 again: 35 - 0 = 35.
|
||||
expect(seer(game, 1)).toContain("much strength.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Photo Studio (PhotoStudio)", () => {
|
||||
/** The photographer stands at (2, 3). */
|
||||
const photo = (game: GameWorld, answers: number[]) =>
|
||||
talk(game, PHOTO_STUDIO, 2, 4, "up", answers);
|
||||
|
||||
it("finds no printer and takes no photo", () => {
|
||||
const { game } = world();
|
||||
game.state.party = [mon(game, 155, 20), mon(game, 175, 5, { egg: true })];
|
||||
const shown = photo(game, [1, 0]);
|
||||
expect(shown).toContain("Which POKéMON\nshould I photo-\ngraph?");
|
||||
expect(shown).toContain("Hold\nstill for a bit.");
|
||||
// PrintPartymon's handshake reads $ff: PRINTER_ERROR_2.
|
||||
expect(shown).toContain(
|
||||
" Printer Error 2\n\nCheck the Game Boy\nPrinter Manual.\n\nPress B to Cancel",
|
||||
);
|
||||
expect(shown).toContain("Oh, no picture?\nCome again, OK?");
|
||||
expect(shown).not.toContain("Presto");
|
||||
expect(photo(game, [1, 1])).toContain("An EGG? My talent\nis worth more…");
|
||||
expect(photo(game, [1, -1])).toContain("Oh, no picture?");
|
||||
});
|
||||
});
|
||||
});
|
||||
+91
-12
@@ -25,6 +25,9 @@ const WARP_EXITS: Partial<Record<number, Direction>> = {
|
||||
0x7e: "right",
|
||||
};
|
||||
|
||||
/** PLAYER_SURF (constants/wram_constants.asm). */
|
||||
const PLAYER_SURF = 4;
|
||||
|
||||
/** Plays the game through the world's own inputs, as tests/playthrough
|
||||
* does: scripts, battles and walking, with a seeded Random. A stage's save
|
||||
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
|
||||
@@ -107,7 +110,7 @@ export class Playthrough {
|
||||
const { engine } = battle,
|
||||
mon = engine.player.mon;
|
||||
const ball = world.liveState.bag.balls.findIndex(
|
||||
(slot) => slot.item === C.POKE_BALL,
|
||||
(slot) => slot.item === C.POKE_BALL || slot.item === C.GREAT_BALL,
|
||||
);
|
||||
const potion = world.liveState.bag.items.findIndex(
|
||||
(slot) => slot.item === C.SUPER_POTION,
|
||||
@@ -121,7 +124,11 @@ export class Playthrough {
|
||||
!world.liveState.party.some((member) => member.species === this.wanted) &&
|
||||
ball >= 0
|
||||
)
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
|
||||
world.battleAction({
|
||||
action: "item",
|
||||
item: world.liveState.bag.balls[ball].item,
|
||||
slot: ball,
|
||||
});
|
||||
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
|
||||
world.battleAction({ action: "run" });
|
||||
else if (
|
||||
@@ -213,7 +220,9 @@ export class Playthrough {
|
||||
if (warp && world.mapId !== map) return;
|
||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y)) {
|
||||
// A surfer crosses water until it steps ashore.
|
||||
const water = world.state.playerState === PLAYER_SURF;
|
||||
for (const direction of findPath(world, x, y, { water })) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
if (!world.move(direction)) {
|
||||
@@ -233,8 +242,41 @@ export class Playthrough {
|
||||
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
|
||||
);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile. */
|
||||
cross = (direction: Direction, target: string) => {
|
||||
/** Walk and Surf to a tile: where a step leaves the shore, face the
|
||||
* water and answer AskSurfScript's question with YES. */
|
||||
sail = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
this.play();
|
||||
expect(world.mapId, `sailing to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y, { water: true })) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
const ahead = world.assets.collision(world.map, from[0] + dx, from[1] + dy);
|
||||
if (world.state.playerState !== PLAYER_SURF && world.assets.permission(ahead) === 1) {
|
||||
world.move(direction); // only turns to face the water
|
||||
world.interact();
|
||||
this.play([1]);
|
||||
expect(world.state.playerState, "surfing").toBe(PLAYER_SURF);
|
||||
} else if (!world.move(direction)) {
|
||||
for (let tick = 0; tick < 64; tick++) world.tick();
|
||||
break;
|
||||
} else this.play();
|
||||
if (
|
||||
world.mapId !== map ||
|
||||
world.x !== from[0] + dx ||
|
||||
world.y !== from[1] + dy
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not sail to ${x},${y} on ${map} from ${world.x},${world.y}`);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile;
|
||||
* `water` lets a player who can Surf reach it over the sea. */
|
||||
cross = (direction: Direction, target: string, { water = false } = {}) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
const [dx, dy] = STEP[direction];
|
||||
@@ -246,14 +288,15 @@ export class Playthrough {
|
||||
: [i, dy < 0 ? 0 : world.map.height - 1],
|
||||
).flatMap(([x, y]) => {
|
||||
try {
|
||||
return [{ x, y, length: findPath(world, x, y).length }];
|
||||
return [{ x, y, length: findPath(world, x, y, { water }).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
edge.sort((a, b) => a.length - b.length);
|
||||
for (const { x, y } of edge) {
|
||||
this.walk(x, y);
|
||||
if (water) this.sail(x, y);
|
||||
else this.walk(x, y);
|
||||
if (world.move(direction)) {
|
||||
this.play();
|
||||
if (world.mapId === target) return;
|
||||
@@ -416,12 +459,45 @@ export class Playthrough {
|
||||
}
|
||||
const from = world.mapId;
|
||||
this.walk(warp.x, warp.y);
|
||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||
if (world.mapId === from && exit && world.move(exit)) this.play();
|
||||
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
|
||||
return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** The fewest warps (stairs, holes, doors) among `maps` to `target`:
|
||||
* breadth first over the places each warp lands, so a maze of floors
|
||||
* whose holes drop into cut-off corners is solved without looping. */
|
||||
travel = (target: string, maps: string[]) => {
|
||||
const world = this.world;
|
||||
const queue = [world.save()];
|
||||
const seen = new Set([`${world.mapId} ${world.x},${world.y}`]);
|
||||
for (let i = 0; i < queue.length && i < 200; i++) {
|
||||
world.restore(queue[i]);
|
||||
const from = world.mapId;
|
||||
for (const warp of world.map.warps) {
|
||||
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
||||
world.restore(queue[i]);
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
this.walk(warp.x, warp.y);
|
||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||
if (world.mapId === from && exit && world.move(exit)) this.play();
|
||||
if (world.mapId === from) continue;
|
||||
if (world.mapId === target) return;
|
||||
const key = `${world.mapId} ${world.x},${world.y}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
queue.push(world.save());
|
||||
}
|
||||
}
|
||||
throw new Error(`No way to ${target} through ${maps.join(", ")}`);
|
||||
};
|
||||
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||
toChoice = () => {
|
||||
const world = this.world,
|
||||
@@ -496,12 +572,13 @@ export class Playthrough {
|
||||
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
|
||||
* item in the stock list, the quantity, YES, back out of the list,
|
||||
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
|
||||
* martmenu/martlist/martquantity choices directly. */
|
||||
* martmenu/martlist/martquantity choices directly. A pharmacy
|
||||
* (MARTTYPE_PHARMACY) opens straight onto its list, with no menu. */
|
||||
buy = (clerk: number, item: number, quantity: number) => {
|
||||
const world = this.world;
|
||||
this.talkTo(clerk);
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(0); // BUY
|
||||
const menu = this.toChoice().kind === "martmenu";
|
||||
if (menu) world.choose(0); // BUY
|
||||
const list = this.toChoice();
|
||||
if (list.kind !== "martlist")
|
||||
throw new Error(`Expected a mart list, got ${list.kind}`);
|
||||
@@ -515,8 +592,10 @@ export class Playthrough {
|
||||
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
|
||||
expect(this.toChoice().kind).toBe("martlist");
|
||||
world.choose(-1); // back to the mart's own menu
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(2); // QUIT
|
||||
if (menu) {
|
||||
expect(this.toChoice().kind).toBe("martmenu");
|
||||
world.choose(2); // QUIT
|
||||
}
|
||||
this.play();
|
||||
};
|
||||
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
|
||||
|
||||
@@ -105,6 +105,44 @@ const EVENT_RELEASED_THE_BEASTS = 0x7b,
|
||||
RAIKOU = 0xf3,
|
||||
ENTEI = 0xf4,
|
||||
ROUTE_42 = "2:5";
|
||||
const ROUTE_38_GATE = "1:9",
|
||||
ROUTE_38 = "1:12",
|
||||
ROUTE_39 = "1:13",
|
||||
OLIVINE = "1:14",
|
||||
OLIVINE_CENTER = "1:1",
|
||||
OLIVINE_GYM = "1:2",
|
||||
OLIVINE_CAFE = "1:7",
|
||||
GOOD_ROD_HOUSE = "1:6",
|
||||
OLIVINE_MART = "1:8",
|
||||
LIGHTHOUSE = ["3:42", "3:43", "3:44", "3:45", "3:46", "3:47"],
|
||||
ROUTE_40 = "22:1",
|
||||
ROUTE_41 = "22:2",
|
||||
CIANWOOD = "22:3",
|
||||
CIANWOOD_GYM = "22:5",
|
||||
CIANWOOD_CENTER = "22:6",
|
||||
CIANWOOD_PHARMACY = "22:7";
|
||||
const GOOD_ROD = 0x3b,
|
||||
SECRETPOTION = 0x43,
|
||||
HM02 = 0xf4,
|
||||
HM03 = 0xf5,
|
||||
HM04 = 0xf6,
|
||||
KRABBY = 98,
|
||||
SURF = 0x39,
|
||||
STRENGTH = 0x46,
|
||||
EVENT_GOT_HM04_STRENGTH = 0x13,
|
||||
EVENT_GOT_GOOD_ROD = 0x18,
|
||||
EVENT_RIVAL_OLIVINE_CITY = 0x6c3,
|
||||
EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS = 0x37,
|
||||
EVENT_FOUGHT_EUSINE = 0x333,
|
||||
EVENT_GOT_SECRETPOTION_FROM_PHARMACY = 0x23,
|
||||
EVENT_BEAT_CHUCK = 0x4c2,
|
||||
EVENT_GOT_TM01_DYNAMICPUNCH = 0xa,
|
||||
EVENT_GOT_HM02_FLY = 0x11,
|
||||
EVENT_JASMINE_RETURNED_TO_GYM = 0x20,
|
||||
EVENT_BEAT_JASMINE = 0x4c1,
|
||||
EVENT_GOT_TM23_IRON_TAIL = 0xd,
|
||||
ENGINE_MINERALBADGE = 31,
|
||||
ENGINE_STORMBADGE = 32;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -131,6 +169,8 @@ describe.skipIf(!existsSync(romPath))(
|
||||
train,
|
||||
stairs,
|
||||
route,
|
||||
travel,
|
||||
sail,
|
||||
toChoice,
|
||||
choose,
|
||||
choosePack,
|
||||
@@ -798,5 +838,191 @@ describe.skipIf(!existsSync(romPath))(
|
||||
reload();
|
||||
writeTestSave("ecruteak-city");
|
||||
}, 240_000);
|
||||
|
||||
it("crosses Routes 38 and 39 to Olivine City, where the rival is waiting", () => {
|
||||
traceFailures();
|
||||
// Ecruteak's west gate and the gate's own west side are edge warps.
|
||||
exit(ROUTE_38_GATE, "left");
|
||||
exit(ROUTE_38, "left");
|
||||
cross("left", ROUTE_39);
|
||||
cross("down", OLIVINE);
|
||||
// OlivineCityRivalSceneTop/Bottom: the way from Route 39 to the
|
||||
// Pokémon Center crosses one of the two coordinate events.
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
expect(p.log.join(" ")).toContain("GYM LEADER isn't\nhere.");
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_OLIVINE_CITY)).toBe(true);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("gets HM04 and the Good Rod, fishes up a Krabby and teaches it Surf", () => {
|
||||
traceFailures();
|
||||
enter(OLIVINE_CAFE, "up");
|
||||
talkTo(p.world.map.objects[0].id); // OLIVINECAFE_SAILOR1
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM04_STRENGTH)).toBe(true);
|
||||
exit(OLIVINE);
|
||||
enter(GOOD_ROD_HOUSE, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_GOT_GOOD_ROD)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(GOOD_ROD);
|
||||
exit(OLIVINE);
|
||||
// Olivine's Mart has Great Balls, not Poké Balls.
|
||||
enter(OLIVINE_MART, "up");
|
||||
buy(p.world.map.objects[0].id, C.GREAT_BALL, 5);
|
||||
exit(OLIVINE);
|
||||
// Typhlosion cannot learn Surf: fish a Krabby (FISHGROUP_SHORE's
|
||||
// Good Rod table) from the nearest shore tile facing open water.
|
||||
const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const;
|
||||
const map = p.world.map;
|
||||
const shore = (Object.keys(STEP) as (keyof typeof STEP)[])
|
||||
.flatMap((face) =>
|
||||
Array.from({ length: map.width * map.height }, (_, i) => {
|
||||
const x = i % map.width,
|
||||
y = Math.floor(i / map.width);
|
||||
const [dx, dy] = STEP[face];
|
||||
if (p.world.assets.collision(map, x + dx, y + dy) !== 0x29) return [];
|
||||
try {
|
||||
return [{ x, y, face, length: findPath(p.world, x, y).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}).flat(),
|
||||
)
|
||||
.sort((a, b) => a.length - b.length)[0];
|
||||
walk(shore.x, shore.y);
|
||||
p.wanted = KRABBY;
|
||||
for (let cast = 0; cast < 40; cast++) {
|
||||
if (p.world.state.party.some((mon) => mon.species === KRABBY)) break;
|
||||
p.world.move(shore.face);
|
||||
expect(p.world.openStartMenu()).toBe(true);
|
||||
choose("PACK");
|
||||
choosePack(GOOD_ROD);
|
||||
choose("USE");
|
||||
play();
|
||||
}
|
||||
p.wanted = 0;
|
||||
const krabby = p.world.state.party.findIndex((mon) => mon.species === KRABBY);
|
||||
expect(krabby).toBeGreaterThan(0);
|
||||
p.teach(HM03, krabby);
|
||||
// Strength replaces Tackle (the first move) on the lead.
|
||||
p.teach(HM04, 0);
|
||||
expect(p.world.state.party[krabby].moves).toContain(SURF);
|
||||
expect(lead().moves).toContain(STRENGTH);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("climbs the Lighthouse to Jasmine and her sick Ampharos", () => {
|
||||
traceFailures();
|
||||
enter(LIGHTHOUSE[0], "up");
|
||||
// Two stairways and the outside ledges lead up; Jasmine stands by
|
||||
// Amphy in 6F's far corner.
|
||||
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
|
||||
talkTo(p.world.map.objects[0].id); // OLIVINELIGHTHOUSE6F_JASMINE
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_JASMINE_EXPLAINED_AMPHYS_SICKNESS)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("wonderful PHARMACY\nin CIANWOOD");
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("drops back down the Lighthouse and surfs Routes 40 and 41 to Cianwood City", () => {
|
||||
traceFailures();
|
||||
// The holes by the windows drop to the floor below; some land in
|
||||
// corners cut off from the next hole, so search the floors.
|
||||
travel(OLIVINE, LIGHTHOUSE);
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
cross("left", ROUTE_40);
|
||||
cross("down", ROUTE_41, { water: true });
|
||||
cross("left", CIANWOOD, { water: true });
|
||||
expect(p.world.mapId).toBe(CIANWOOD);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("meets Suicune and Eusine, gets the SecretPotion and beats Chuck for the STORMBADGE and HM02", () => {
|
||||
traceFailures();
|
||||
sail(23, 44); // ashore by the Pokémon Center
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
// ReleaseTheBeasts armed SCENE_CIANWOODCITY_SUICUNE_AND_EUSINE; its
|
||||
// coordinate event is on the way north.
|
||||
walk(11, 16);
|
||||
expect(p.world.state.events.has(EVENT_FOUGHT_EUSINE)).toBe(true);
|
||||
expect(p.log.join(" ")).toContain("SUICUNE running on\nthe waves.");
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
enter(CIANWOOD_PHARMACY, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_SECRETPOTION_FROM_PHARMACY)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).toContain(SECRETPOTION);
|
||||
// Afterwards the pharmacist runs MARTTYPE_PHARMACY.
|
||||
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
|
||||
exit(CIANWOOD);
|
||||
// The four black belts stand on the way to the boulders.
|
||||
enter(CIANWOOD_GYM, "up");
|
||||
walk(3, 8);
|
||||
exit(CIANWOOD);
|
||||
// Chuck's Poliwrath can win with critical hits; a loss whites out to
|
||||
// Cianwood's Pokémon Center and the boulders go back in place.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (p.world.state.events.has(EVENT_BEAT_CHUCK)) break;
|
||||
heal(CIANWOOD_CENTER, CIANWOOD);
|
||||
enter(CIANWOOD_GYM, "up");
|
||||
walk(3, 8);
|
||||
// AskStrengthScript from the left boulder, then push it up, the
|
||||
// right one up and the middle one left into the gap.
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(5, 8);
|
||||
p.world.move("up");
|
||||
play();
|
||||
walk(5, 7);
|
||||
p.world.move("left");
|
||||
play();
|
||||
walk(4, 2);
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
}
|
||||
expect(p.world.state.events.has(EVENT_BEAT_CHUCK)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_STORMBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM01_DYNAMICPUNCH)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[0]).toBe(1); // TM01
|
||||
exit(CIANWOOD);
|
||||
talkTo(p.world.map.objects[9].id); // CIANWOODCITY_POKEFAN_F
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM02_FLY)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[51]).toBe(1); // HM02
|
||||
reload();
|
||||
writeTestSave("cianwood-city");
|
||||
}, 240_000);
|
||||
|
||||
it("brings the SecretPotion to Amphy and beats Jasmine for the MINERALBADGE and TM23", () => {
|
||||
traceFailures();
|
||||
// Nobody in the party learns Fly; surf back.
|
||||
cross("right", ROUTE_41, { water: true });
|
||||
cross("up", ROUTE_40, { water: true });
|
||||
cross("right", OLIVINE, { water: true });
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
enter(LIGHTHOUSE[0], "up");
|
||||
expect(route(LIGHTHOUSE[5], LIGHTHOUSE, 12, { x: 8, y: 9 })).toBe(true);
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_JASMINE_RETURNED_TO_GYM)).toBe(true);
|
||||
expect(p.world.state.bag.keyItems).not.toContain(SECRETPOTION);
|
||||
travel(OLIVINE, LIGHTHOUSE);
|
||||
heal(OLIVINE_CENTER, OLIVINE);
|
||||
enter(OLIVINE_GYM, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play([1]);
|
||||
expect(p.world.state.events.has(EVENT_BEAT_JASMINE)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_MINERALBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM23_IRON_TAIL)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[22]).toBe(1); // TM23
|
||||
exit(OLIVINE);
|
||||
reload();
|
||||
writeTestSave("olivine-city");
|
||||
}, 240_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,480 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
test("CardFlip_UpdateCursorOAM matches cursorSprites for every cursor position", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
cgb: at("hCGB"),
|
||||
cursorX: at("wCardFlipCursorX"),
|
||||
cursorY: at("wCardFlipCursorY"),
|
||||
shadowOAM: at("wShadowOAMSprite00"),
|
||||
};
|
||||
const updateCursorOAM = routine("CardFlip_UpdateCursorOAM");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, updateCursorOAM }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { cursorSprites } = await import("/src/game/scripts/card-flip.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
// DI; SP=$c7fe; CGB mode so the blink check is skipped, as the port's
|
||||
// own comment assumes; CALL CardFlip_UpdateCursorOAM; sentinel.
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, updateCursorOAM.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, updateCursorOAM.address & 255, updateCursorOAM.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
const OBJ_BASE = 0x100;
|
||||
// ScreenSprite.x/y are plain screen pixels (tile-screen.ts draws them
|
||||
// with the same col*8/row*8 convention as background tiles); hardware
|
||||
// OAM bytes are 8 (x) and 16 (y) beyond that (see unown-puzzle.ts's own
|
||||
// "OAM coordinates are 16 (y) and 8 (x) beyond the screen's").
|
||||
const OAM_Y_OFFSET = 16,
|
||||
OAM_X_OFFSET = 8;
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
// Every cell ChooseCard_HandleJoypad's grid can address (single tiles,
|
||||
// number/Pokémon rows, columns and the paired groups), all 48 of them.
|
||||
for (let cursorY = 0; cursorY < 8; cursorY++)
|
||||
for (let cursorX = 0; cursorX < 6; cursorX++) {
|
||||
write(a.cursorX, cursorX);
|
||||
write(a.cursorY, cursorY);
|
||||
write(a.cgb, 1);
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("CardFlip_UpdateCursorOAM did not return");
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 40 * 4; i++) original.push(read(a.shadowOAM + i));
|
||||
const sprites = cursorSprites(assets.reader, cursorX, cursorY);
|
||||
const native = new Array(160).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
native[i * 4 + 0] = (sprite.y + OAM_Y_OFFSET) & 0xff;
|
||||
native[i * 4 + 1] = (sprite.x + OAM_X_OFFSET) & 0xff;
|
||||
native[i * 4 + 2] = (sprite.tile - OBJ_BASE) & 0xff;
|
||||
native[i * 4 + 3] = (sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0) | (sprite.behind ? 0x80 : 0);
|
||||
});
|
||||
if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ cursorX, cursorY, count: sprites.length, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, updateCursorOAM });
|
||||
expect(result.compared).toBe(48);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("Slots_UpdateReelPositionAndOAM/.SpinReel match reelSprites for reel positions and spin distances", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
// RomReader addresses (as JOHTO.Reel1Tilemap etc. are generated) are flat
|
||||
// ROM-file offsets, bank * $4000 + (address - $4000); CPU addresses
|
||||
// written into the reel struct for the ROM's own code to dereference are
|
||||
// the plain 16-bit address instead.
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) => bank * 0x4000 + (address - 0x4000);
|
||||
const reel1Tilemap = routine("Reel1Tilemap"),
|
||||
reel2Tilemap = routine("Reel2Tilemap"),
|
||||
reel3Tilemap = routine("Reel3Tilemap");
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
wReel1: at("wReel1"),
|
||||
wReel2: at("wReel2"),
|
||||
wReel3: at("wReel3"),
|
||||
reel1Tilemap: reel1Tilemap.address,
|
||||
reel2Tilemap: reel2Tilemap.address,
|
||||
reel3Tilemap: reel3Tilemap.address,
|
||||
reel1TilemapFlat: flat(reel1Tilemap),
|
||||
reel2TilemapFlat: flat(reel2Tilemap),
|
||||
reel3TilemapFlat: flat(reel3Tilemap),
|
||||
shadow16: at("wShadowOAMSprite16"),
|
||||
shadow24: at("wShadowOAMSprite24"),
|
||||
shadow32: at("wShadowOAMSprite32"),
|
||||
};
|
||||
const updateReel = routine("Slots_UpdateReelPositionAndOAM");
|
||||
const spinReel = routine("Slots_SpinReels.SpinReel");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, updateReel, spinReel }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { readReelTilemap, makeReel, reelSprites } = await import("/src/game/scripts/slot-machine.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
// struct fields (engine/games/slot_machine.asm): REEL_ACTION=0,
|
||||
// REEL_TILEMAP_ADDR=1(w), REEL_POSITION=3, REEL_SPIN_DISTANCE=4,
|
||||
// REEL_SPIN_RATE=5, REEL_OAM_ADDR=6(w), REEL_X_COORD=8.
|
||||
const call = (bank: number, address: number, bc: number) => {
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0x01, bc & 255, bc >> 8,
|
||||
0xcd, address & 255, address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("reel routine did not return");
|
||||
};
|
||||
const SYMBOL_BASE_TILE = 0x25;
|
||||
// .LoadOAM writes one hardware 8x16 sprite per column (2 per row, tile
|
||||
// auto-pairing its own bottom half); reelSprites instead draws each as
|
||||
// two separate 8x8 ScreenSprites (dy 0 and 1). Keep only the dy=0
|
||||
// (top) halves, at the even positions of each row's four pushes, to
|
||||
// compare against the ROM's own 8 real OAM entries.
|
||||
const toNative = (sprites: { x: number; y: number; tile: number; palette: number }[]) => {
|
||||
const tops = sprites.filter((_, i) => i % 2 === 0);
|
||||
const oam = new Array(32).fill(0);
|
||||
tops.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
oam[i * 4 + 2] = (sprite.tile - SYMBOL_BASE_TILE) & 0xff;
|
||||
oam[i * 4 + 3] = (sprite.palette & 7) | 0x80; // B_OAM_PRIO, always set
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
const defs = [
|
||||
{ wReel: a.wReel1, tilemapAddr: a.reel1Tilemap, tilemapFlat: a.reel1TilemapFlat, shadow: a.shadow16, xTile: 6 },
|
||||
{ wReel: a.wReel2, tilemapAddr: a.reel2Tilemap, tilemapFlat: a.reel2TilemapFlat, shadow: a.shadow24, xTile: 10 },
|
||||
{ wReel: a.wReel3, tilemapAddr: a.reel3Tilemap, tilemapFlat: a.reel3TilemapFlat, shadow: a.shadow32, xTile: 14 },
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
for (const def of defs) {
|
||||
const tilemap = readReelTilemap(assets.reader, def.tilemapFlat);
|
||||
for (const position of [0, 5, 14]) {
|
||||
write(def.wReel + 1, def.tilemapAddr & 255);
|
||||
write(def.wReel + 2, def.tilemapAddr >> 8);
|
||||
write(def.wReel + 3, position);
|
||||
write(def.wReel + 6, def.shadow & 255);
|
||||
write(def.wReel + 7, def.shadow >> 8);
|
||||
write(def.wReel + 8, def.xTile * 8);
|
||||
call(updateReel.bank, updateReel.address, def.wReel);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 32; i++) original.push(read(def.shadow + i));
|
||||
const reel = makeReel(tilemap);
|
||||
reel.position = position;
|
||||
const native = toNative(reelSprites(reel, def.xTile));
|
||||
if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ reel: def.xTile, position, original, native });
|
||||
compared++;
|
||||
}
|
||||
}
|
||||
// Spin distance: one real .SpinReel tick from a freshly redrawn (subY=0)
|
||||
// reel 1, with the low nibble of the resulting distance left nonzero so
|
||||
// it takes the partial, Y-bytes-only shift path instead of a full
|
||||
// redraw.
|
||||
for (const rate of [5, 11]) {
|
||||
write(a.wReel1 + 0, 0); // REEL_ACTION_DO_NOTHING (so .SpinReel's own ReelActionJumptable call is a no-op)
|
||||
write(a.wReel1 + 1, a.reel1Tilemap & 255);
|
||||
write(a.wReel1 + 2, a.reel1Tilemap >> 8);
|
||||
write(a.wReel1 + 3, 5);
|
||||
write(a.wReel1 + 6, a.shadow16 & 255);
|
||||
write(a.wReel1 + 7, a.shadow16 >> 8);
|
||||
write(a.wReel1 + 8, 6 * 8);
|
||||
call(updateReel.bank, updateReel.address, a.wReel1);
|
||||
write(a.wReel1 + 4, 0); // REEL_SPIN_DISTANCE
|
||||
write(a.wReel1 + 5, rate); // REEL_SPIN_RATE
|
||||
call(spinReel.bank, spinReel.address, a.wReel1);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 32; i++) original.push(read(a.shadow16 + i));
|
||||
const tilemap = readReelTilemap(assets.reader, a.reel1TilemapFlat);
|
||||
const reel = makeReel(tilemap);
|
||||
reel.position = 5;
|
||||
reel.spinDistance = rate;
|
||||
const native = toNative(reelSprites(reel, 6));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ rate, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, updateReel, spinReel });
|
||||
expect(result.compared).toBe(11);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("the sprite-animation engine's Golem, Chansey and Egg OAM match golemSprites/chanseySprites/eggSprites", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
structs: at("wSpriteAnimationStructs"),
|
||||
count: at("wSpriteAnimCount"),
|
||||
oamAddr: at("wCurSpriteOAMAddr"),
|
||||
shadow: at("wShadowOAM"),
|
||||
globalX: at("wGlobalAnimXOffset"),
|
||||
globalY: at("wGlobalAnimYOffset"),
|
||||
};
|
||||
const initStruct = routine("_InitSpriteAnimStruct");
|
||||
const updateFrame = routine("UpdateAnimFrame");
|
||||
const animateGolem = routine("Slots_AnimateGolem");
|
||||
const animateChansey = routine("Slots_AnimateChansey");
|
||||
const animateEgg = routine("SpriteAnimFunc_SlotsChanseyEgg");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { initGolem, stepGolem, golemSprites, initChansey, stepChansey, chanseySprites, stepEgg, eggSprites } = await import(
|
||||
"/src/game/scripts/slot-machine.ts"
|
||||
);
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (bank: number, address: number, program: number[]) => {
|
||||
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
full.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
|
||||
};
|
||||
// SPRITEANIMSTRUCT field offsets (constants/sprite_anim_constants.asm).
|
||||
const VAR3 = 14;
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
|
||||
function run(animId: number, d: number, e: number, animate: { bank: number; address: number }, extra?: () => void) {
|
||||
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
|
||||
write(a.count, 0);
|
||||
write(a.oamAddr, 0);
|
||||
for (let i = 0; i < 32; i++) write(a.shadow + i, 0);
|
||||
// wGlobalAnimXOffset/YOffset are an untracked engine-global leftover,
|
||||
// like the OBJ palette applySlotsAttrs' own doc comment describes;
|
||||
// UpdateAnimFrame adds them into every OAM byte it writes, so pin
|
||||
// them at 0 here to compare the engine's own per-sprite math cleanly.
|
||||
write(a.globalX, 0);
|
||||
write(a.globalY, 0);
|
||||
// LD DE, de; LD A, animId; CALL _InitSpriteAnimStruct.
|
||||
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
|
||||
if (extra) extra();
|
||||
// The struct lands at wSpriteAnimationStructs (the only free slot);
|
||||
// CALL animate(bc=struct); CALL UpdateAnimFrame(bc=struct) to draw it.
|
||||
call(animate.bank, animate.address, [0x01, a.structs & 255, a.structs >> 8]);
|
||||
call(updateFrame.bank, updateFrame.address, [0x01, a.structs & 255, a.structs >> 8]);
|
||||
const oam: number[] = [];
|
||||
for (let i = 0; i < 32; i++) oam.push(read(a.shadow + i));
|
||||
return oam;
|
||||
}
|
||||
// Bit 4 of the attribute byte is set in several of the ROM's own OAM
|
||||
// tables (e.g. $15, $16) but is not a real CGB OBJ attribute bit
|
||||
// (palette 0-2, bank 3, XFLIP 5, YFLIP 6, PRIO 7): it has no effect on
|
||||
// hardware, and golemSprites/chanseySprites/eggSprites do not keep it.
|
||||
// Masked out here rather than reproduced.
|
||||
const ATTR_MASK = 0xe7;
|
||||
const toNative = (sprites: { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean }[]) => {
|
||||
const oam = new Array(32).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
// Only CREATURE_BASE_TILE (this port's own merged-tile-layout
|
||||
// rebasing) comes off; GOLEM_VTILE/CHANSEY_VTILE/EGG_VTILE already
|
||||
// equal the ROM's own per-animal TILE_ID (GetSpriteAnimVTile).
|
||||
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
|
||||
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
const maskAttrs = (oam: number[]) => oam.map((v, i) => (i % 4 === 3 ? v & ATTR_MASK : v));
|
||||
|
||||
// Golem: depixel 12, 13 -> D=$60, E=$68; VAR3=1 so .init falls through
|
||||
// .retain into .fall with VAR1=$30 (matching initGolem(session, 1)),
|
||||
// computing the first real sine step (one stepGolem tick).
|
||||
{
|
||||
const session: { golem?: never } = {};
|
||||
initGolem(session as never, 1);
|
||||
const original = maskAttrs(run(6, 0x60, 0x68, animateGolem, () => write(a.structs + VAR3, 1)));
|
||||
stepGolem(session as never, assets.reader);
|
||||
const native = toNative(golemSprites(assets.reader, session.golem!));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "golem", original, native });
|
||||
compared++;
|
||||
}
|
||||
// Chansey: depixel 12, 0 -> D=$60, E=$00; a single `.walk` tick (the
|
||||
// state Slots_AnimateChansey's own jumptable index 0 starts at).
|
||||
{
|
||||
const session: { chansey?: never } = {};
|
||||
initChansey(session as never);
|
||||
const original = maskAttrs(run(7, 0x60, 0x00, animateChansey));
|
||||
stepChansey(session as never);
|
||||
const native = toNative(chanseySprites(assets.reader, session.chansey!));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "chansey", original, native });
|
||||
compared++;
|
||||
}
|
||||
// Egg: depixel 12, 13, 0, 4 -> D=$60, E=$6c; SpriteAnimFunc_SlotsChanseyEgg
|
||||
// dispatches on JUMPTABLE_INDEX's own parity (not a jumptable), so it
|
||||
// is directly callable the same way. There is no ReelAction_InitChansey-
|
||||
// style helper for just the egg, so its state is built by hand from the
|
||||
// same depixel values (`.spawn_egg`'s own `session.egg = {...}`).
|
||||
{
|
||||
const egg = { counter: 0, xCoord: 0x6c, yOffset: 0 };
|
||||
const original = maskAttrs(run(8, 0x60, 0x6c, animateEgg));
|
||||
stepEgg({ egg } as never, assets.reader);
|
||||
const native = toNative(eggSprites(assets.reader, egg));
|
||||
if (original.some((v, i) => v !== native[i])) failures.push({ which: "egg", original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, failures, count: failures.length };
|
||||
}, { a, initStruct, updateFrame, animateGolem, animateChansey, animateEgg });
|
||||
expect(result.compared).toBe(3);
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
test("PlaySpriteAnimations' Golem, Chansey and Egg sequences match the native steps frame by frame", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
structs: at("wSpriteAnimationStructs"),
|
||||
count: at("wSpriteAnimCount"),
|
||||
shadow: at("wShadowOAM"),
|
||||
globalX: at("wGlobalAnimXOffset"),
|
||||
globalY: at("wGlobalAnimYOffset"),
|
||||
delay: at("wSlotsDelay"),
|
||||
scy: at("hSCY"),
|
||||
};
|
||||
const initStruct = routine("_InitSpriteAnimStruct");
|
||||
const playAnimations = routine("PlaySpriteAnimations");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, initStruct, playAnimations }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const slots = await import("/src/game/scripts/slot-machine.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const call = (bank: number, address: number, program: number[] = []) => {
|
||||
const full = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20, ...program, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
write(0xc0f0, 0);
|
||||
full.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 10 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("sprite anim routine did not return");
|
||||
};
|
||||
// See the first-frame test above: attribute bit 4 is not a CGB OBJ bit.
|
||||
const ATTR_MASK = 0xe7;
|
||||
type Sprite = { x: number; y: number; tile: number; palette: number; flipX?: boolean; flipY?: boolean };
|
||||
const toNative = (sprites: Sprite[]) => {
|
||||
const oam = new Array(160).fill(0);
|
||||
sprites.forEach((sprite, i) => {
|
||||
oam[i * 4 + 0] = (sprite.y + 16) & 0xff;
|
||||
oam[i * 4 + 1] = (sprite.x + 8) & 0xff;
|
||||
oam[i * 4 + 2] = (sprite.tile - 0x100) & 0xff;
|
||||
oam[i * 4 + 3] = ((sprite.palette & 7) | (sprite.flipX ? 0x20 : 0) | (sprite.flipY ? 0x40 : 0)) & ATTR_MASK;
|
||||
});
|
||||
return oam;
|
||||
};
|
||||
type Session = Parameters<typeof slots.stepGolem>[0];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0;
|
||||
|
||||
/** Runs both sides tick by tick. `drive` applies the reel actions'
|
||||
* wSlotsDelay writes to both, between ticks; it returns false to stop. */
|
||||
function sequence(
|
||||
which: string,
|
||||
animId: number,
|
||||
d: number,
|
||||
e: number,
|
||||
setup: (session: Session) => void,
|
||||
var3: number | undefined,
|
||||
drive: (tick: number, delay: number, session: Session) => number | false,
|
||||
) {
|
||||
for (let i = 0; i < 160; i++) write(a.structs + i, 0);
|
||||
write(a.count, 0);
|
||||
write(a.globalX, 0);
|
||||
write(a.globalY, 0);
|
||||
write(a.delay, 0);
|
||||
write(a.scy, 0);
|
||||
call(initStruct.bank, initStruct.address, [0x11, e, d, 0x3e, animId]);
|
||||
if (var3 !== undefined) write(a.structs + 14, var3); // SPRITEANIMSTRUCT_VAR3
|
||||
const session = { delay: 0 } as Session;
|
||||
setup(session);
|
||||
for (let tick = 0; tick < 1000; tick++) {
|
||||
call(playAnimations.bank, playAnimations.address);
|
||||
slots.stepGolem(session, assets.reader);
|
||||
slots.stepChansey(session);
|
||||
slots.stepEgg(session, assets.reader);
|
||||
const original: number[] = [];
|
||||
for (let i = 0; i < 160; i++) original.push(i % 4 === 3 ? read(a.shadow + i) & ATTR_MASK : read(a.shadow + i));
|
||||
const sprites: Sprite[] = [];
|
||||
if (session.golem) sprites.push(...slots.golemSprites(assets.reader, session.golem));
|
||||
if (session.chansey) sprites.push(...slots.chanseySprites(assets.reader, session.chansey));
|
||||
if (session.egg) sprites.push(...slots.eggSprites(assets.reader, session.egg));
|
||||
const native = toNative(sprites);
|
||||
const delay = read(a.delay);
|
||||
if (delay !== session.delay) failures.push({ which, tick, delay, native: session.delay });
|
||||
else if (read(a.scy) !== (session.scy ?? 0)) failures.push({ which, tick, scy: read(a.scy), native: session.scy });
|
||||
else if (original.some((v, i) => v !== native[i]))
|
||||
failures.push({ which, tick, original: original.slice(0, 32), native: native.slice(0, 32) });
|
||||
compared++;
|
||||
if (failures.length) return tick;
|
||||
const next = drive(tick, delay, session);
|
||||
if (next === false) return tick;
|
||||
write(a.delay, next);
|
||||
session.delay = next;
|
||||
}
|
||||
throw Error(`${which} did not finish`);
|
||||
}
|
||||
|
||||
// Golem: three throws. ReelAction_WaitGolem/EndGolem answer each landing
|
||||
// (wSlotsDelay 1) by clearing it again a few ticks later; the sequence
|
||||
// ends two ticks after .init's last-Golem branch (wSlotsDelay 2).
|
||||
let landed = -1, finished = -1;
|
||||
const golemTicks = sequence("golem", 6, 0x60, 0x68, (s) => slots.initGolem(s, 3), 3, (tick, delay) => {
|
||||
if (delay === 1 && landed < 0) landed = tick;
|
||||
if (landed >= 0 && tick === landed + 4) { landed = -1; return 0; }
|
||||
if (delay === 2 && finished < 0) finished = tick;
|
||||
if (finished >= 0 && tick === finished + 2) return false;
|
||||
return delay;
|
||||
});
|
||||
|
||||
// Chansey: walks in (wSlotsDelay 1), ReelAction_WaitChansey answers with
|
||||
// 2, the egg rolls out (4), DropReel's EggAgain asks for another (1, then
|
||||
// 2 again), and the second egg ends the round (5, Chansey removed).
|
||||
let eggs = 0, at = -1, done = -1;
|
||||
const chanseyTicks = sequence("chansey", 7, 0x60, 0x00, (s) => slots.initChansey(s), undefined, (tick, delay) => {
|
||||
if (delay === 1 && at < 0) at = tick;
|
||||
if (delay === 1 && tick === at + 3) { at = -1; return 2; }
|
||||
if (delay === 4) {
|
||||
if (at < 0) at = tick;
|
||||
if (tick === at + 17) {
|
||||
at = -1;
|
||||
return ++eggs === 1 ? 1 : 5;
|
||||
}
|
||||
}
|
||||
if (delay === 5 && done < 0) done = tick;
|
||||
if (done >= 0 && tick === done + 2) return false;
|
||||
return delay;
|
||||
});
|
||||
reference.dispose();
|
||||
return { compared, golemTicks, chanseyTicks, eggs, failures: failures.slice(0, 3), count: failures.length };
|
||||
}, { a, initStruct, playAnimations });
|
||||
expect(result, JSON.stringify(result)).toMatchObject({ count: 0, eggs: 2 });
|
||||
// Three throws of 17 falling and 37 rolling ticks each, plus the last .init.
|
||||
expect(result.golemTicks).toBe(167);
|
||||
expect(result.chanseyTicks).toBe(216);
|
||||
expect(result.compared).toBe(168 + 217);
|
||||
});
|
||||
@@ -232,6 +232,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
party: Array.from({ length: input.otPartyCount }, () => ({})) as never,
|
||||
aiFlags: 0,
|
||||
switchFlags: 0,
|
||||
items: [0, 0],
|
||||
baseReward: 0,
|
||||
reward: 0,
|
||||
},
|
||||
random: () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ test("all Smart AI move handlers match original scores and Random consumption",
|
||||
const party = [{ species: 143, dvs: 0x8888, hp: 100, status: 0 }, { species: 16, hp: next(2) ? 100 : 0, status: next(256) }];
|
||||
const enemyParty = structuredClone(party);
|
||||
enemyParty[1].hp = next(2) ? 100 : 0;
|
||||
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, reward: 0 } });
|
||||
const battle = new Battle({ data: assets.pokemon, party: party as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1, random: () => bytes[draws++], text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() }, trainer: { name: "", className: "", party: enemyParty as never, aiFlags: 16, switchFlags: 0, items: [0, 0], baseReward: 0, reward: 0 } });
|
||||
const internal = battle as unknown as Record<string, any>;
|
||||
internal.battleMode = 2;
|
||||
internal.enemySpecies = 143;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
// TrainerClassAttributes AI_ITEM_SWITCH bits (constants/trainer_data_constants.asm).
|
||||
const ALWAYS_USE = 1 << 4,
|
||||
UNKNOWN_USE = 1 << 5,
|
||||
CONTEXT_USE = 1 << 6;
|
||||
|
||||
/**
|
||||
* Compares the native AI_TryItem (engine/battle/ai/items.asm) against the
|
||||
* unmodified routine in the reference emulator, called directly (not
|
||||
* through AI_SwitchOrTryItem, whose switch-side dispatch predates this
|
||||
* port and is exercised by the existing switch tests/playthrough). Random
|
||||
* is redirected to a fixed byte stream shared by both runs, so the number
|
||||
* of draws is part of the comparison alongside the modeled state: the two item slots, the base reward (the misread-item bug), the
|
||||
* enemy's HP/status/substatuses, its stat levels, the turn-ending
|
||||
* substatus resets and wEnemyGoesFirst.
|
||||
*
|
||||
* The AI context/switch flags (CONTEXT_USE, ALWAYS_USE, UNKNOWN_USE) are
|
||||
* read live from TrainerClassAttributes by class, so eight reserved
|
||||
* classes (1-8) have that one byte patched into the ROM copy with every
|
||||
* combination; every other table this routine reads (AI_Items' item ids)
|
||||
* is exercised as native code, not re-read from ROM.
|
||||
*/
|
||||
test("AI_TryItem matches the original across items, flags, HP, status and turn state", async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
const { at, routine } = loadSymbols();
|
||||
const names = [
|
||||
"hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward",
|
||||
"wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wOTPartyMon1HP",
|
||||
"wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP",
|
||||
"wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken",
|
||||
"wEnemyGoesFirst", "wEnemyFuryCutterCount", "wEnemyProtectCount", "wEnemyRageCounter",
|
||||
"wLastEnemyCounterMove", "wEnemyStatLevels", "wEnemyStats", "hBattleTurn", "wLinkMode", "wBattleMode",
|
||||
"wAttackMissed", "wEffectFailed",
|
||||
];
|
||||
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
|
||||
const commands = { tryItem: routine("AI_TryItem"), random: routine("Random") };
|
||||
const trainerClassAttributes = routine("TrainerClassAttributes");
|
||||
// bank*0x4000 + address-0x4000: the ROM file offset (scripts/check-reference.mjs's formula).
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) =>
|
||||
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
|
||||
const attributesFlat = flat(trainerClassAttributes);
|
||||
// A used item prints text and plays a sound, and the heal items animate
|
||||
// the HP bar and redraw the HUD; all four wait on VBlank-driven input or
|
||||
// timing that this isolated, interrupt-disabled call never provides, so
|
||||
// they are neutered like Random. None of their own effects (the screen,
|
||||
// audio and the HP bar's visual fill) are part of this comparison; the
|
||||
// WRAM state AI_TryItem changes before and around them is.
|
||||
const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name)));
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(
|
||||
async ({ a, commands, attributesFlat, bits, presentationFlats }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
// Patch Random (reference-smart-ai.spec.ts's stub and stream) and, for
|
||||
// eight reserved trainer classes (1-8), the AI_ITEM_SWITCH byte each
|
||||
// flag combination needs; AI_TryItem reads it live from ROM by class.
|
||||
const patched = rom.slice();
|
||||
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
|
||||
for (const p of presentationFlats) patched[p] = 0xc9; // ret
|
||||
const NUM_TRAINER_ATTRIBUTES = 7, TRNATTR_AI_ITEM_SWITCH = 5;
|
||||
bits.forEach((flags: number, i: number) => {
|
||||
const p = attributesFlat + i * NUM_TRAINER_ATTRIBUTES + TRNATTR_AI_ITEM_SWITCH;
|
||||
patched[p] = flags;
|
||||
patched[p + 1] = 0;
|
||||
});
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(patched, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readWord = (p: number) => (read(p) << 8) | read(p + 1);
|
||||
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
|
||||
stub.forEach((v, i) => write(0xc080 + i, v));
|
||||
const { bank, address } = commands.tryItem;
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
|
||||
let seed = 0x9a7c3;
|
||||
const next = (limit = 256) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return (seed >>> 16) % limit; };
|
||||
|
||||
// Items in AI_Items' scan order, with 0 standing for NO_ITEM.
|
||||
const I = { FR: 0x0e, MP: 0x0f, HP: 0x10, SP: 0x11, PO: 0x12, XAC: 0x21, FH: 0x26, GS: 0x29, DH: 0x2c, XAT: 0x31, XDE: 0x33, XSP: 0x34, XSC: 0x35 };
|
||||
const itemPairs: [number, number][] = [
|
||||
[0, 0], [I.FR, 0], [I.MP, 0], [I.HP, I.SP], [I.PO, I.XAC], [I.FH, I.GS], [I.DH, I.XAT],
|
||||
[I.XDE, 0], [I.XSP, 0], [I.XSC, 0], [I.FH, I.HP], // last pair: the stuck-pointer scenario
|
||||
];
|
||||
const hpCases = [[100, 100], [30, 100], [10, 100], [25, 100], [26, 100], [50, 100], [51, 100], [25, 101]];
|
||||
const statusCases: { status: number; toxic?: number }[] = [
|
||||
{ status: 0 }, { status: 1 << 5 /* FRZ */ }, { status: 3 /* asleep */ },
|
||||
{ status: 1 << 3 /* PSN */, toxic: 5 },
|
||||
];
|
||||
const failures: unknown[] = [];
|
||||
let compared = 0, maxDraws = 0;
|
||||
for (let flagIndex = 0; flagIndex < bits.length; flagIndex++)
|
||||
for (const [item1, item2] of itemPairs)
|
||||
for (const [hp, maxHp] of hpCases)
|
||||
for (const { status, toxic } of statusCases)
|
||||
for (const turnsTaken of [0, 1]) {
|
||||
const classId = flagIndex + 1;
|
||||
const [attackMissed, effectFailed] = [[0, 0], [1, 0], [0, 1]][compared % 3];
|
||||
const bytes = Array.from({ length: 64 }, () => next());
|
||||
let draws = 0;
|
||||
const party = [{ level: 30, status: 0, hp: 50 }];
|
||||
const trainer = {
|
||||
name: "", className: "", party: party as never, aiFlags: 0, switchFlags: bits[flagIndex],
|
||||
items: [item1, item2] as [number, number], baseReward: 25, reward: 0,
|
||||
};
|
||||
const battle = new Battle({
|
||||
data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1,
|
||||
random: () => bytes[draws++], text: () => ({}) as never,
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
trainer,
|
||||
});
|
||||
battle.turn = 1;
|
||||
battle.attackMissed = attackMissed;
|
||||
battle.effectFailed = effectFailed;
|
||||
battle.curOTMon = 0;
|
||||
battle.enemy.mon = {
|
||||
species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70,
|
||||
level: 30, status, hp, maxHp, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA",
|
||||
} as never;
|
||||
battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
battle.enemy.sub = [0, 0, 0, (1 << 0) | (1 << 7) /* Bide, Confused */, 1 << 6 /* Rage */, toxic ? 1 : 0];
|
||||
battle.enemy.toxicCount = toxic ?? 0;
|
||||
battle.enemy.turnsTaken = turnsTaken;
|
||||
battle.enemy.furyCutterCount = 3;
|
||||
battle.enemy.protectCount = 2;
|
||||
battle.enemy.rageCounter = 4;
|
||||
battle.enemy.lastCounterMove = 33;
|
||||
battle.enemyGoesFirst = 0;
|
||||
const internal = battle as unknown as { aiTryItem(): Generator<unknown, boolean, unknown> };
|
||||
const gen = internal.aiTryItem();
|
||||
for (let step = gen.next(); !step.done; step = gen.next());
|
||||
const nativeDraws = draws;
|
||||
|
||||
write(a.wInBattleTowerBattle, 0);
|
||||
write(a.wTrainerClass, classId);
|
||||
write(a.wEnemyTrainerItem1, item1);
|
||||
write(a.wEnemyTrainerItem2, item2);
|
||||
write(a.wEnemyTrainerBaseReward, 25);
|
||||
write(a.wOTPartyCount, 1);
|
||||
write(a.wOTPartyMon1Level, 30);
|
||||
write(a.wOTPartyMon1Status, 0);
|
||||
word(a.wOTPartyMon1HP, 50);
|
||||
write(a.wCurOTMon, 0);
|
||||
write(a.wEnemyMonStatus, status);
|
||||
word(a.wEnemyMonHP, hp);
|
||||
word(a.wEnemyMonMaxHP, maxHp);
|
||||
write(a.wEnemySubStatus3, (1 << 0) | (1 << 7));
|
||||
write(a.wEnemySubStatus4, 1 << 6);
|
||||
write(a.wEnemySubStatus5, toxic ? 1 : 0);
|
||||
write(a.wEnemyToxicCount, toxic ?? 0);
|
||||
write(a.wEnemyTurnsTaken, turnsTaken);
|
||||
write(a.wEnemyGoesFirst, 0);
|
||||
write(a.wEnemyFuryCutterCount, 3);
|
||||
write(a.wEnemyProtectCount, 2);
|
||||
write(a.wEnemyRageCounter, 4);
|
||||
write(a.wLastEnemyCounterMove, 33);
|
||||
for (let i = 0; i < 8; i++) write(a.wEnemyStatLevels + i, 7);
|
||||
for (let i = 0; i < 5; i++) word(a.wEnemyStats + i * 2, 30);
|
||||
write(a.hBattleTurn, 1);
|
||||
write(a.wLinkMode, 0);
|
||||
write(a.wBattleMode, 2);
|
||||
// RaiseStat (not part of this port) gates on these without
|
||||
// AI_TryItem ever clearing them; both runs start a turn
|
||||
// with the same stale flags; successful, missed and failed
|
||||
// previous moves all occur in this sweep.
|
||||
write(a.wAttackMissed, attackMissed);
|
||||
write(a.wEffectFailed, effectFailed);
|
||||
bytes.forEach((v, i) => write(0xc100 + i, v));
|
||||
write(0xc0e0, 0);
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return");
|
||||
|
||||
const original = {
|
||||
used: read(a.wEnemyGoesFirst) === 1,
|
||||
item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2),
|
||||
baseReward: read(a.wEnemyTrainerBaseReward),
|
||||
status: read(a.wEnemyMonStatus), hp: readWord(a.wEnemyMonHP),
|
||||
partyStatus: read(a.wOTPartyMon1Status), partyHP: readWord(a.wOTPartyMon1HP),
|
||||
sub3: read(a.wEnemySubStatus3), sub4: read(a.wEnemySubStatus4), sub5: read(a.wEnemySubStatus5),
|
||||
furyCutterCount: read(a.wEnemyFuryCutterCount), protectCount: read(a.wEnemyProtectCount),
|
||||
rageCounter: read(a.wEnemyRageCounter), lastCounterMove: read(a.wLastEnemyCounterMove),
|
||||
levels: Array.from({ length: 7 }, (_, i) => read(a.wEnemyStatLevels + i)),
|
||||
draws: read(0xc0e0),
|
||||
};
|
||||
const native = {
|
||||
used: battle.enemyGoesFirst === 1,
|
||||
item1: trainer.items[0], item2: trainer.items[1],
|
||||
baseReward: trainer.baseReward,
|
||||
status: battle.enemy.mon.status, hp: battle.enemy.mon.hp,
|
||||
partyStatus: party[0].status, partyHP: party[0].hp,
|
||||
sub3: battle.enemy.sub[3], sub4: battle.enemy.sub[4], sub5: battle.enemy.sub[5],
|
||||
furyCutterCount: battle.enemy.furyCutterCount, protectCount: battle.enemy.protectCount,
|
||||
rageCounter: battle.enemy.rageCounter, lastCounterMove: battle.enemy.lastCounterMove,
|
||||
levels: battle.enemy.levels.slice(),
|
||||
draws: nativeDraws,
|
||||
};
|
||||
maxDraws = Math.max(maxDraws, native.draws, original.draws);
|
||||
if (JSON.stringify(original) !== JSON.stringify(native))
|
||||
failures.push({ flagIndex, item1, item2, hp, maxHp, status, toxic, turnsTaken, original, native });
|
||||
compared++;
|
||||
}
|
||||
reference.dispose();
|
||||
return { compared, maxDraws, failures, count: failures.length };
|
||||
},
|
||||
{ a: addresses, commands, attributesFlat, presentationFlats, bits: [0, ALWAYS_USE, UNKNOWN_USE, CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE, ALWAYS_USE | CONTEXT_USE, UNKNOWN_USE | CONTEXT_USE, ALWAYS_USE | UNKNOWN_USE | CONTEXT_USE] },
|
||||
);
|
||||
expect(result.compared).toBe(8 * 11 * 8 * 4 * 2);
|
||||
expect(result, JSON.stringify(result.failures.slice(0, 3))).toMatchObject({ count: 0, failures: [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* The base-reward-misread bug (docs/bugs_and_glitches.md "AI might use its
|
||||
* base reward value as an item"): no real trainer's base reward aliases an
|
||||
* AI_Items id (every two-item trainer's is 25), so this sets one up by
|
||||
* hand and checks both engines agree the AI "uses" Full Heal by zeroing
|
||||
* the reward rather than either real item slot.
|
||||
*/
|
||||
for (const entry of ["direct", "no-switch-style", "mean-look", "wrap"] as const)
|
||||
test(`Trainer item base-reward collision matches the original through ${entry}`, async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const names = ["hROMBank", "wInBattleTowerBattle", "wEnemyTrainerItem1", "wEnemyTrainerItem2", "wEnemyTrainerBaseReward", "wTrainerClass", "wOTPartyCount", "wOTPartyMon1Level", "wOTPartyMon1Status", "wCurOTMon", "wEnemyMonStatus", "wEnemyMonHP", "wEnemyMonMaxHP", "wEnemySubStatus3", "wEnemySubStatus4", "wEnemySubStatus5", "wEnemyToxicCount", "wEnemyTurnsTaken", "wEnemyGoesFirst", "hBattleTurn", "wLinkMode", "wBattleMode", "wPlayerSubStatus5", "wEnemyWrapCount", "wEnemySubStatus1"];
|
||||
const addresses = Object.fromEntries(names.map((name) => [name, at(name)]));
|
||||
const commands = { tryItem: routine(entry === "direct" ? "AI_TryItem" : "AI_SwitchOrTryItem"), random: routine("Random") };
|
||||
const trainerClassAttributes = routine("TrainerClassAttributes");
|
||||
const flat = ({ bank, address }: { bank: number; address: number }) => (address < 0x4000 ? address : bank * 0x4000 + address - 0x4000);
|
||||
const attributesFlat = flat(trainerClassAttributes);
|
||||
// See the main sweep above: these four are presentation-only and would
|
||||
// otherwise wait on input/timing this isolated call never provides.
|
||||
const presentationFlats = ["PrintText", "PlaySFX", "AnimateHPBar", "UpdateEnemyHUD"].map((name) => flat(routine(name)));
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, commands, attributesFlat, presentationFlats, entry }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const assets = new CrystalAssets(rom);
|
||||
const patched = rom.slice();
|
||||
patched.set([0xc3, 0x80, 0xc0], commands.random.address);
|
||||
for (const p of presentationFlats) patched[p] = 0xc9; // ret
|
||||
// Class 1's AI_ITEM_SWITCH byte: CONTEXT_USE.
|
||||
patched[attributesFlat + 5] = 0x40;
|
||||
patched[attributesFlat + 6] = 0;
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(patched, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const word = (p: number, v: number) => { write(p, v >> 8); write(p + 1, v); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readWord = (p: number) => (read(p) << 8) | read(p + 1);
|
||||
const stub = [0xe5, 0xd5, 0xfa, 0xe0, 0xc0, 0x5f, 0x16, 0, 0x21, 0, 0xc1, 0x19, 0x3c, 0xea, 0xe0, 0xc0, 0x7e, 0xd1, 0xe1, 0xc9];
|
||||
stub.forEach((v, i) => write(0xc080 + i, v));
|
||||
const { bank, address } = commands.tryItem;
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, bank, 0xe0, a.hROMBank & 255, 0xea, 0, 0x20, 0xcd, address & 255, address >> 8, 0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe];
|
||||
for (let i = 0; i < 16; i++) write(0xc100 + i, 0); // never drawn, but kept tidy
|
||||
const item1 = 0, item2 = 0x10 /* HYPER_POTION */, baseReward = 0x26; /* FULL_HEAL */
|
||||
const trainer = { name: "", className: "", party: [{ level: 30, status: 0, hp: 50 }] as never, aiFlags: 0, switchFlags: 0x40, items: [item1, item2] as [number, number], baseReward, reward: 0 };
|
||||
const battle = new Battle({
|
||||
data: assets.pokemon, party: [] as never, playerId: 1, playerName: "CHRIS", timeOfDay: 1,
|
||||
random: () => 0, text: () => ({}) as never, pokedex: { seen: new Set(), caught: new Set() },
|
||||
trainer,
|
||||
});
|
||||
battle.turn = 1;
|
||||
battle.curOTMon = 0;
|
||||
battle.enemy.mon = { species: 19, item: 0, moves: [33, 0, 0, 0], pp: [35, 0, 0, 0], dvs: 0, happiness: 70, level: 30, status: 1 << 5, hp: 100, maxHp: 100, stats: [30, 30, 30, 30, 30], types: [0, 0], nickname: "RATTATA" } as never;
|
||||
battle.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
battle.enemyGoesFirst = 0;
|
||||
if (entry === "mean-look") battle.player.set(5, 7);
|
||||
if (entry === "wrap") battle.enemy.wrapCount = 2;
|
||||
const internal = battle as unknown as {
|
||||
aiTryItem(): Generator<unknown, boolean, unknown>;
|
||||
aiSwitchOrTryItem(): Generator<unknown, boolean, unknown>;
|
||||
};
|
||||
const gen = entry === "direct" ? internal.aiTryItem() : internal.aiSwitchOrTryItem();
|
||||
for (let step = gen.next(); !step.done; step = gen.next());
|
||||
|
||||
write(a.wInBattleTowerBattle, 0);
|
||||
write(a.wTrainerClass, 1);
|
||||
write(a.wEnemyTrainerItem1, item1);
|
||||
write(a.wEnemyTrainerItem2, item2);
|
||||
write(a.wEnemyTrainerBaseReward, baseReward);
|
||||
write(a.wOTPartyCount, 1);
|
||||
write(a.wOTPartyMon1Level, 30);
|
||||
write(a.wOTPartyMon1Status, 0);
|
||||
write(a.wCurOTMon, 0);
|
||||
write(a.wEnemyMonStatus, 1 << 5);
|
||||
word(a.wEnemyMonHP, 100);
|
||||
word(a.wEnemyMonMaxHP, 100);
|
||||
write(a.wEnemySubStatus3, 0);
|
||||
write(a.wEnemySubStatus4, 0);
|
||||
write(a.wEnemySubStatus5, 0);
|
||||
write(a.wEnemyToxicCount, 0);
|
||||
write(a.wEnemyTurnsTaken, 0);
|
||||
write(a.wEnemyGoesFirst, 0);
|
||||
write(a.hBattleTurn, 1);
|
||||
write(a.wLinkMode, 0);
|
||||
write(a.wBattleMode, 2);
|
||||
write(a.wPlayerSubStatus5, entry === "mean-look" ? 0x80 : 0);
|
||||
write(a.wEnemyWrapCount, entry === "wrap" ? 2 : 0);
|
||||
write(a.wEnemySubStatus1, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
write(0xc0f0, 0);
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw new Error("AI_TryItem did not return");
|
||||
const final = {
|
||||
original: { used: read(a.wEnemyGoesFirst) === 1, item1: read(a.wEnemyTrainerItem1), item2: read(a.wEnemyTrainerItem2), baseReward: read(a.wEnemyTrainerBaseReward), status: read(a.wEnemyMonStatus) },
|
||||
native: { used: battle.enemyGoesFirst === 1, item1: trainer.items[0], item2: trainer.items[1], baseReward: trainer.baseReward, status: battle.enemy.mon.status },
|
||||
};
|
||||
reference.dispose();
|
||||
return final;
|
||||
}, { a: addresses, commands, attributesFlat, presentationFlats, entry });
|
||||
expect(result.native).toEqual(result.original);
|
||||
expect(result.native).toEqual({ used: true, item1: 0, item2: 0x10, baseReward: 0, status: 0 });
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
|
||||
|
||||
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs private ROM and reference symbols.");
|
||||
|
||||
// BurnedTower1F's rival (map object 3) is OBJECTTYPE_TRAINER with sight 3,
|
||||
// but its script pointer is the generic ObjectEvent (home/map.asm's
|
||||
// `jumptextfaceplayer ObjectEventText`), not a TrainerHeader: see
|
||||
// maps/BurnedTower1F.asm's `object_event 8, 9, SPRITE_RIVAL, ..., 0,
|
||||
// OBJECTTYPE_TRAINER, 3, ObjectEvent, EVENT_RIVAL_BURNED_TOWER`. After
|
||||
// BurnedTowerRivalBattleScript the rival stands at (9,9) facing right and
|
||||
// the player at (10,9). _CheckTrainerBattle reads the "beaten" flag from
|
||||
// the script pointer's first two bytes: jumptextfaceplayer_command ($51)
|
||||
// and ObjectEventText's low byte ($f2), i.e. flag $f251. EventFlagAction's
|
||||
// bit math (de >> 3 added to wEventFlags) overflows by $1e4a bytes into
|
||||
// echo RAM, landing on wNumKeyItems (bank 1, $d8bc) bit 1.
|
||||
test("CheckTrainerBattle reads BurnedTower1F's rival as beaten only when wNumKeyItems' bit 1 is set", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
lastTalked: at("hLastTalked"),
|
||||
mapScriptsBank: at("wMapScriptsBank"),
|
||||
playerMapX: at("wPlayerMapX"),
|
||||
playerMapY: at("wPlayerMapY"),
|
||||
map3Object: at("wMap3Object"),
|
||||
object1Struct: at("wObject1Struct"),
|
||||
numKeyItems: at("wNumKeyItems"),
|
||||
seenTrainerDistance: at("wSeenTrainerDistance"),
|
||||
seenTrainerDirection: at("wSeenTrainerDirection"),
|
||||
tempTrainerEventFlag: at("wTempTrainerEventFlag"),
|
||||
tempTrainerClass: at("wTempTrainerClass"),
|
||||
tempTrainerID: at("wTempTrainerID"),
|
||||
seenTextPointer: at("wSeenTextPointer"),
|
||||
winTextPointer: at("wWinTextPointer"),
|
||||
lossTextPointer: at("wLossTextPointer"),
|
||||
scriptAfterPointer: at("wScriptAfterPointer"),
|
||||
runningTrainerBattleScript: at("wRunningTrainerBattleScript"),
|
||||
};
|
||||
const checkTrainerBattle = routine("CheckTrainerBattle");
|
||||
const objectEvent = at("ObjectEvent");
|
||||
const mapScriptsBank = routine("BurnedTower1F_MapScripts").bank;
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, checkTrainerBattle, objectEvent, mapScriptsBank }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const ptr = (p: number, v: number) => { write(p, v); write(p + 1, v >> 8); };
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
const readPtr = (p: number) => read(p) | (read(p + 1) << 8);
|
||||
// DI; SP=$c7fe; CheckTrainerBattle is home code, bank is irrelevant;
|
||||
// CALL CheckTrainerBattle; branch on carry into $c0f1, then the sentinel
|
||||
// at $c0f0, as the other reference fixtures do. Both live away from any
|
||||
// battle or trainer variable.
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, checkTrainerBattle.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, checkTrainerBattle.address & 255, checkTrainerBattle.address >> 8,
|
||||
0x38, 3, 0xaf, 0x18, 2, 0x3e, 1, 0xea, 0xf1, 0xc0,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
|
||||
const results: unknown[] = [];
|
||||
for (let numKeyItems = 0; numKeyItems <= 6; numKeyItems++) {
|
||||
// The rival: a trainer with sight 3 whose script pointer is the
|
||||
// generic ObjectEvent, standing at (9,9) facing right; the player at
|
||||
// (10,9), one tile to the right.
|
||||
write(a.mapScriptsBank, mapScriptsBank);
|
||||
write(a.playerMapX, 10);
|
||||
write(a.playerMapY, 9);
|
||||
write(a.map3Object + 0, 1); // MAPOBJECT_OBJECT_STRUCT_ID -> wObject1Struct
|
||||
write(a.map3Object + 1, 1); // MAPOBJECT_SPRITE (nonzero: has a sprite)
|
||||
write(a.map3Object + 8, 2); // MAPOBJECT_TYPE: OBJECTTYPE_TRAINER
|
||||
write(a.map3Object + 9, 3); // MAPOBJECT_SIGHT_RANGE
|
||||
ptr(a.map3Object + 0xa, objectEvent); // MAPOBJECT_SCRIPT_POINTER -> ObjectEvent
|
||||
write(a.object1Struct + 0x10, 9); // OBJECT_MAP_X
|
||||
write(a.object1Struct + 0x11, 9); // OBJECT_MAP_Y
|
||||
write(a.object1Struct + 0x08, 0x0c); // OBJECT_DIRECTION: facing right (OW_RIGHT)
|
||||
write(a.numKeyItems, numKeyItems);
|
||||
// Sentinels: only a reported sighting overwrites these.
|
||||
write(a.lastTalked, 0xff);
|
||||
write(a.seenTrainerDistance, 0xff);
|
||||
write(a.seenTrainerDirection, 0xff);
|
||||
ptr(a.tempTrainerEventFlag, 0xffff);
|
||||
write(a.tempTrainerClass, 0xff);
|
||||
write(a.tempTrainerID, 0xff);
|
||||
ptr(a.seenTextPointer, 0xffff);
|
||||
ptr(a.winTextPointer, 0xffff);
|
||||
ptr(a.lossTextPointer, 0xffff);
|
||||
ptr(a.scriptAfterPointer, 0xffff);
|
||||
write(a.runningTrainerBattleScript, 0xff);
|
||||
write(0xc0f0, 0);
|
||||
write(0xc0f1, 0xff);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("CheckTrainerBattle did not return");
|
||||
results.push({
|
||||
numKeyItems,
|
||||
carry: read(0xc0f1) === 1,
|
||||
lastTalked: read(a.lastTalked),
|
||||
seenTrainerDistance: read(a.seenTrainerDistance),
|
||||
seenTrainerDirection: read(a.seenTrainerDirection),
|
||||
tempTrainer: {
|
||||
eventFlag: readPtr(a.tempTrainerEventFlag),
|
||||
class: read(a.tempTrainerClass),
|
||||
id: read(a.tempTrainerID),
|
||||
seenText: readPtr(a.seenTextPointer),
|
||||
winText: readPtr(a.winTextPointer),
|
||||
lossText: readPtr(a.lossTextPointer),
|
||||
scriptAfter: readPtr(a.scriptAfterPointer),
|
||||
runningTrainerBattleScript: read(a.runningTrainerBattleScript),
|
||||
},
|
||||
});
|
||||
}
|
||||
reference.dispose();
|
||||
return results;
|
||||
}, { a, checkTrainerBattle, objectEvent, mapScriptsBank });
|
||||
|
||||
// Bit 1 of wNumKeyItems (2, 3, 6) makes the overflowed flag read set, so
|
||||
// the original treats the rival as already beaten and skips it; every
|
||||
// other count reports the sighting, copying ObjectEvent's bytes as a
|
||||
// (nonsensical) trainer header into wTempTrainer.
|
||||
const sentinelPtr = { eventFlag: 0xffff, class: 0xff, id: 0xff, seenText: 0xffff, winText: 0xffff, lossText: 0xffff, scriptAfter: 0xffff, runningTrainerBattleScript: 0xff };
|
||||
const decodedHeader = { eventFlag: 0xf251, class: 0x26, id: 0x16, seenText: 0x46ee, winText: 0x5071, lossText: 0xfa53, scriptAfter: 0x1626, runningTrainerBattleScript: 0 };
|
||||
const expected = [0, 1, 2, 3, 4, 5, 6].map((numKeyItems) => {
|
||||
const beaten = (numKeyItems & 2) !== 0;
|
||||
return {
|
||||
numKeyItems,
|
||||
carry: !beaten,
|
||||
lastTalked: beaten ? 0xff : 3,
|
||||
seenTrainerDistance: beaten ? 0xff : 1,
|
||||
seenTrainerDirection: beaten ? 0xff : 0x0c,
|
||||
tempTrainer: beaten ? sentinelPtr : decodedHeader,
|
||||
};
|
||||
});
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
// The battle CheckTrainerBattle reports when it does fire: LoadTrainer_continue
|
||||
// copies 13 bytes starting at ObjectEvent into wTempTrainer, reading its
|
||||
// command byte and ObjectEventText's pointer as a trainer class, ID and
|
||||
// text pointers. Class $26 is SWIMMERM (constants/trainer_constants.asm),
|
||||
// which has exactly 21 named trainers; ID $16 (22) is one past the last of
|
||||
// them. ReadTrainerParty's name-skipping loop (engine/battle/
|
||||
// read_trainer_party.asm) scans forward from SwimmerMGroup's start for 21
|
||||
// end-of-party ($ff) markers and, since the group tables in data/trainers/
|
||||
// parties.asm sit back-to-back with no padding, lands exactly on
|
||||
// SwimmerFGroup's first entry: ELAINE, a single level 21 STARYU. The
|
||||
// party itself is no garbage: SWIMMERM's sprite, cry and class text with
|
||||
// SwimmerF's ELAINE as the opponent's name and sole Pokemon, confirmed here
|
||||
// by running the unmodified ReadTrainerParty and Battle_GetTrainerName
|
||||
// routines. StartBattleWithMapTrainerScript prints the seen text first,
|
||||
// though, and the next test shows that is not text.
|
||||
test("the decoded trainer header resolves to SwimmerF's ELAINE, one past SwimmerM's last trainer", async ({ page }) => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const a = {
|
||||
romBank: at("hROMBank"),
|
||||
inBattleTowerBattle: at("wInBattleTowerBattle"),
|
||||
linkMode: at("wLinkMode"),
|
||||
otherTrainerClass: at("wOtherTrainerClass"),
|
||||
otherTrainerID: at("wOtherTrainerID"),
|
||||
otPartyCount: at("wOTPartyCount"),
|
||||
otPartyMon1Species: at("wOTPartyMon1Species"),
|
||||
otPartyMon1Level: at("wOTPartyMon1Level"),
|
||||
stringBuffer1: at("wStringBuffer1"),
|
||||
};
|
||||
const readTrainerParty = routine("ReadTrainerParty");
|
||||
const getTrainerName = routine("Battle_GetTrainerName");
|
||||
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async ({ a, readTrainerParty, getTrainerName }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const rom = new Uint8Array(await (document.querySelector("#reference-fixture") as HTMLInputElement).files![0].arrayBuffer());
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (p: number, v: number) => reference.writeMemory(p, v & 255);
|
||||
const read = (p: number) => reference.readMemory(p);
|
||||
write(a.inBattleTowerBattle, 0);
|
||||
write(a.linkMode, 0);
|
||||
write(a.otherTrainerClass, 0x26); // SWIMMERM
|
||||
write(a.otherTrainerID, 0x16); // 22: one past SWIMMERM's last trainer (21)
|
||||
// DI; SP=$c7fe; both routines share engine/battle/core.asm's bank;
|
||||
// CALL ReadTrainerParty (ends in a party of OT mons), then
|
||||
// CALL Battle_GetTrainerName (copies the name into wStringBuffer1).
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7,
|
||||
0x3e, readTrainerParty.bank, 0xe0, a.romBank & 255, 0xea, 0, 0x20,
|
||||
0xcd, readTrainerParty.address & 255, readTrainerParty.address >> 8,
|
||||
0xcd, getTrainerName.address & 255, getTrainerName.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xf0, 0xc0,
|
||||
0x18, 0xfe,
|
||||
];
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((v, i) => write(0xc000 + i, v));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw Error("ReadTrainerParty/Battle_GetTrainerName did not return");
|
||||
const outcome = {
|
||||
partyCount: read(a.otPartyCount),
|
||||
species: read(a.otPartyMon1Species),
|
||||
level: read(a.otPartyMon1Level),
|
||||
name: Array.from({ length: 11 }, (_, i) => read(a.stringBuffer1 + i)),
|
||||
};
|
||||
reference.dispose();
|
||||
return outcome;
|
||||
}, { a, readTrainerParty, getTrainerName });
|
||||
|
||||
const STARYU = 0x78;
|
||||
// "ELAINE@" in the game's charmap, followed by SwimmerFGroup's next three
|
||||
// bytes (TRAINERTYPE_NORMAL, level 21, STARYU) and the party's $ff end
|
||||
// marker: the exact 11 (NAME_LENGTH) bytes CopyTrainerName copies.
|
||||
expect(result).toEqual({
|
||||
partyCount: 1,
|
||||
species: STARYU,
|
||||
level: 21,
|
||||
name: [0x84, 0x8b, 0x80, 0x88, 0x8d, 0x84, 0x50, 0x00, 0x15, 0x78, 0xff],
|
||||
});
|
||||
});
|
||||
|
||||
// StartBattleWithMapTrainerScript's `trainertext TRAINERTEXT_SEEN` prints
|
||||
// the decoded seen text ($46ee in the map scripts' bank) before the battle.
|
||||
// It points into the middle of SproutTower1FStatueText: its first byte is
|
||||
// the space character $7f, which DoTextUntilTerminator dispatches as a text
|
||||
// command. TextCommands has NUM_TEXT_CMDS (23) entries, so command $7f reads
|
||||
// its pointer from home code beyond the table and jumps into the middle of
|
||||
// the VBlank handlers. What happens next depends on the whole CPU state, so
|
||||
// the native port keeps skipping the sighting (see checkTrainers).
|
||||
test("the decoded seen text dispatches text command $7f, beyond TextCommands", () => {
|
||||
const { at, routine } = loadSymbols();
|
||||
const rom = readFileSync(romPath);
|
||||
const bank = routine("BurnedTower1F_MapScripts").bank;
|
||||
const seenText = 0x46ee;
|
||||
const first = rom[bank * 0x4000 + seenText - 0x4000];
|
||||
const NUM_TEXT_CMDS = 23;
|
||||
const entry = at("TextCommands") + first * 2;
|
||||
const target = rom[entry] | (rom[entry + 1] << 8);
|
||||
expect(first).toBe(0x7f);
|
||||
expect(first).toBeGreaterThanOrEqual(NUM_TEXT_CMDS);
|
||||
expect(entry).toBeGreaterThan(at("TextCommands") + NUM_TEXT_CMDS * 2);
|
||||
expect(target).toBe(0x0428);
|
||||
expect(target).toBeGreaterThan(at("VBlank_Credits"));
|
||||
expect(target).toBeLessThan(at("TextCommands"));
|
||||
});
|
||||
@@ -52,6 +52,8 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
items: [...trainer.attributes.items] as [number, number],
|
||||
baseReward: trainer.attributes.baseReward,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
@@ -302,4 +304,55 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
|
||||
expect(result.result).toBe(0);
|
||||
expect(result.texts).toContain("was defeated!");
|
||||
});
|
||||
it("Clair's AI_TryItem heals her Kingdra with the real Hyper Potion, stranding the Full Heal behind it", () => {
|
||||
// CLAIR's real class attributes (items.asm): FULL_HEAL then HYPER_POTION,
|
||||
// CONTEXT_USE and SWITCH_SOMETIMES. Her Kingdra (party index 3, level
|
||||
// 40) is the only one of her four Pokémon .IsHighestLevel ever lets use
|
||||
// an item; the other three are level 37. A single Kingdra, taken from
|
||||
// her real ROM party and class data, is enough to exercise that gate
|
||||
// through a real Battle.run(), without needing her map open yet.
|
||||
const clair = trainers.trainer(8, 1);
|
||||
expect(clair.name).toBe("CLAIR");
|
||||
expect(clair.attributes.items).toEqual([0x26, 0x10]); // FULL_HEAL, HYPER_POTION
|
||||
const kingdra = trainers.createParty(clair)[3];
|
||||
expect(kingdra.species).toBe(230); // KINGDRA
|
||||
// Well under a quarter of max HP: AI_TryItem heals on Clair's very
|
||||
// first turn, before any Random draw.
|
||||
kingdra.hp = Math.floor(kingdra.stats[0] / 10);
|
||||
const weak = generatePartyMon(assets.pokemon, {
|
||||
species: 155, level: 5, item: 0, otId: 1234, ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
});
|
||||
weak.moves = [33, 0, 0, 0]; // Tackle: never one-shots a level 40 Kingdra
|
||||
weak.pp = [35, 0, 0, 0];
|
||||
let seed = 1;
|
||||
const options: BattleOptions = {
|
||||
data: assets.pokemon,
|
||||
party: [weak],
|
||||
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: clair.name,
|
||||
className: clair.className,
|
||||
party: [kingdra],
|
||||
aiFlags: clair.attributes.aiMoveFlags,
|
||||
switchFlags: clair.attributes.aiItemSwitchFlags,
|
||||
items: [...clair.attributes.items] as [number, number],
|
||||
baseReward: clair.attributes.baseReward,
|
||||
reward: clair.reward,
|
||||
},
|
||||
};
|
||||
const before = kingdra.hp;
|
||||
const result = finish(new Battle(options));
|
||||
expect(result.texts).toContain("LEADER CLAIR used HYPER POTION on KINGDRA!");
|
||||
// The Full Heal is still held; it is the Hyper Potion slot that empties.
|
||||
expect(options.trainer!.items).toEqual([0x26, 0]);
|
||||
expect(kingdra.hp).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -88,11 +88,17 @@ const edges = (tile: number): readonly Direction[] =>
|
||||
const isWarpHazard = (tile: number) =>
|
||||
(tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70;
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps. */
|
||||
/** COLL_WATER and the unused COLL_WATER_21: open water a surfer crosses
|
||||
* freely, unlike currents, waterfalls and whirlpools. */
|
||||
const isOpenWater = (tile: number) => tile === 0x29 || tile === 0x21;
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps.
|
||||
* `water` also crosses open water, for a player who can Surf. */
|
||||
export function findPath(
|
||||
world: GameWorld,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
{ water = false } = {},
|
||||
): Direction[] {
|
||||
const queue = [{ x: world.x, y: world.y, path: [] as Direction[] }];
|
||||
const visited = new Set<string>();
|
||||
@@ -108,7 +114,8 @@ export function findPath(
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
||||
continue;
|
||||
const tile = world.assets.collision(world.map, x, y);
|
||||
if (world.assets.permission(tile) !== 0) continue;
|
||||
if (world.assets.permission(tile) !== 0 && !(water && isOpenWater(tile)))
|
||||
continue;
|
||||
if (
|
||||
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
|
||||
edges(tile).includes(OPPOSITE[direction])
|
||||
|
||||
Reference in new issue
Block a user