Port Repels and the other non-fishing field items
Repel, Super Repel and Max Repel count down 100, 200 or 250 steps where CountStep does, keep weaker wild Pokémon away from the first party member that has not fainted, and print "REPEL's effect wore off." on the last step. The Coin Case and the Blue Card show their balances, Sacred Ash revives the party once one Pokémon has fainted, and the Normal and Gorgeous Boxes send their trophy home. The Itemfinder looks for unfound hidden items on the original's screen around the player. The Card Key, the Basement Key and the Squirtbottle run the original's checks for their doors and Sudowoodo, whose maps are not open yet. Saves gain the Repel's steps, the coins and the Blue Card's points. Part of #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10e1560212
commit
4cb4676c7d
13 files changed
+821
-21
No files matched your search
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld, migrateSave } from "../src/game/world/world";
|
||||
import { DecorationData } from "../src/game/world/decorations";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import {
|
||||
PLAYER,
|
||||
ScriptRun,
|
||||
emptyScriptState,
|
||||
type ScriptHost,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { StartMenuRun } from "../src/game/scripts/start-menu";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
import { OVERWORLD } from "../src/game/rom/profile";
|
||||
import { close, useItem } from "./menu-driver";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PLAYERS_ROOM = "24:7",
|
||||
ROUTE_30 = "26:1",
|
||||
RADIO_TOWER_3F = "3:19",
|
||||
GOLDENROD_UNDERGROUND = "3:53",
|
||||
ROUTE_36 = "10:3";
|
||||
const CYNDAQUIL = 155,
|
||||
PIDGEY = 16;
|
||||
const POTION = 0x12,
|
||||
REPEL = 0x14,
|
||||
SUPER_REPEL = 0x2a,
|
||||
MAX_REPEL = 0x2b,
|
||||
COIN_CASE = 0x36,
|
||||
BLUE_CARD = 0x74,
|
||||
SACRED_ASH = 0x9c,
|
||||
NORMAL_BOX = 0xa7,
|
||||
GORGEOUS_BOX = 0xa8,
|
||||
ITEMFINDER = 0x37,
|
||||
CARD_KEY = 0x7f,
|
||||
BASEMENT_KEY = 0x85,
|
||||
SQUIRTBOTTLE = 0xaf;
|
||||
const SPRITEMOVEDATA_SUDOWOODO = 0x17;
|
||||
const OAK_WARNING = "OAK: CHRIS! This isn't the time to use that!";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("field items", () => {
|
||||
/** The player's room, where every floor tile is grass for a test-only
|
||||
* table of level-`wildLevel` Pidgey met on every step past the cooldown. */
|
||||
function fixture(wildLevel = 3, map = PLAYERS_ROOM, x = 3, y = 3) {
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
assets.grassCollisions = () => [
|
||||
assets.collision(assets.map(PLAYERS_ROOM), 3, 2),
|
||||
];
|
||||
assets.grassWildMons = (id) =>
|
||||
id === PLAYERS_ROOM
|
||||
? {
|
||||
rates: [256, 256, 256],
|
||||
slots: [0, 1, 2].map(() =>
|
||||
Array.from({ length: 7 }, () => ({
|
||||
species: PIDGEY,
|
||||
level: wildLevel,
|
||||
})),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
let seed = 1;
|
||||
const world = new GameWorld(
|
||||
assets,
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
const mon = (level: number) =>
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species: CYNDAQUIL,
|
||||
level,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
});
|
||||
world.state.party = [mon(5), mon(8)];
|
||||
world.state.bag.items = [{ item: POTION, quantity: 1 }];
|
||||
world.restore({ ...world.save(), map, x, y, objects: [] });
|
||||
return world;
|
||||
}
|
||||
/** Step left and right until a wild battle or a page appears, at most
|
||||
* `limit` steps; returns the number of steps taken. */
|
||||
function walk(world: GameWorld, limit: number) {
|
||||
for (let step = 1; step <= limit; step++) {
|
||||
expect(world.move(step & 1 ? "left" : "right")).toBe(true);
|
||||
if (world.battle || world.dialogue) return step;
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
it("keeps weaker wild Pokémon away while a Repel lasts", () => {
|
||||
const world = fixture();
|
||||
world.state.bag.items.push({ item: REPEL, quantity: 2 });
|
||||
expect(useItem(world, REPEL)).toEqual(["CHRIS used the REPEL."]);
|
||||
// The pack stays open (ITEMMENU_CURRENT); a second one is refused.
|
||||
expect(world.dialogue?.choice?.kind).toBe("pack");
|
||||
world.advanceDialogue();
|
||||
close(world);
|
||||
expect(world.state.repel).toBe(100);
|
||||
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
|
||||
expect(useItem(world, REPEL)).toEqual([
|
||||
"The REPEL used earlier is still in effect.",
|
||||
]);
|
||||
close(world);
|
||||
expect(world.state.bag.items.at(-1)).toEqual({ item: REPEL, quantity: 1 });
|
||||
|
||||
// The Level 3 Pidgey never reaches the Level 5 lead.
|
||||
expect(walk(world, 20)).toBe(20);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.state.repel).toBe(80);
|
||||
// A fainted lead hands the check to the next Pokémon (Level 8).
|
||||
world.state.party[0].hp = 0;
|
||||
expect(walk(world, 10)).toBe(10);
|
||||
expect(world.battle).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets wild Pokémon of the lead's level through", () => {
|
||||
const world = fixture(5);
|
||||
world.state.repel = 100;
|
||||
expect(walk(world, 10)).toBe(5);
|
||||
expect(world.battle).toBeDefined();
|
||||
// The step that met the Pokémon replays after a reload, with its
|
||||
// Repel step: the save made during the battle has the earlier count.
|
||||
expect(world.save().repel).toBe(96);
|
||||
expect(world.state.repel).toBe(95);
|
||||
});
|
||||
|
||||
it("wears off on its last step, which meets no Pokémon", () => {
|
||||
const world = fixture();
|
||||
world.state.repel = 6;
|
||||
// Five steps pass the cooldown under the Repel.
|
||||
expect(walk(world, 5)).toBe(5);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.move("left")).toBe(true);
|
||||
expect(world.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.state.repel).toBe(0);
|
||||
// A save during the message replays the Repel's last step.
|
||||
const saved = world.save();
|
||||
expect([saved.repel, saved.x]).toEqual([1, 2]);
|
||||
settle(world);
|
||||
expect(walk(world, 1)).toBe(1);
|
||||
expect(world.battle).toBeDefined();
|
||||
|
||||
const replayed = fixture();
|
||||
replayed.restore(saved);
|
||||
expect(replayed.move("left")).toBe(true);
|
||||
expect(replayed.dialogue?.pages).toEqual(["REPEL's effect\nwore off."]);
|
||||
});
|
||||
|
||||
it("gives Super and Max Repels 200 and 250 steps", () => {
|
||||
for (const [item, steps, name] of [
|
||||
[SUPER_REPEL, 200, "SUPER REPEL"],
|
||||
[MAX_REPEL, 250, "MAX REPEL"],
|
||||
] as const) {
|
||||
const world = fixture();
|
||||
world.state.bag.items.push({ item, quantity: 1 });
|
||||
expect(useItem(world, item)).toEqual([`CHRIS used the ${name}.`]);
|
||||
close(world);
|
||||
expect(world.state.repel).toBe(steps);
|
||||
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the Coin Case's coins and the Blue Card's points", () => {
|
||||
const world = fixture();
|
||||
world.state.bag.keyItems = [COIN_CASE, BLUE_CARD];
|
||||
world.state.coins = 1234;
|
||||
world.state.blueCardBalance = 7;
|
||||
expect(useItem(world, COIN_CASE)).toEqual(["Coins: 1234"]);
|
||||
world.advanceDialogue();
|
||||
close(world);
|
||||
expect(useItem(world, BLUE_CARD)).toEqual(["You now have 7 points."]);
|
||||
close(world);
|
||||
expect(world.state.bag.keyItems).toEqual([COIN_CASE, BLUE_CARD]);
|
||||
});
|
||||
|
||||
it("revives the whole party with Sacred Ash, only after a faint", () => {
|
||||
const world = fixture();
|
||||
world.state.bag.items.push({ item: SACRED_ASH, quantity: 2 });
|
||||
// CheckAnyFaintedMon: nobody has fainted, so Oak objects.
|
||||
expect(useItem(world, SACRED_ASH)).toEqual([OAK_WARNING]);
|
||||
close(world);
|
||||
expect(world.state.bag.items.at(-1)).toEqual({
|
||||
item: SACRED_ASH,
|
||||
quantity: 2,
|
||||
});
|
||||
|
||||
const [first, second] = world.state.party;
|
||||
first.hp = 0;
|
||||
first.status = 0;
|
||||
second.hp = 1;
|
||||
second.status = 0x08;
|
||||
second.pp[0] = 0;
|
||||
// The menu closes and SacredAshScript runs.
|
||||
expect(useItem(world, SACRED_ASH)).toEqual([
|
||||
"CHRIS's POKéMON were all healed!",
|
||||
]);
|
||||
close(world);
|
||||
for (const mon of world.state.party) {
|
||||
expect([mon.hp, mon.status]).toEqual([mon.stats[0], 0]);
|
||||
expect(mon.pp[0]).toBeGreaterThan(0);
|
||||
}
|
||||
expect(world.state.bag.items.at(-1)).toEqual({
|
||||
item: SACRED_ASH,
|
||||
quantity: 1,
|
||||
});
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("sends the trophy in a Normal or Gorgeous Box home", () => {
|
||||
for (const [item, trophy] of [
|
||||
[NORMAL_BOX, "SILVER TROPHY"],
|
||||
[GORGEOUS_BOX, "GOLD TROPHY"],
|
||||
] as const) {
|
||||
const world = fixture();
|
||||
world.state.bag.items.push({ item, quantity: 1 });
|
||||
const before = new Set(world.state.events);
|
||||
expect(useItem(world, item)).toEqual([
|
||||
"There was a trophy inside!",
|
||||
"CHRIS sent the trophy home.",
|
||||
]);
|
||||
close(world);
|
||||
const added = [...world.state.events].filter((id) => !before.has(id));
|
||||
const decorations = new DecorationData(
|
||||
world.assets.reader,
|
||||
world.assets.pokemon,
|
||||
);
|
||||
const owned = Array.from({ length: 52 }, (_, i) => i + 1).filter((id) =>
|
||||
added.includes(decorations.attribute(id).event),
|
||||
);
|
||||
expect(owned.map((id) => decorations.name(id))).toEqual([trophy]);
|
||||
expect(world.state.bag.items.some((slot) => slot.item === item)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("saves the Repel, coins and points, and migrates saves without them", () => {
|
||||
const world = fixture();
|
||||
Object.assign(world.state, { repel: 42, coins: 9999, blueCardBalance: 30 });
|
||||
const saved = world.save();
|
||||
expect(saved).toMatchObject({
|
||||
repel: 42,
|
||||
coins: 9999,
|
||||
blueCardBalance: 30,
|
||||
});
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
const { repel: _, coins: __, blueCardBalance: ___, ...legacy } = saved;
|
||||
expect(migrateSave(legacy, () => 0)).toMatchObject({
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
blueCardBalance: 0,
|
||||
});
|
||||
for (const invalid of [
|
||||
{ repel: 256 },
|
||||
{ coins: 10000 },
|
||||
{ blueCardBalance: -1 },
|
||||
])
|
||||
expect(() => world.restore({ ...saved, ...invalid })).toThrow(
|
||||
"Invalid native save",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds hidden items on the screen with the Itemfinder", () => {
|
||||
// Route 30's hidden Potion at 14, 9 is seen from 9-18 across and 5-13
|
||||
// down: four squares left of and above the player, five right of it
|
||||
// and four below.
|
||||
const nearby = "Yes! ITEMFINDER indicates there's an item nearby.",
|
||||
nope = "Nope! ITEMFINDER isn't responding.";
|
||||
for (const [x, y, pages] of [
|
||||
[18, 9, [nearby]],
|
||||
[19, 9, [nope]],
|
||||
[9, 6, [nearby]],
|
||||
[7, 6, [nope]],
|
||||
[12, 5, [nearby]],
|
||||
[12, 4, [nope]],
|
||||
[12, 13, [nearby]],
|
||||
[12, 14, [nope]],
|
||||
] as const) {
|
||||
const world = fixture(3, ROUTE_30, x, y);
|
||||
world.state.bag.keyItems = [ITEMFINDER];
|
||||
expect(useItem(world, ITEMFINDER), `${x}, ${y}`).toEqual(pages);
|
||||
close(world);
|
||||
expect(world.state.bag.keyItems).toEqual([ITEMFINDER]);
|
||||
}
|
||||
// Once found, the item no longer answers.
|
||||
const world = fixture(3, ROUTE_30, 14, 10);
|
||||
world.state.bag.keyItems = [ITEMFINDER];
|
||||
world.state.events.add(
|
||||
world.assets.reader.word(
|
||||
world.map.backgroundEvents.find((event) => event.kind === 7)!.script,
|
||||
),
|
||||
);
|
||||
expect(useItem(world, ITEMFINDER)).toEqual([nope]);
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("uses the keys only at their doors", () => {
|
||||
// Away from the Radio Tower and the Underground, Oak objects.
|
||||
const world = fixture();
|
||||
world.state.bag.keyItems = [CARD_KEY, BASEMENT_KEY];
|
||||
for (const key of [CARD_KEY, BASEMENT_KEY]) {
|
||||
expect(useItem(world, key)).toEqual([OAK_WARNING]);
|
||||
close(world);
|
||||
}
|
||||
expect(world.state.bag.keyItems).toEqual([CARD_KEY, BASEMENT_KEY]);
|
||||
|
||||
// Those maps are not open yet: a menu run with their positions.
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const use = (
|
||||
item: number,
|
||||
mapId: string,
|
||||
facing: number,
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
const state = emptyScriptState();
|
||||
state.bag.keyItems = [item];
|
||||
const run = new StartMenuRun(state, {
|
||||
reader: assets.reader,
|
||||
data: assets.pokemon,
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
scriptHost: () =>
|
||||
({
|
||||
mapId,
|
||||
facing: () => facing,
|
||||
position: () => position,
|
||||
}) as ScriptHost,
|
||||
} as unknown as ConstructorParameters<typeof StartMenuRun>[1]);
|
||||
let effect = run.resume();
|
||||
// PACK, the key, USE.
|
||||
const menu = (label: string) => {
|
||||
if (effect?.kind !== "menu")
|
||||
throw new Error(`Expected a menu, got ${effect?.kind}`);
|
||||
effect = run.resume(effect.items.indexOf(label));
|
||||
};
|
||||
menu("PACK");
|
||||
effect = run.resume((2 << 8) | 0);
|
||||
menu("USE");
|
||||
return effect?.kind === "text"
|
||||
? effect.pages.map((page) => page.replace(/\n/g, " "))
|
||||
: run.deferred;
|
||||
};
|
||||
const UP = 1,
|
||||
DOWN = 0,
|
||||
LEFT = 2;
|
||||
// _CardKey: facing up at the slot, 18, 6 with the map's border.
|
||||
expect(use(CARD_KEY, RADIO_TOWER_3F, UP, { x: 14, y: 3 })).toBe(
|
||||
OVERWORLD["_CardKey.CardKeyScript"],
|
||||
);
|
||||
expect(use(CARD_KEY, RADIO_TOWER_3F, LEFT, { x: 15, y: 2 })).toEqual([
|
||||
OAK_WARNING,
|
||||
]);
|
||||
expect(use(CARD_KEY, ROUTE_36, UP, { x: 14, y: 3 })).toEqual([OAK_WARNING]);
|
||||
// _BasementKey: facing the door at 22, 10 from any side.
|
||||
expect(
|
||||
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, DOWN, { x: 18, y: 5 }),
|
||||
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
|
||||
expect(
|
||||
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, LEFT, { x: 19, y: 6 }),
|
||||
).toBe(OVERWORLD["_BasementKey.BasementKeyScript"]);
|
||||
expect(
|
||||
use(BASEMENT_KEY, GOLDENROD_UNDERGROUND, UP, { x: 18, y: 5 }),
|
||||
).toEqual([OAK_WARNING]);
|
||||
});
|
||||
|
||||
it("sprinkles water with the Squirtbottle, which only Sudowoodo minds", () => {
|
||||
const world = fixture();
|
||||
world.state.bag.keyItems = [SQUIRTBOTTLE];
|
||||
expect(useItem(world, SQUIRTBOTTLE)).toEqual([
|
||||
"CHRIS sprinkled water.",
|
||||
"But nothing happened…",
|
||||
]);
|
||||
close(world);
|
||||
expect(world.state.bag.keyItems).toEqual([SQUIRTBOTTLE]);
|
||||
|
||||
// Route 36 is not open yet: the script against a host facing Sudowoodo.
|
||||
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
const facing = { id: 5, movement: SPRITEMOVEDATA_SUDOWOODO };
|
||||
const host = {
|
||||
mapId: ROUTE_36,
|
||||
lastTalked: PLAYER,
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
data: assets.pokemon,
|
||||
facingObject: () => facing,
|
||||
sceneOwner: () => undefined,
|
||||
} as unknown as ScriptHost;
|
||||
const run = new ScriptRun(
|
||||
assets.reader,
|
||||
OVERWORLD["_Squirtbottle.SquirtbottleScript"],
|
||||
emptyScriptState(),
|
||||
host,
|
||||
{},
|
||||
);
|
||||
const first = run.resume();
|
||||
expect(first?.kind === "text" && first.pages).toEqual([
|
||||
"CHRIS used the\nSQUIRTBOTTLE.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user