Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Checks / web (push) Canceled after 0s

Field moves chosen in the party menu run their original checks and the
ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall,
Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key
item that Select then uses, replacing the Key Items shortcut, and Bill's
PC moves Pokémon between the party and every box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 17:22:56 +02:00
1 parent 45275ba83b
commit 3456aa4e11
25 files changed
+1129 -186

No files matched your search

+39 -20
View File
@@ -46,25 +46,34 @@ async function backup(page: Page) {
state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")),
};
}
test("Select opens Key Items, freezes movement, and uses the Bicycle with keyboard and touch", async ({
test("SEL registers the Bicycle, and Select rides it with keyboard and touch", async ({
page,
}) => {
await start(page);
await page.keyboard.press("ShiftLeft");
await expect(page.locator("#field-menu")).toBeVisible();
await page.keyboard.down("ArrowDown");
await page.clock.runFor(1000);
await page.keyboard.up("ArrowDown");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "6");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
// Right twice: the Balls pocket, then Key Items.
await page.keyboard.press("ArrowRight");
await page.keyboard.press("ArrowRight");
await pack.getByRole("button", { name: "BICYCLE", exact: true }).click();
await choices.getByRole("option", { name: "SEL", exact: true }).click();
await expect(text).toContainText("Registered the");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyZ");
await page.keyboard.press("KeyZ");
await expect(choices).toBeHidden();
await page.keyboard.press("ShiftLeft");
await page.clock.runFor(500);
await expect(page.locator("#field-menu")).toBeHidden();
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(1);
await page.locator("#tab-game").click();
await page.locator("#play").click();
await page.getByRole("button", { name: "select", exact: true }).click();
await page.clock.runFor(500);
expect((await backup(page)).state.playerState).toBe(0);
});
test("the bedroom PC puts away a decoration and saves the result", async ({
page,
@@ -91,19 +100,29 @@ test("the bedroom PC puts away a decoration and saves the result", async ({
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.decorations.bed).toBe(0);
});
test("B cancels the pack and an indoor Bicycle attempt leaves the player walking", async ({
test("Select without a registered item explains SEL, and the Bicycle stays packed indoors", async ({
page,
}) => {
await start(page, true);
const text = page.locator("#dialogue-text"),
choices = page.locator("#dialogue-choice"),
pack = page.locator("#mart");
await page.keyboard.press("ShiftLeft");
await expect(text).toContainText("An item in your");
await page.keyboard.press("KeyX");
await page.keyboard.press("KeyX");
await page.keyboard.press("Enter");
await choices.getByRole("option", { name: /^PACK/ }).click();
await page.keyboard.press("ArrowLeft");
await page.keyboard.press("ArrowLeft");
await pack.getByRole("button", { name: "BICYCLE", exact: true }).click();
await choices.getByRole("option", { name: "USE", exact: true }).click();
await expect(text).toContainText("This isn't the");
await page.keyboard.press("KeyX");
await pack.getByRole("button", { name: "BACK", exact: true }).click();
await page.keyboard.press("KeyZ");
await expect(page.locator("#field-menu")).toBeHidden();
await page.keyboard.press("ShiftLeft");
await page
.locator("#field-items")
.getByRole("button", { name: "BICYCLE", exact: true })
.click();
await expect(page.locator("#game-notice")).toContainText("can’t use");
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
expect((await backup(page)).state.playerState).toBe(0);
});
+163
View File
@@ -419,6 +419,169 @@ describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
/** Start → POKéMON → the first Pokémon → `move`; returns the pages
* shown until the menu closed or a choice (the party menu) returned. */
const useFromMenu = (world: GameWorld, move: string) => {
expect(world.openStartMenu()).toBe(true);
const choose = (label: string) => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`);
const index = choice.items.findIndex((item) => item.startsWith(label));
expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0);
world.choose(index);
};
choose("POKéMON");
choose("SWIMMY");
choose(move);
const pages: string[] = [];
for (let i = 0; i < 50 && world.dialogue && !world.dialogue.choice; i++) {
pages.push(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
}
return pages;
};
/** B out of every menu, then let any queued script finish. */
const closeMenus = (world: GameWorld) => {
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
return settle(world).join("\n");
};
it("surfs from the party menu, after the badge and facing checks", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x0f, 0x39]); // CUT, SURF
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
expect(world.dialogue).toBeUndefined();
// CUT and SURF come first in the submenu.
expect(useFromMenu(world, "SURF")).toEqual(["Sorry! A new BADGE\nis required."]);
closeMenus(world);
world.state.flags.add(30); // ENGINE_FOGBADGE
world.restore({ ...world.save(), direction: "up" });
expect(useFromMenu(world, "SURF")).toEqual(["You can't SURF\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "right" });
const shown = [...useFromMenu(world, "SURF"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
// No "Want to SURF?" question from the menu.
expect(shown).not.toContain("?");
expect(shown).toContain("SWIMMY used\nSURF!");
expect([world.x, world.y, world.state.playerState]).toEqual([18, 7, 4]);
expect(useFromMenu(world, "SURF")).toEqual(["You're already\nSURFING."]);
closeMenus(world);
});
it("cuts from the party menu, or finds nothing to cut", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]);
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "CUT")).toEqual(["There's nothing to\nCUT here."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
const shown = [...useFromMenu(world, "CUT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
});
it("uses Strength from the party menu", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x46]);
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const shown = [...useFromMenu(world, "STRENGTH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
});
it("lights a dark cave with Flash, which fails elsewhere", () => {
// Dark Cave's Violet entrance (PALETTE_DARK).
const DARK_CAVE = "3:78";
const { assets, world } = fixture([DARK_CAVE]);
const map = assets.map(DARK_CAVE);
expect(map.paletteMode).toBe(4);
let spot = { map: DARK_CAVE, x: 0, y: 0 };
search: for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++)
if (
assets.permission(assets.collision(map, x, y)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y)
) {
spot = { map: DARK_CAVE, x, y };
break search;
}
world.state.party = partyWith(assets, [0x94]); // FLASH
world.state.flags.add(27); // ENGINE_ZEPHYRBADGE
world.warpTo(spot.map, spot.x, spot.y);
expect(world.timeOfDay).toBe(3);
const shown = [...useFromMenu(world, "FLASH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("A blinding FLASH\nlights the area!");
expect(world.state.flash).toBe(true);
expect(world.timeOfDay).toBe(2);
expect(useFromMenu(world, "FLASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
});
it("recognises a smashable rock for Rock Smash, which is not ported further", () => {
// A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6.
const spot = { map: "3:78", x: 27, y: 6 };
const { assets, world } = fixture([spot.map]);
expect(
assets.map(spot.map).objects.some(
(o) => o.x === spot.x && o.y === spot.y && o.movement === 0x18,
),
).toBe(true);
world.state.party = partyWith(assets, [0xf9]); // ROCK_SMASH
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "ROCK SMASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
useFromMenu(world, "ROCK SMASH");
expect(world.notice).toBe("Smashing rocks is not ported yet.");
closeMenus(world);
expect(world.diagnostic).toBe("");
});
it("draws a wild Pokémon with Sweet Scent in grass, and nothing elsewhere", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0xe6]); // SWEET_SCENT
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const nothing = [...useFromMenu(world, "SWEET SCENT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(nothing).toContain("SWIMMY used\nSWEET SCENT!");
expect(nothing).toContain("nothing");
const grass = find(assets, (map, x, y) =>
map.id === "24:3" &&
assets.grassCollisions().includes(assets.collision(map, x, y)) &&
!map.objects.some((o) => o.x === x && o.y === y),
);
world.warpTo(grass.map, grass.x, grass.y);
useFromMenu(world, "SWEET SCENT");
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
for (let tick = 0; tick < 200 && !world.battle; tick++) {
if (world.dialogue) world.advanceDialogue();
world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(world.battle).toBeDefined();
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
+109
View File
@@ -226,6 +226,115 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
expect(world.save()).toEqual(saved);
});
it("moves Pokémon between the party and boxes without mail", () => {
const world = fixture();
const data = world.assets.pokemon;
const [cyndaquil, pidgey] = world.state.party;
pidgey.hp = 1;
pidgey.pp = [1, 0, 0, 0];
const boxed = toBoxMon(
generatePartyMon(data, {
species: 19, // RATTATA
level: 6,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x1234,
caughtData: 0,
}),
);
world.state.storage.boxes[0] = [boxed];
open(world);
pick(world, "BILL's PC");
expect(pick(world, "MOVE POKéMON W/O MAIL")).toEqual([
"Each time you move a POKéMON, data will be saved. OK?",
]);
world.choose(1);
until(world);
// The party, then the fourteen boxes, with the current box highlighted.
const boxes = world.dialogue?.choice;
expect(boxes?.kind === "menu" && [boxes.items.length, boxes.selected]).toEqual([15, 1]);
expect(menuItems(world)[0]).toBe("PARTY POKéMON");
// PIDGEY from the party to the end of BOX1: its PP are restored.
pick(world, "PARTY POKéMON");
pick(world, /^PIDGEY/);
expect(menuItems(world)).toEqual(["MOVE", "STATS", "CANCEL"]);
pick(world, "MOVE");
pick(world, "BOX1");
expect(menuItems(world)).toEqual(["RATTATA", "▶"]);
pick(world, "▶");
// RATTATA from BOX1 to the front of the party, with full HP.
pick(world, "BOX1");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, /^CYNDAQUIL/);
expect(world.liveState.party.map((mon) => mon.species)).toEqual([19, 155]);
// Within the party: RATTATA to the end.
pick(world, "PARTY POKéMON");
pick(world, /^RATTATA/);
pick(world, "MOVE");
pick(world, "PARTY POKéMON");
pick(world, "▶");
close(world);
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 19]);
const rattata = world.state.party[1];
expect(rattata.hp).toBe(rattata.stats[0]);
expect(rattata.stats).toEqual(calcStats(data, 19, 0x1234, 6, rattata.statExp));
expect(world.state.storage.boxes[0].map((mon) => mon.species)).toEqual([16]);
expect(world.state.storage.boxes[0][0].pp[0]).toBe(data.move(pidgey.moves[0]).pp);
expect(cyndaquil.species).toBe(155);
});
it("keeps the last Pokémon and refuses a full destination when moving", () => {
const world = fixture();
const data = world.assets.pokemon;
world.state.party = [world.state.party[0]];
const full = Array.from({ length: 20 }, () =>
toBoxMon(
generatePartyMon(data, {
species: 16,
level: 3,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
}),
),
);
world.state.storage.boxes[1] = full;
world.state.storage.boxes[2] = [structuredClone(full[0])];
world.state.storage.boxes[2][0].nickname = "SPARE";
open(world);
pick(world, "BILL's PC");
pick(world, "MOVE POKéMON W/O MAIL");
world.choose(1);
until(world);
pick(world, "PARTY POKéMON");
expect(pick(world, /^CYNDAQUIL/)).toEqual(["What's up?"]);
expect(pick(world, "MOVE")).toContain("It's your last POKéMON!");
// BOX3 into the full BOX2: no room, and the position is chosen again.
pick(world, "BOX3");
pick(world, /^SPARE/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("There's no room!");
// B leaves the destination list for the list of boxes to move from.
world.advanceDialogue();
until(world);
// Within the full BOX2 a Pokémon can still move.
pick(world, "BOX2");
pick(world, /^PIDGEY/);
pick(world, "MOVE");
pick(world, "BOX2");
expect(pick(world, "▶")).toContain("Saving… Leave ON!");
close(world);
expect(world.state.storage.boxes[1]).toHaveLength(20);
expect(world.state.storage.boxes[2].map((mon) => mon.nickname)).toEqual(["SPARE"]);
expect(world.state.party).toHaveLength(1);
});
it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => {
const world = fixture();
const data = world.assets.pokemon;
+99 -2
View File
@@ -33,6 +33,7 @@ const BICYCLE = 7,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const SOFTBOILED = 0x87;
const CUT = 0x0f,
GUST = 0x10,
SAND_ATTACK = 0x1c,
@@ -197,8 +198,11 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
pick(world, "CANCEL");
pick(world, "PIDGEY");
expect(menuItems(world)).toEqual(["CUT", "STATS", "SWITCH", "MOVE", "ITEM", "CANCEL"]);
pick(world, "CUT");
expect(world.notice).toBe("Using CUT from the menu is not ported yet.");
// CutFunction's CheckBadge, then the party menu again.
expect(pick(world, "CUT")).toEqual([
"Sorry! A new BADGE is required.",
"Choose a POKéMON.",
]);
// STATS shows the stats screen, and B returns to the party.
pick(world, "PIDGEY");
world.choose(menuItems(world).indexOf("STATS"));
@@ -209,6 +213,56 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
close(world);
});
it("shares HP with Softboiled, which needs more than a fifth of it", () => {
const world = fixture();
const data = world.assets.pokemon;
const chansey = generatePartyMon(data, {
species: 113,
level: 20,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
chansey.moves = [SOFTBOILED, 0, 0, 0];
world.state.party.push(chansey);
world.state.party[0].hp = 3;
const fifth = Math.floor(chansey.stats[0] / 5);
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual(["Use on which POKéMON?"]);
// Not on itself, and not on a Pokémon at full HP.
expect(pick(world, "CHANSEY")).toEqual([
"That can't be used on this POKéMON.",
"Use on which POKéMON?",
]);
expect(pick(world, "PIDGEY")[0]).toBe("That can't be used on this POKéMON.");
expect(pick(world, "CYNDAQUIL")).toEqual([
`CYNDAQUIL recovered ${Math.min(fifth, world.liveState.party[0].stats[0] - 3)}HP!`,
"Choose a POKéMON.",
]);
// The cursor returns to the user.
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.selected).toBe(2);
close(world);
expect(world.state.party[2].hp).toBe(chansey.stats[0] - fifth);
expect(world.state.party[0].hp).toBe(
Math.min(world.state.party[0].stats[0], 3 + fifth),
);
world.state.party[2].hp = fifth;
open(world);
pick(world, "POKéMON");
pick(world, "CHANSEY");
expect(pick(world, "SOFTBOILED")).toEqual([
"Not enough HP!",
"Choose a POKéMON.",
]);
close(world);
});
it("switches two Pokémon, and B cancels the move", () => {
const world = fixture();
open(world);
@@ -525,6 +579,49 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
expect(indoors.state.playerState).toBe(0);
});
it("registers the Bicycle with SEL, and Select rides it without a message", () => {
const world = fixture();
// Nothing registered yet.
expect(world.useSelect()).toBe(true);
expect(until(world)).toEqual([
"An item in your PACK may be",
"registered for use on SELECT Button.",
]);
close(world);
open(world);
pick(world, "PACK");
pickItem(world, BICYCLE);
expect(pick(world, "SEL")).toEqual(["Registered the BICYCLE."]);
close(world);
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
// The registration is saved.
world.restore(world.save());
expect(world.state.registeredItem).toEqual({ which: 0x81, item: BICYCLE });
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.diagnostic).toBe("");
expect(world.state.playerState).toBe(1);
expect(world.useSelect()).toBe(true);
expect(settle(world)).toEqual([]);
expect(world.state.playerState).toBe(0);
const indoors = fixture(PLAYERS_HOUSE, 7, 4);
indoors.state.registeredItem = { which: 0x81, item: BICYCLE };
indoors.useSelect();
expect(until(indoors)).toEqual(["OAK: CHRIS! This isn't the time to use that!"]);
close(indoors);
expect(indoors.state.playerState).toBe(0);
});
it("forgets a registered item that left its slot", () => {
const world = fixture();
world.state.registeredItem = { which: 0x02, item: POTION };
world.useSelect();
expect(until(world)[0]).toBe("An item in your PACK may be");
close(world);
expect(world.state.registeredItem).toEqual({ which: 0, item: 0 });
});
it("offers no USE without a field menu, and a notice for unported effects", () => {
const world = fixture();
world.state.bag.items.push(