Port evolution, Rare Candy, evolution stones and vitamins
Checks / web (push) Canceled after 0s

Pokémon that levelled up evolve after a won battle, following
EvolveAfterBattle's checks over the ROM's evolution bytes, with the
evolution screen that B can stop and move learning outside battle. The pack
uses Rare Candy, stones (with ABLE/NOT ABLE labels) and vitamins, and its
pocket tabs now sit in their own row.

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 16:21:59 +02:00
1 parent 1af2beacdc
commit 45275ba83b
19 files changed
+1032 -39

No files matched your search

+40
View File
@@ -54,6 +54,46 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(world.save()).toEqual(saved);
});
it("evolves a Pokémon that levelled up once the battle is won", () => {
const world = fixture();
const data = world.assets.pokemon;
const mon = generatePartyMon(data, {
species: 155, level: 13, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
mon.exp = data.expAtLevel(data.baseData(155).growthRate, 14) - 1;
world.state.party[0] = mon;
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("What? CYNDAQUIL\nis evolving!");
expect(messages).toContain("evolved into\nQUILAVA!");
const evolved = world.state.party[0];
expect([evolved.species, evolved.nickname, evolved.level]).toEqual([156, "QUILAVA", 14]);
expect(evolved.stats).toEqual(calcStats(data, 156, evolved.dvs, 14, evolved.statExp));
expect(world.state.pokedex.caught.has(156)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => {
const world = fixture();
const data = world.assets.pokemon;
// A level-14 Cyndaquil that does not take part could evolve, but
// EvolveAfterBattle only checks the one that grew to level 6.
world.state.party.push(generatePartyMon(data, {
species: 155, level: 14, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
}));
encounter(world);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party[0].level).toBe(6);
expect(messages).not.toContain("evolving");
expect(world.state.party.map((mon) => mon.species)).toEqual([155, 155]);
});
it("struggles with recoil once every move is out of PP", () => {
const world = fixture();
world.state.party[0].pp = [0, 0, 0, 0];
+38 -1
View File
@@ -9,7 +9,10 @@ test.beforeEach(() =>
test.skip(!existsSync(romPath), "Requires the local user ROM."),
);
async function start(page: Page, room: boolean | "poster" | "center" = false) {
async function start(
page: Page,
room: boolean | "poster" | "center" | "candy" = false,
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch(),
source = await response.text();
@@ -239,3 +242,37 @@ test("Start opens the start menu: SWITCH reorders the party and the pack tosses
]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 4 }]);
});
test("a Rare Candy levels up and evolves a Pokémon through the pack", async ({
page,
}) => {
await start(page, "candy");
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();
await pack.getByRole("button", { name: /RARE CANDY/ }).click();
await choices.getByRole("option", { name: "USE", exact: true }).click();
await choices.getByRole("option", { name: /^CYNDAQUIL/ }).click();
await expect(text).toContainText("grew to");
await page.keyboard.press("KeyX");
await expect(text).toContainText("ATTACK");
await page.keyboard.press("KeyX");
await expect(text).toContainText("is evolving!");
await page.clock.runFor(1500);
await expect(page.locator("#pokepic")).toBeVisible();
await page.clock.runFor(8000);
await expect(text).toContainText("Congratulations!");
await page.keyboard.press("KeyX");
await expect(text).toContainText("QUILAVA!");
await page.keyboard.press("KeyX");
await expect(pack).toHaveAttribute("aria-label", "Pack");
await pack.getByRole("button", { name: "BACK", exact: true }).click();
await choices.getByRole("option", { name: /^EXIT/ }).click();
await page.clock.runFor(500);
await expect(page.locator("#game-notice")).toHaveText("");
const { state } = await backup(page);
expect([state.party[0].species, state.party[0].level]).toEqual([156, 14]);
expect(state.bag.items).toEqual([{ item: 18, quantity: 5 }]);
});
+7 -5
View File
@@ -4,10 +4,11 @@ import { OW } from "../src/game/world/constants";
import { generatePartyMon } from "../src/game/party/pokemon";
/** `room` places the player at the bedroom PC or poster, "center" at
* Cherrygrove's Pokémon Center PC with two Pokémon; otherwise outdoors. */
* Cherrygrove's Pokémon Center PC with two Pokémon ("candy": the first at
* level 13, with a Rare Candy); otherwise outdoors. */
export function foundationFixture(
assets: CrystalAssets,
room: boolean | "poster" | "center",
room: boolean | "poster" | "center" | "candy",
) {
const world = new GameWorld(
assets,
@@ -16,11 +17,11 @@ export function foundationFixture(
);
world.state.scenes["24:4"] = 1;
world.state.bag.keyItems.push(OW.BICYCLE);
if (room === "center") {
if (room === "center" || room === "candy") {
world.state.party = [155, 16].map((species, i) =>
generatePartyMon(assets.pokemon, {
species,
level: 8 - i * 4,
level: room === "candy" && !i ? 13 : 8 - i * 4,
item: 0,
otId: world.playerId,
ot: "CHRIS",
@@ -29,10 +30,11 @@ export function foundationFixture(
}),
);
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
if (room === "candy") world.state.bag.items.push({ item: 0x20, quantity: 1 });
}
const save = world.save();
const [map, x, y] =
room === "center"
room === "center" || room === "candy"
? ["26:5", 9, 2]
: room === "poster"
? ["24:7", 6, 1]
+217 -5
View File
@@ -2,7 +2,7 @@ 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 { generatePartyMon, maxPP } from "../src/game/party/pokemon";
import { calcStats, generatePartyMon, maxPP } from "../src/game/party/pokemon";
import { ENGINE_FLAGS } from "../src/game/rom/profile";
import { settle } from "./world-driver";
@@ -12,21 +12,30 @@ const romPath =
const NEW_BARK_TOWN = "24:4",
PLAYERS_HOUSE = "24:6",
CYNDAQUIL = 155,
PIDGEY = 16;
QUILAVA = 156,
PIDGEY = 16,
PIKACHU = 25,
RAICHU = 26;
const BICYCLE = 7,
ANTIDOTE = 9,
FULL_RESTORE = 0x0e,
POTION = 0x12,
REPEL = 0x14,
THUNDERSTONE = 0x17,
HP_UP = 0x1a,
RARE_CANDY = 0x20,
REVIVE = 0x27,
PP_UP = 0x3e,
ETHER = 0x3f,
ELIXER = 0x41,
EVERSTONE = 0x70,
ENERGYPOWDER = 0x79,
LEFTOVERS = 0x92,
FLOWER_MAIL = 0x9e,
BERRY = 0xad;
const CUT = 0x0f,
GUST = 0x10,
SAND_ATTACK = 0x1c,
TACKLE = 0x21,
LEER = 0x2b,
SMOKESCREEN = 0x6c;
@@ -520,7 +529,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
const world = fixture();
world.state.bag.items.push(
{ item: BERRY, quantity: 1 },
{ item: RARE_CANDY, quantity: 1 },
{ item: REPEL, quantity: 1 },
);
world.state.bag.balls = [{ item: 5, quantity: 3 }];
open(world);
@@ -529,11 +538,214 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
pickItem(world, 5);
expect(menuItems(world)).toEqual(["GIVE", "TOSS", "QUIT"]);
pick(world, "QUIT");
pickItem(world, REPEL);
pick(world, "USE");
expect(world.notice).toBe("Using REPEL is not ported yet.");
close(world);
expect(world.state.bag.items.find((slot) => slot.item === REPEL)).toBeTruthy();
});
it("raises a level with a Rare Candy, forgetting a move to learn a new one", () => {
const world = fixture();
const pidgey = world.state.party[1];
pidgey.moves = [TACKLE, CUT, LEER, GUST];
pidgey.pp = [35, 30, 30, 35];
pidgey.hp = 0;
world.state.bag.items.push({ item: RARE_CANDY, quantity: 2 });
const data = world.assets.pokemon;
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
expect(world.notice).toBe("Using RARE CANDY is not ported yet.");
const pages = pick(world, "PIDGEY");
expect(pages.slice(0, 2)).toEqual([
"PIDGEY grew to level 5!",
pages[1],
]);
expect(pages[1]).toMatch(/^ATTACK \d+ DEFENSE \d+ SPCL\.ATK \d+ SPCL\.DEF \d+ SPEED \d+$/);
expect(pages.slice(2)).toEqual([
"PIDGEY is trying to learn SAND-ATTACK.",
"But PIDGEY can't learn more than four moves.",
"Delete an older move to make room for SAND-ATTACK?",
]);
world.choose(1);
expect(until(world)).toEqual(["Which move should be forgotten?"]);
expect(menuItems(world)).toEqual(["TACKLE", "CUT", "LEER", "GUST"]);
expect(pick(world, "CUT")).toEqual([
"HM moves can't be forgotten now.",
"Which move should be forgotten?",
]);
expect(pick(world, "LEER")).toEqual([
"1, 2 and… Poof!",
"PIDGEY forgot LEER.",
"And…",
"PIDGEY learned SAND-ATTACK!",
]);
close(world);
expect(world.state.bag.items.find((slot) => slot.item === RARE_CANDY)).toBeTruthy();
const mon = world.state.party[1];
expect(mon.level).toBe(5);
expect(mon.exp).toBe(data.expAtLevel(data.baseData(PIDGEY).growthRate, 5));
expect(mon.moves).toEqual([TACKLE, CUT, SAND_ATTACK, GUST]);
expect(mon.pp[2]).toBe(data.move(SAND_ATTACK).pp);
// The Rare Candy adds the maximum HP's rise even to a fainted Pokémon.
expect(mon.hp).toBe(mon.stats[0] - pidgey.stats[0]);
expect(mon.hp).toBeGreaterThan(0);
expect(world.state.bag.items.at(-1)).toEqual({ item: RARE_CANDY, quantity: 1 });
});
it("can decline to learn the move", () => {
const world = fixture();
world.state.party[1].moves = [TACKLE, CUT, LEER, GUST];
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
pick(world, "PIDGEY");
world.choose(0);
expect(until(world)).toEqual(["Stop learning SAND-ATTACK?"]);
world.choose(1);
expect(until(world)).toEqual(["PIDGEY did not learn SAND-ATTACK."]);
close(world);
expect(world.state.party[1].moves).toEqual([TACKLE, CUT, LEER, GUST]);
expect(world.state.party[1].level).toBe(5);
});
it("evolves after a Rare Candy, and B can stop it", () => {
for (const stop of [false, true]) {
const world = fixture();
const data = world.assets.pokemon;
world.state.party[0] = generatePartyMon(data, {
species: CYNDAQUIL,
level: 13,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.bag.items.push({ item: RARE_CANDY, quantity: 1 });
open(world);
pick(world, "PACK");
pickItem(world, RARE_CANDY);
pick(world, "USE");
world.choose(0);
// Acknowledge pages until the evolution animation starts.
const pages: string[] = [];
for (let i = 0; i < 200 && !world.evolution; i++) {
const dialogue = world.dialogue;
if (dialogue) {
pages.push(dialogue.pages[dialogue.index].replace(/\n/g, " "));
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else world.tick();
}
expect(pages[0]).toBe("CYNDAQUIL grew to level 14!");
expect(pages.at(-1)).toBe("What? CYNDAQUIL is evolving!");
expect(world.picture).toBe(CYNDAQUIL);
// B does nothing before the flashes begin.
world.advanceDialogue();
expect(world.evolution).toBeDefined();
for (let tick = 0; tick < 40; tick++) world.tick();
if (stop) {
world.advanceDialogue();
expect(until(world)).toEqual(["Huh? CYNDAQUIL stopped evolving!"]);
} else {
expect(until(world)).toEqual([
"Congratulations! Your CYNDAQUIL",
"evolved into QUILAVA!",
]);
}
close(world);
const mon = world.state.party[0];
expect(mon.level).toBe(14);
expect([mon.species, mon.nickname]).toEqual(
stop ? [CYNDAQUIL, "CYNDAQUIL"] : [QUILAVA, "QUILAVA"],
);
expect(mon.stats).toEqual(
calcStats(data, mon.species, mon.dvs, 14, mon.statExp),
);
expect(world.state.pokedex.caught.has(QUILAVA)).toBe(!stop);
expect(world.state.bag.items.some((slot) => slot.item === RARE_CANDY)).toBe(false);
}
});
it("evolves with a stone, which B cannot stop, unless held back by an Everstone", () => {
const world = fixture();
const data = world.assets.pokemon;
world.state.party[1] = generatePartyMon(data, {
species: PIKACHU,
level: 10,
item: EVERSTONE,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x9abc,
caughtData: 0,
});
world.state.party[1].nickname = "SPARKY";
world.state.bag.items.push({ item: THUNDERSTONE, quantity: 2 });
open(world);
pick(world, "PACK");
pickItem(world, THUNDERSTONE);
expect(pick(world, "USE")).toEqual(["Use on which POKéMON?"]);
const party = world.dialogue?.choice;
expect(party?.kind === "menu" && party.details).toEqual(["NOT ABLE", "ABLE"]);
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
pickItem(world, THUNDERSTONE);
pick(world, "USE");
expect(pick(world, "SPARKY")).toEqual(["It won't have any effect."]);
close(world);
world.state.party[1].item = 0;
open(world);
pick(world, "PACK");
pickItem(world, THUNDERSTONE);
pick(world, "USE");
world.choose(1);
for (let i = 0; i < 20 && !world.evolution; i++) {
if (world.dialogue?.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
}
for (let tick = 0; tick < 60; tick++) world.tick();
world.advanceDialogue();
expect(until(world)).toEqual([
"Congratulations! Your SPARKY",
"evolved into RAICHU!",
]);
close(world);
const mon = world.state.party[1];
// A nickname stays.
expect([mon.species, mon.nickname]).toEqual([RAICHU, "SPARKY"]);
expect(world.state.bag.items.at(-1)).toEqual({ item: THUNDERSTONE, quantity: 1 });
});
it("raises stat experience with a vitamin, up to its limit", () => {
const world = fixture();
const data = world.assets.pokemon;
const mon = world.state.party[0];
const hp = mon.hp,
happiness = mon.happiness;
world.state.bag.items.push({ item: HP_UP, quantity: 2 });
open(world);
pick(world, "PACK");
pickItem(world, HP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["CYNDAQUIL's HEALTH rose."]);
close(world);
const raised = world.state.party[0];
expect(raised.statExp[0]).toBe(2560);
expect(raised.stats).toEqual(calcStats(data, CYNDAQUIL, raised.dvs, 8, raised.statExp));
expect(raised.hp).toBe(hp);
expect(raised.happiness).toBeGreaterThan(happiness);
raised.statExp[0] = 100 << 8;
open(world);
pick(world, "PACK");
pickItem(world, HP_UP);
pick(world, "USE");
expect(pick(world, "CYNDAQUIL")).toEqual(["It won't have any effect."]);
close(world);
expect(world.state.party[0].statExp[0]).toBe(100 << 8);
expect(world.state.bag.items.at(-1)).toEqual({ item: HP_UP, quantity: 1 });
});
it("saves the state from before the menu while it is open", () => {