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

+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", () => {