Open Route 29 and Cherrygrove City
Checks / web (push) Canceled after 0s

- Enable Route 29, its Route 46 gate, Cherrygrove City and its buildings.
- Run item balls through FindItemInBallScript and fruit trees through
  FruitTreeScript, with a native daily reset of the daily engine flags.
- Port the Pokémon Center nurse (HealParty with the PP Up clamp,
  CheckPokerus, phone-call checks) and record the whiteout spawn on
  entering a Pokémon Center, as EnterMapWarp does.
- Port standard Poké Marts with keyboard, D-pad and pointer menus.
- Add the recoil battle command (Struggle) and VAR_BOXSPACE.
- Save fruit trees, the daily reset day and the last spawn map; older
  version-3 backups migrate by absence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-23 17:25:02 +02:00
1 parent 11d1153802
commit 796fe8f6d5
24 files changed
+1549 -59

No files matched your search

+166 -11
View File
@@ -1,10 +1,11 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, SYMBOLS } from "../rom/profile";
import { INITIAL_POSITION, NUM_FRUIT_TREES, SYMBOLS } from "../rom/profile";
import {
PLAYER,
ScriptRun,
copyScriptState,
emptyScriptState,
type MartEntry,
type ScriptEffect,
type ScriptHost,
type ScriptState,
@@ -57,6 +58,12 @@ export interface NativeSave {
bag: Bag;
pokedex: { seen: number[]; caught: number[] };
money: number;
fruitTrees: number[];
dailyReset: number | null;
/** wLastSpawnMapGroup/Number: the outdoor map last entered through a
* Pokémon Center door, for the whiteout/blackout spawn. Null on early
* version-3 backups and before any Center has been entered. */
lastSpawnMap: string | null;
}
/** A random byte from the platform's secure generator. */
@@ -112,7 +119,22 @@ export type Choice =
| { kind: "yesno"; selected: 0 | 1 }
| { kind: "weekday"; names: string[]; selected: number }
/** The naming screen: an empty answer keeps `current`. */
| { kind: "name"; current: string; max: number; selected?: undefined };
| { kind: "name"; current: string; max: number; selected?: undefined }
/** The mart's BUY/SELL/QUIT box. */
| { kind: "martmenu"; prompt: string; selected: number }
/** The mart's item list; `selected` answers with that entry's index. */
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
| {
kind: "martquantity";
mode: "buy" | "sell";
item: number;
name: string;
price: number;
max: number;
prompt: string;
selected: number;
};
export interface Dialogue {
pages: string[];
index: number;
@@ -247,6 +269,7 @@ export class GameWorld {
private script?: ActiveScript;
/** Scene scripts are re-evaluated only after the map or state changes. */
private sceneCheck = true;
private dailyFlags?: number[];
constructor(
readonly assets: CrystalAssets,
@@ -524,7 +547,18 @@ export class GameWorld {
const arrival = target.warps[warp.targetWarp - 1];
if (!arrival || !this.walkable(target, arrival.x, arrival.y))
throw new Error("Warp destination is invalid.");
const entry = this.prepareMap(target.id, "warp");
// EnterMapWarp's SetSpawn: walking from an outdoor map into a Pokémon
// Center records that outdoor map as the whiteout/blackout spawn.
let sourceState = this.state;
if (
(target.tileset === 7 || target.tileset === 21) && // TILESET_POKECENTER, TILESET_POKECOM_CENTER
(this.map.environment === 1 || this.map.environment === 2) // TOWN, ROUTE
)
sourceState = {
...copyScriptState(sourceState),
lastSpawnMap: this.mapId,
};
const entry = this.prepareMap(target.id, "warp", sourceState);
return {
entry,
arrival,
@@ -769,6 +803,7 @@ export class GameWorld {
committedPosition?: { x: number; y: number },
scene = false,
wild?: { species: number; level: number },
itemBall?: { item: number; quantity: number },
) {
const committed = this.save();
if (committedPosition) {
@@ -785,6 +820,7 @@ export class GameWorld {
this.host(lastTalked),
{
wild,
itemBall,
},
),
committed,
@@ -957,6 +993,45 @@ export class GameWorld {
choice: { kind: "name", current: effect.current, max: effect.max },
};
return true;
case "martmenu":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martmenu",
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
case "martlist":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martlist",
mode: effect.mode,
entries: effect.entries,
selected: effect.selected,
},
};
return true;
case "martquantity":
this.dialogue = {
pages: [""],
index: 0,
choice: {
kind: "martquantity",
mode: effect.mode,
item: effect.item,
name: effect.name,
price: effect.price,
max: effect.max,
prompt: effect.prompt,
selected: effect.selected,
},
};
return true;
}
}
@@ -1162,6 +1237,19 @@ export class GameWorld {
if (!prompt && view.message?.prompt) this.pumpBattle();
}
/** CheckDailyResetTimer, from CheckTimeEvents after scene scripts: the
* first idle check on a new game day clears the daily engine flags. */
private checkDailyReset() {
const now = this.now();
const day =
Math.floor(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
) + this.state.clock.weekdayOffset;
if (this.state.dailyReset === day) return;
this.dailyFlags ??= this.assets.dailyEngineFlags();
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
this.state.dailyReset = day;
}
/** Scene scripts run while the player is idle (RunSceneScript). */
private checkScene() {
if (!this.sceneCheck || this.busy) return;
@@ -1229,6 +1317,7 @@ export class GameWorld {
if (this.dialogue) return;
this.checkScene();
if (this.busy) return;
this.checkDailyReset();
if (
FORCED_WARP_EXITS.has(this.assets.collision(this.map, player.x, player.y))
)
@@ -1270,6 +1359,21 @@ export class GameWorld {
);
let script: number | undefined;
if (object) {
if (object.kind === 1) {
// OBJECTTYPE_ITEMBALL: the script field names raw data (the itemball
// macro's item, quantity), not a script; FindItemInBallScript always
// runs, with hLastTalked left on this object as TryObjectEvent does.
const r = this.assets.reader;
this.startScript(
SYMBOLS.findItemInBallScript,
object.id,
undefined,
false,
undefined,
{ item: r.byte(object.script), quantity: r.byte(object.script + 1) },
);
return;
}
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
@@ -1323,8 +1427,14 @@ export class GameWorld {
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.holdTicks !== undefined) return;
if (last && dialogue.choice) {
// B answers NO on a yes/no box; other choices need their own answer.
if (dialogue.choice.kind === "yesno") this.choose(0);
// B answers NO on a yes/no box, quits the mart menu (matching the
// ROM's VerticalMenu carry) or backs out of a mart list/quantity
// screen; other choices need their own answer.
const choice = dialogue.choice;
if (choice.kind === "yesno") this.choose(0);
else if (choice.kind === "martmenu") this.choose(2);
else if (choice.kind === "martlist" || choice.kind === "martquantity")
this.choose(-1);
return;
}
if (!last) {
@@ -1348,6 +1458,29 @@ export class GameWorld {
if (choice.kind === "yesno") {
if (direction === "up") choice.selected = 1;
if (direction === "down") choice.selected = 0;
} else if (choice.kind === "martmenu") {
if (direction === "up") choice.selected = (choice.selected + 2) % 3;
if (direction === "down") choice.selected = (choice.selected + 1) % 3;
} else if (choice.kind === "martlist") {
const length = choice.entries.length;
if (!length) return;
if (direction === "up")
choice.selected = (choice.selected + length - 1) % length;
if (direction === "down")
choice.selected = (choice.selected + 1) % length;
} else if (choice.kind === "martquantity") {
// BuySellToss_InterpretJoypad: Up/Down step by 1 and wrap; Left/Right
// step by 10 and clamp to the 1-max range.
if (direction === "down")
choice.selected =
choice.selected > 1 ? choice.selected - 1 : choice.max;
if (direction === "up")
choice.selected =
choice.selected < choice.max ? choice.selected + 1 : 1;
if (direction === "left")
choice.selected = Math.max(1, choice.selected - 10);
if (direction === "right")
choice.selected = Math.min(choice.max, choice.selected + 10);
} else if (direction === "up") choice.selected = (choice.selected + 1) % 7;
else if (direction === "down") choice.selected = (choice.selected + 6) % 7;
}
@@ -1408,6 +1541,9 @@ export class GameWorld {
caught: [...state.pokedex.caught].sort((a, b) => a - b),
},
money: state.money,
fruitTrees: [...state.fruitTrees].sort((a, b) => a - b),
dailyReset: state.dailyReset,
lastSpawnMap: state.lastSpawnMap,
};
}
restore(value: unknown) {
@@ -1470,7 +1606,14 @@ export class GameWorld {
!species(save.pokedex.seen) ||
!species(save.pokedex.caught) ||
!save.pokedex.caught.every((id) => save.pokedex.seen.includes(id)) ||
!integer(save.money, 0, C.MAX_MONEY)
!integer(save.money, 0, C.MAX_MONEY) ||
!Array.isArray(save.fruitTrees) ||
!save.fruitTrees.every((id) => integer(id, 1, NUM_FRUIT_TREES)) ||
new Set(save.fruitTrees).size !== save.fruitTrees.length ||
!(save.dailyReset === null || integer(save.dailyReset, 0, 0xffffff)) ||
(save.lastSpawnMap !== null &&
(typeof save.lastSpawnMap !== "string" ||
!/^\d{1,2}:\d{1,3}$/.test(save.lastSpawnMap)))
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
@@ -1517,6 +1660,10 @@ export class GameWorld {
caught: new Set(save.pokedex.caught),
},
money: save.money,
fruitTrees: new Set(save.fruitTrees),
dailyReset: save.dailyReset,
lastSpawnMap: save.lastSpawnMap,
specialPhoneCall: 0,
};
let entry;
try {
@@ -1571,11 +1718,16 @@ export function migrateSave(
if (!save || typeof save !== "object" || save.format !== "crystal-native")
return undefined;
if (save.version === 3) {
// Early version-3 backups predate battle rewards and the money field.
// Only absence migrates; an explicitly invalid value must still be rejected.
return (Object.hasOwn(save, "money")
? save
: { ...save, money: C.START_MONEY }) as unknown as NativeSave;
// Early version-3 backups predate battle rewards, the money field, daily
// state and the recorded whiteout spawn. Only absence migrates; an
// explicitly invalid value must still be rejected.
return {
...save,
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
} as unknown as NativeSave;
}
if (save.version !== 1 && save.version !== 2) return undefined;
const previous =
@@ -1595,6 +1747,9 @@ export function migrateSave(
party: [],
bag: emptyBag(),
pokedex: { seen: [], caught: [] },
lastSpawnMap: null,
money: C.START_MONEY,
fruitTrees: [],
dailyReset: null,
} as unknown as NativeSave;
}