- 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:
1 parent
11d1153802
commit
796fe8f6d5
24 files changed
+1549
-59
No files matched your search
@@ -967,6 +967,8 @@ export class Battle {
|
||||
return yield* this.confuseCommand();
|
||||
case COMMAND.draintarget:
|
||||
return yield* this.drainTarget();
|
||||
case COMMAND.recoil:
|
||||
return yield* this.recoil();
|
||||
case COMMAND.checksafeguard:
|
||||
return yield* this.checkSafeguard();
|
||||
case COMMAND.splash:
|
||||
@@ -1546,6 +1548,14 @@ export class Battle {
|
||||
yield this.hud();
|
||||
yield* this.say(BATTLE.SuckedHealthText);
|
||||
}
|
||||
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
|
||||
private *recoil(): Gen {
|
||||
const mon = this.user.mon;
|
||||
mon.hp = Math.max(0, mon.hp - (this.curDamage >> 2 || 1));
|
||||
if (this.turn === 0) this.updateBattleMonInParty();
|
||||
yield this.hud();
|
||||
yield* this.say(BATTLE.RecoilText);
|
||||
}
|
||||
private *checkSafeguard(): Gen {
|
||||
if (!this.safeguarded()) return;
|
||||
this.attackMissed = 1;
|
||||
|
||||
@@ -234,6 +234,7 @@ export const COMMAND = {
|
||||
freezetarget: 24,
|
||||
paralyzetarget: 25,
|
||||
flinchtarget: 37,
|
||||
recoil: 39,
|
||||
confuse: 42,
|
||||
confusetarget: 43,
|
||||
poison: 47,
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
LAST_MAP_GROUP_SIZE,
|
||||
MAP_GROUP_COUNT,
|
||||
NATIVE_MAPS,
|
||||
NUM_ENGINE_FLAGS,
|
||||
RAM,
|
||||
SYMBOLS,
|
||||
} from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
@@ -360,6 +362,18 @@ export class CrystalAssets {
|
||||
}
|
||||
throw new Error("Wild encounter data is unterminated.");
|
||||
}
|
||||
/** Engine flags kept in the four bytes CheckDailyResetTimer clears
|
||||
* (wDailyFlags1 through wUnusedDailyFlag), from the ROM's EngineFlags. */
|
||||
dailyEngineFlags(): number[] {
|
||||
const r = this.reader,
|
||||
flags: number[] = [];
|
||||
for (let index = 0; index < NUM_ENGINE_FLAGS; index++) {
|
||||
const address = r.word(SYMBOLS.engineFlags + index * 3);
|
||||
if (address >= RAM.dailyFlags1 && address <= RAM.unusedDailyFlag)
|
||||
flags.push(index);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
/** Emote bubble graphics (data/sprites/emotes.asm). */
|
||||
emote(index: number): Uint8Array {
|
||||
const r = this.reader,
|
||||
|
||||
@@ -129,6 +129,20 @@ export class PokemonData {
|
||||
if (!pocket) throw new Error("Item has no pocket.");
|
||||
return pocket;
|
||||
}
|
||||
/** ItemAttributes: price, in Poké Dollars. */
|
||||
itemPrice(item: number): number {
|
||||
this.checkItem(item);
|
||||
return this.reader.word(SYMBOLS.itemAttributes + (item - 1) * 7);
|
||||
}
|
||||
/** ItemAttributes permissions, bit 7 (CANT_TOSS_F): mostly key items and
|
||||
* TMs/HMs, checked by _CheckTossableItem before a mart buys one back. */
|
||||
itemUnsellable(item: number): boolean {
|
||||
this.checkItem(item);
|
||||
const permissions = this.reader.byte(
|
||||
SYMBOLS.itemAttributes + (item - 1) * 7 + 4,
|
||||
);
|
||||
return Boolean(permissions & 0x80);
|
||||
}
|
||||
pocketName(pocket: Pocket): string {
|
||||
const r = this.reader,
|
||||
bank = r.bank(SYMBOLS.itemPocketNames);
|
||||
|
||||
@@ -40,6 +40,7 @@ export const SYMBOLS = {
|
||||
giveItemScript: 0x96f77,
|
||||
putItemInPocketText: 0x9705b,
|
||||
pocketIsFullText: 0x97060,
|
||||
findItemInBallScript: 0x122ce,
|
||||
caughtAskNicknameText: 0x4db44,
|
||||
nicknameStrings: 0x11780,
|
||||
wildBattleScript: 0x97cf9,
|
||||
@@ -48,6 +49,22 @@ export const SYMBOLS = {
|
||||
johtoGrassWildMons: 0x2a5e9,
|
||||
grassMonProbTable: 0x2a1cb,
|
||||
grassCollisions: 0x149ea,
|
||||
fruitTreeScript: 0x44000,
|
||||
fruitTreeItems: 0x44097,
|
||||
engineFlags: 0x80462,
|
||||
marts: 0x160a9,
|
||||
martWelcomeText: 0x15f83,
|
||||
martAskMoreText: 0x15fb9,
|
||||
martComeAgainText: 0x15fb4,
|
||||
martHowManyText: 0x15e0e,
|
||||
martFinalPriceText: 0x15e13,
|
||||
martNoMoneyText: 0x15fa5,
|
||||
martPackFullText: 0x15faa,
|
||||
martThanksText: 0x15fa0,
|
||||
martCantBuyText: 0x15faf,
|
||||
martSellHowManyText: 0x15f73,
|
||||
martSellPriceText: 0x15f78,
|
||||
martBoughtText: 0x15fbe,
|
||||
} as const;
|
||||
|
||||
/** Assembly routines that scripts `callasm` but that do nothing. */
|
||||
@@ -60,6 +77,11 @@ export const EMPTY_ROUTINES = {
|
||||
export const NATIVE_ROUTINES = {
|
||||
halveMoney: 0x12513,
|
||||
getWhiteoutSpawn: 0x12527,
|
||||
tryReceiveItem: 0x122f8,
|
||||
getCurTreeFruit: 0x44041,
|
||||
tryResetFruitTrees: 0x4404c,
|
||||
checkFruitTree: 0x44055,
|
||||
pickedFruitTree: 0x4405f,
|
||||
} as const;
|
||||
|
||||
/** Native handlers are selected by the routine a special index points to,
|
||||
@@ -73,6 +95,9 @@ export const SPECIAL_ROUTINES = {
|
||||
toggleMaptileDecorations: 0x26feb,
|
||||
fadeOutToWhite: 0x8c084,
|
||||
healParty: 0xc658,
|
||||
healMachineAnim: 0x12324,
|
||||
checkPokerus: 0xc419,
|
||||
stubbedTrainerRankingsHealings: 0x1060a2,
|
||||
warpToSpawnPoint: 0x97c28,
|
||||
} as const;
|
||||
|
||||
@@ -90,6 +115,12 @@ export const RAM = {
|
||||
enemyMonNickname: 0xc616,
|
||||
curPartyLevel: 0xd143,
|
||||
monOrItemNameBuffer: 0xd050,
|
||||
curFruit: 0xd03f,
|
||||
dailyFlags1: 0xdc1e,
|
||||
unusedDailyFlag: 0xdc21,
|
||||
itemQuantityChange: 0xd10c,
|
||||
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
||||
moneyTemp: 0xffc3,
|
||||
} as const;
|
||||
|
||||
/** Event constants named directly by translated special routines. */
|
||||
@@ -101,6 +132,11 @@ export const EVENTS = {
|
||||
playersHouseBigDoll: 1860,
|
||||
} as const;
|
||||
|
||||
/** Engine flag indices named directly by translated routines. */
|
||||
export const ENGINE_FLAGS = {
|
||||
allFruitTrees: 84,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
@@ -187,6 +223,7 @@ export const BATTLE = {
|
||||
ProtectedByText: 0x815a9,
|
||||
ProtectingItselfText: 0x8167a,
|
||||
RageBuildingText: 0x810f3,
|
||||
RecoilText: 0x8134d,
|
||||
RecoveredUsingText: 0x80bc2,
|
||||
RegainedHealthText: 0x8100c,
|
||||
SafeguardProtectText: 0x81733,
|
||||
@@ -217,6 +254,8 @@ export const BATTLE = {
|
||||
export const MAP_GROUP_COUNT = 26;
|
||||
export const NUM_POKEMON = 251;
|
||||
export const NUM_ITEMS = 190;
|
||||
export const NUM_ENGINE_FLAGS = 162;
|
||||
export const NUM_FRUIT_TREES = 30;
|
||||
/** The last group has no following pointer to bound it. */
|
||||
export const LAST_MAP_GROUP_SIZE = 11;
|
||||
|
||||
@@ -224,11 +263,19 @@ export const LAST_MAP_GROUP_SIZE = 11;
|
||||
* maps remain unavailable; the label only distinguishes interiors that share
|
||||
* a ROM landmark name. */
|
||||
export const NATIVE_MAPS: Record<string, string> = {
|
||||
"24:3": "Route 29",
|
||||
"24:13": "Route 29 gate",
|
||||
"24:4": "New Bark Town",
|
||||
"24:5": "Elm’s lab",
|
||||
"24:6": "Your home",
|
||||
"24:7": "Your room",
|
||||
"24:8": "Neighbor’s house",
|
||||
"24:9": "Elm’s house",
|
||||
"26:3": "Cherrygrove City",
|
||||
"26:4": "Poké Mart",
|
||||
"26:5": "Pokémon Center",
|
||||
"26:6": "Gym speech house",
|
||||
"26:7": "Guide Gent’s house",
|
||||
"26:8": "Evolution speech house",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
+8
-3
@@ -63,9 +63,9 @@ export class NativeRuntime implements GameRuntime {
|
||||
}
|
||||
if (button === "a") world.interact();
|
||||
if (button === "b") world.advanceDialogue();
|
||||
if ((button === "up" || button === "down") &&
|
||||
if (["up", "down", "left", "right"].includes(button) &&
|
||||
(world.dialogue?.choice || world.battle?.prompt?.kind === "yesno"))
|
||||
world.moveCursor(button);
|
||||
world.moveCursor(button as Direction);
|
||||
if ((button === "start" || button === "select") && !world.busy)
|
||||
world.notice =
|
||||
"Use the Savegames tab to save, export or import. Party, pack and Pokégear menus are not ported yet.";
|
||||
@@ -142,7 +142,12 @@ export class NativeRuntime implements GameRuntime {
|
||||
],
|
||||
dialogue?.index,
|
||||
dialogue?.pages,
|
||||
dialogue?.choice?.selected,
|
||||
// The whole choice, not just `selected`: mart screens replace one
|
||||
// choice kind with another (menu -> list -> quantity) while keeping
|
||||
// the same page and index, and `world.liveState.money` the mart UI
|
||||
// reads directly is otherwise absent from this signature.
|
||||
dialogue?.choice,
|
||||
world.liveState.money,
|
||||
dialogue?.holdTicks === undefined,
|
||||
]);
|
||||
if (signature === this.published) return;
|
||||
|
||||
+315
-14
@@ -1,7 +1,9 @@
|
||||
import { RomReader } from "../rom/reader";
|
||||
import {
|
||||
EMPTY_ROUTINES,
|
||||
ENGINE_FLAGS,
|
||||
NATIVE_ROUTINES,
|
||||
NUM_FRUIT_TREES,
|
||||
EVENTS,
|
||||
RAM,
|
||||
SPECIAL_ROUTINES,
|
||||
@@ -50,6 +52,18 @@ export interface ScriptState {
|
||||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||||
/** wMoney: a 24-bit amount. */
|
||||
money: number;
|
||||
/** wFruitTreeFlags: trees (FRUITTREE_* ids) picked since the last reset. */
|
||||
fruitTrees: Set<number>;
|
||||
/** Local calendar day of the last CheckDailyResetTimer reset; null until
|
||||
* the first check of a new game, which resets immediately. */
|
||||
dailyReset: number | null;
|
||||
/** wLastSpawnMapGroup/wLastSpawnMapNumber: the outdoor map last entered
|
||||
* through a Pokémon Center door (EnterMapWarp's SetSpawn), used by
|
||||
* GetWhiteoutSpawn. Null before any Center has been entered. */
|
||||
lastSpawnMap: string | null;
|
||||
/** wSpecialPhoneCallID's low byte (checkphonecall): nonzero while a call
|
||||
* is pending. No ported content ever sets it, so it is not saved. */
|
||||
specialPhoneCall: number;
|
||||
}
|
||||
export const emptyScriptState = (): ScriptState => ({
|
||||
events: new Set(),
|
||||
@@ -62,6 +76,10 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
bag: emptyBag(),
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
money: C.START_MONEY,
|
||||
fruitTrees: new Set(),
|
||||
dailyReset: null,
|
||||
lastSpawnMap: null,
|
||||
specialPhoneCall: 0,
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
events: new Set(state.events),
|
||||
@@ -77,6 +95,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
caught: new Set(state.pokedex.caught),
|
||||
},
|
||||
money: state.money,
|
||||
fruitTrees: new Set(state.fruitTrees),
|
||||
dailyReset: state.dailyReset,
|
||||
lastSpawnMap: state.lastSpawnMap,
|
||||
specialPhoneCall: state.specialPhoneCall,
|
||||
});
|
||||
|
||||
/** Object operands keep the script's numbering: 0 is the player, 2+ are map
|
||||
@@ -85,7 +107,15 @@ export const PLAYER = 0;
|
||||
const LAST_TALKED = 0xfe;
|
||||
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
const MONS_PER_BOX = 20;
|
||||
|
||||
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
|
||||
export interface MartEntry {
|
||||
item: number;
|
||||
name: string;
|
||||
price: number;
|
||||
owned?: number;
|
||||
}
|
||||
export type ScriptEffect =
|
||||
/** Pages the player acknowledges. With holdTicks the last page instead
|
||||
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
||||
@@ -106,6 +136,23 @@ export type ScriptEffect =
|
||||
| { kind: "picture"; species: number }
|
||||
/** The naming screen; answered with the name, or "" to keep `current`. */
|
||||
| { kind: "name"; pages: string[]; current: string; max: number }
|
||||
/** pokemart's BUY/SELL/QUIT box; answered 0/1/2. */
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
/** The mart's item list (buy: full stock; sell: the bag's pockets);
|
||||
* answered with the chosen entry's index, or a negative number to cancel. */
|
||||
| { kind: "martlist"; mode: "buy" | "sell"; entries: MartEntry[]; selected: number }
|
||||
/** The 1-99 quantity stepper; answered with the chosen amount, or a
|
||||
* number below 1 to cancel back to the item list. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
mode: "buy" | "sell";
|
||||
item: number;
|
||||
name: string;
|
||||
price: number;
|
||||
max: number;
|
||||
prompt: string;
|
||||
selected: number;
|
||||
}
|
||||
/** startbattle: answered with wBattleResult. */
|
||||
| { kind: "battle"; wild: { species: number; level: number } }
|
||||
/** newloadmap: the world enters this map after the script ends. */
|
||||
@@ -162,8 +209,14 @@ export class ScriptRun {
|
||||
deferred?: number;
|
||||
done = false;
|
||||
private strings: Record<number, string> = {};
|
||||
/** Native values for text_decimal, keyed by RAM address (wItemQuantityChange,
|
||||
* hMoneyTemp): the mart's quantity and price texts. */
|
||||
private numbers: Record<number, number> = {};
|
||||
/** wCurItem: the item named by itemnotify and pocketisfull. */
|
||||
private currentItem = 0;
|
||||
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
|
||||
private fruitTree = 0;
|
||||
private fruit = 0;
|
||||
private iterator: Generator<ScriptEffect, void, Input>;
|
||||
private instructions = 0;
|
||||
|
||||
@@ -176,6 +229,9 @@ export class ScriptRun {
|
||||
callback?: boolean;
|
||||
/** The wild Pokémon chosen by the encounter check (wTempWildMonSpecies). */
|
||||
wild?: { species: number; level: number };
|
||||
/** wItemBallItemID/wItemBallQuantity, copied from the itemball data the
|
||||
* facing object's script pointer names. */
|
||||
itemBall?: { item: number; quantity: number };
|
||||
} = {},
|
||||
) {
|
||||
this.state = copyScriptState(state);
|
||||
@@ -199,6 +255,7 @@ export class ScriptRun {
|
||||
return {
|
||||
...this.host.text(),
|
||||
strings: { ...this.host.text().strings, ...this.strings },
|
||||
numbers: { ...this.host.text().numbers, ...this.numbers },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +309,14 @@ export class ScriptRun {
|
||||
return effect;
|
||||
};
|
||||
const sceneOwner = (mapId: string) => this.host.sceneOwner(mapId);
|
||||
// GetWhiteoutSpawn/IsSpawnPoint: the table index of the last recorded
|
||||
// spawn map, or SPAWN_HOME when unset or no longer a listed spawn point.
|
||||
const findSpawn = (mapId: string | null) => {
|
||||
if (mapId)
|
||||
for (let entry = SYMBOLS.spawnPoints, index = 0; r.byte(entry) !== 0xff; entry += 4, index++)
|
||||
if (`${r.byte(entry)}:${r.byte(entry + 1)}` === mapId) return index;
|
||||
return 0;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
if (++this.instructions > 4096)
|
||||
@@ -312,9 +377,10 @@ export class ScriptRun {
|
||||
const index = word();
|
||||
const entry = SYMBOLS.specials + index * 3;
|
||||
const routine = r.address(r.byte(entry), r.word(entry + 1));
|
||||
const special = yield* this.special(routine, pages, take);
|
||||
if (special === undefined)
|
||||
const result = yield* this.special(routine, value, pages, take);
|
||||
if (result === undefined)
|
||||
throw new UnsupportedScript(offset, opcode, ` (special ${index})`);
|
||||
value = result;
|
||||
break;
|
||||
}
|
||||
case 0x11:
|
||||
@@ -352,13 +418,51 @@ export class ScriptRun {
|
||||
const routine = far();
|
||||
if (routine === NATIVE_ROUTINES.halveMoney)
|
||||
this.state.money = this.state.money >> 1;
|
||||
else if (routine === NATIVE_ROUTINES.getCurTreeFruit)
|
||||
this.fruit = r.byte(SYMBOLS.fruitTreeItems + this.fruitTree - 1);
|
||||
else if (routine === NATIVE_ROUTINES.tryResetFruitTrees) {
|
||||
// Trees regrow once per daily reset of ENGINE_ALL_FRUIT_TREES.
|
||||
if (!this.state.flags.has(ENGINE_FLAGS.allFruitTrees)) {
|
||||
this.state.fruitTrees.clear();
|
||||
this.state.flags.add(ENGINE_FLAGS.allFruitTrees);
|
||||
}
|
||||
} else if (routine === NATIVE_ROUTINES.checkFruitTree)
|
||||
value = Number(this.state.fruitTrees.has(this.fruitTree));
|
||||
else if (routine === NATIVE_ROUTINES.pickedFruitTree)
|
||||
this.state.fruitTrees.add(this.fruitTree);
|
||||
else if (routine === NATIVE_ROUTINES.getWhiteoutSpawn)
|
||||
// No Pokémon Center is ported, so the spawn is still SPAWN_HOME.
|
||||
spawn = 0;
|
||||
else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
||||
spawn = findSpawn(this.state.lastSpawnMap);
|
||||
else if (routine === NATIVE_ROUTINES.tryReceiveItem) {
|
||||
// FindItemInBallScript.TryReceiveItem: give the ball's item and
|
||||
// name it for FoundItemText (wStringBuffer3), matching GetItemName
|
||||
// + CopyName2. wScriptVar (value) reports success for iffalse.
|
||||
const ball = this.options.itemBall;
|
||||
if (!ball) throw new UnsupportedScript(offset, opcode, " (no item ball)");
|
||||
const name = data.itemName(ball.item);
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
this.strings[RAM.stringBuffer3] = name;
|
||||
this.currentItem = ball.item;
|
||||
value = Number(
|
||||
receiveItem(data, this.state.bag, ball.item, ball.quantity),
|
||||
);
|
||||
} else if (!Object.values(EMPTY_ROUTINES).includes(routine as never))
|
||||
throw new UnsupportedScript(offset, opcode);
|
||||
break;
|
||||
}
|
||||
case 0x19: {
|
||||
// readmem: only the fruit a tree bears is readable so far.
|
||||
if (word() !== RAM.curFruit) throw new UnsupportedScript(offset, opcode);
|
||||
value = this.fruit;
|
||||
break;
|
||||
}
|
||||
case 0x9b: {
|
||||
// Script_fruittree jumps to FruitTreeScript for FRUITTREE_* id.
|
||||
const tree = byte();
|
||||
if (tree < 1 || tree > NUM_FRUIT_TREES) throw new Error("Invalid fruit tree.");
|
||||
this.fruitTree = tree;
|
||||
p = SYMBOLS.fruitTreeScript;
|
||||
break;
|
||||
}
|
||||
case 0x5b:
|
||||
break; // randomwildmon clears wBattleScriptFlags.
|
||||
case 0x5f: {
|
||||
@@ -386,6 +490,12 @@ export class ScriptRun {
|
||||
case 0x1c:
|
||||
value = this.readVar(byte(), offset);
|
||||
break;
|
||||
case 0x9c:
|
||||
this.state.specialPhoneCall = word();
|
||||
break;
|
||||
case 0x9d:
|
||||
value = Number(this.state.specialPhoneCall !== 0);
|
||||
break;
|
||||
case 0x1f:
|
||||
case 0x9e: {
|
||||
const item = byte(),
|
||||
@@ -723,6 +833,15 @@ export class ScriptRun {
|
||||
case 0x93:
|
||||
yield* flush();
|
||||
return;
|
||||
case 0x94: {
|
||||
const type = byte(),
|
||||
martId = word();
|
||||
if (type !== 0 /* MARTTYPE_STANDARD */)
|
||||
throw new UnsupportedScript(offset, opcode, " (mart type)");
|
||||
yield* flush();
|
||||
yield* this.pokemart(martId, pages);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UnsupportedScript(offset, opcode);
|
||||
}
|
||||
@@ -730,6 +849,164 @@ export class ScriptRun {
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenMartDialog/MartDialog/StandardMart (engine/items/mart.asm), for
|
||||
* MARTTYPE_STANDARD only. Welcome, then BUY/SELL/QUIT until the player
|
||||
* quits; buying and selling loop over their own item lists until the
|
||||
* player backs out, then the menu asks "anything else?" and repeats. */
|
||||
private *pokemart(
|
||||
martId: number,
|
||||
pages: string[],
|
||||
): Generator<ScriptEffect, void, Input> {
|
||||
const r = this.reader;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, this.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
const take = () => pages.splice(0, pages.length);
|
||||
|
||||
const entry = r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts));
|
||||
let p = entry;
|
||||
const count = r.byte(p++);
|
||||
const stock = Array.from({ length: count }, () => r.byte(p++));
|
||||
|
||||
let first = true;
|
||||
for (;;) {
|
||||
const choice = yield {
|
||||
kind: "martmenu",
|
||||
prompt: readText(
|
||||
r,
|
||||
first ? SYMBOLS.martWelcomeText : SYMBOLS.martAskMoreText,
|
||||
this.text(),
|
||||
),
|
||||
selected: 0,
|
||||
};
|
||||
first = false;
|
||||
const option = typeof choice === "number" ? choice : 2;
|
||||
if (option === 0) yield* this.martBuy(stock, pages);
|
||||
else if (option === 1) yield* this.martSell(pages);
|
||||
else {
|
||||
write(SYMBOLS.martComeAgainText);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the
|
||||
* price, then check funds and bag room before handing it over. */
|
||||
private *martBuy(
|
||||
stock: number[],
|
||||
pages: string[],
|
||||
): Generator<ScriptEffect, void, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, this.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
const take = () => pages.splice(0, pages.length);
|
||||
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
};
|
||||
for (;;) {
|
||||
const entries: MartEntry[] = stock.map((item) => ({
|
||||
item,
|
||||
name: data.itemName(item),
|
||||
price: data.itemPrice(item),
|
||||
}));
|
||||
const selection = yield { kind: "martlist", mode: "buy", entries, selected: 0 };
|
||||
const item = typeof selection === "number" ? stock[selection] : undefined;
|
||||
if (item === undefined) return;
|
||||
const price = data.itemPrice(item);
|
||||
const chosen = yield {
|
||||
kind: "martquantity",
|
||||
mode: "buy",
|
||||
item,
|
||||
name: data.itemName(item),
|
||||
price,
|
||||
max: 99,
|
||||
prompt: readText(r, SYMBOLS.martHowManyText, this.text()),
|
||||
selected: 1,
|
||||
};
|
||||
if (typeof chosen !== "number" || chosen < 1) continue;
|
||||
const total = price * chosen;
|
||||
this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||
this.numbers[RAM.itemQuantityChange] = chosen;
|
||||
this.numbers[RAM.moneyTemp] = total;
|
||||
write(SYMBOLS.martFinalPriceText);
|
||||
const confirmed = yield { kind: "yesno", pages: take() };
|
||||
if (!confirmed) continue;
|
||||
if (total > this.state.money) {
|
||||
write(SYMBOLS.martNoMoneyText);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
if (!receiveItem(data, this.state.bag, item, chosen)) {
|
||||
write(SYMBOLS.martPackFullText);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
this.state.money -= total;
|
||||
write(SYMBOLS.martThanksText);
|
||||
yield* flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** SellMenu/DepositSellPack: pick a bag item and a quantity up to its
|
||||
* stack, refuse unsellable items, then pay half price and toss it. */
|
||||
private *martSell(pages: string[]): Generator<ScriptEffect, void, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, this.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
const take = () => pages.splice(0, pages.length);
|
||||
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
};
|
||||
for (;;) {
|
||||
const slots = [...this.state.bag.items, ...this.state.bag.balls];
|
||||
if (!slots.length) return;
|
||||
const entries: MartEntry[] = slots.map((slot) => ({
|
||||
item: slot.item,
|
||||
name: data.itemName(slot.item),
|
||||
price: data.itemPrice(slot.item),
|
||||
owned: slot.quantity,
|
||||
}));
|
||||
const selection = yield { kind: "martlist", mode: "sell", entries, selected: 0 };
|
||||
const slot = typeof selection === "number" ? slots[selection] : undefined;
|
||||
if (!slot) return;
|
||||
if (data.itemUnsellable(slot.item)) {
|
||||
write(SYMBOLS.martCantBuyText);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
const price = data.itemPrice(slot.item);
|
||||
const chosen = yield {
|
||||
kind: "martquantity",
|
||||
mode: "sell",
|
||||
item: slot.item,
|
||||
name: data.itemName(slot.item),
|
||||
price,
|
||||
max: slot.quantity,
|
||||
prompt: readText(r, SYMBOLS.martSellHowManyText, this.text()),
|
||||
selected: 1,
|
||||
};
|
||||
if (typeof chosen !== "number" || chosen < 1) continue;
|
||||
const total = Math.floor((price * chosen) / 2);
|
||||
this.strings[RAM.stringBuffer2] = data.itemName(slot.item);
|
||||
this.numbers[RAM.moneyTemp] = total;
|
||||
write(SYMBOLS.martSellPriceText);
|
||||
const confirmed = yield { kind: "yesno", pages: take() };
|
||||
if (!confirmed) continue;
|
||||
tossItem(this.state.bag, slot.item, chosen);
|
||||
this.state.money = Math.min(C.MAX_MONEY, this.state.money + total);
|
||||
write(SYMBOLS.martBoughtText);
|
||||
yield* flush();
|
||||
}
|
||||
}
|
||||
|
||||
/** readvar: the script variables that native state can answer. */
|
||||
private readVar(id: number, offset: number): number {
|
||||
const clock = this.host.clock();
|
||||
@@ -753,6 +1030,10 @@ export class ScriptRun {
|
||||
return group;
|
||||
case 0x0d:
|
||||
return number;
|
||||
case 0x10:
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
|
||||
// deposited yet (no capture or PC), so the current box is empty.
|
||||
return MONS_PER_BOX;
|
||||
case 0x12:
|
||||
return this.host.position().x;
|
||||
case 0x13:
|
||||
@@ -763,18 +1044,29 @@ export class ScriptRun {
|
||||
}
|
||||
|
||||
/** Native translations of special routines, selected by routine address.
|
||||
* Returns undefined for routines that have not been translated. */
|
||||
* `value` is the script's current wScriptVar, in case a routine (like
|
||||
* CheckPokerus) returns a new one; routines that leave it alone return it
|
||||
* unchanged. Returns undefined for routines that have not been translated. */
|
||||
private *special(
|
||||
routine: number,
|
||||
value: number,
|
||||
pages: string[],
|
||||
take: () => string[],
|
||||
): Generator<ScriptEffect, true | undefined, Input> {
|
||||
): Generator<ScriptEffect, number | undefined, Input> {
|
||||
const r = this.reader;
|
||||
switch (routine) {
|
||||
case SPECIAL_ROUTINES.restartMapMusic:
|
||||
case SPECIAL_ROUTINES.fadeOutToWhite:
|
||||
case SPECIAL_ROUTINES.warpToSpawnPoint:
|
||||
return true; // Music, fades and Safari/contest flags not in use.
|
||||
case SPECIAL_ROUTINES.stubbedTrainerRankingsHealings:
|
||||
// Music and fades are not modeled; Safari/contest flags are unused.
|
||||
// StubbedTrainerRankings_Healings begins with `ret`: the Trainer
|
||||
// Rankings feature (Pokémon News) was cut, leaving a dead stub.
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.healMachineAnim:
|
||||
// Graphics, palettes and sound only: wPartyCount/wScriptVar select
|
||||
// an animation sequence but nothing persists past it.
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.healParty:
|
||||
// HealPartyMon: clear status, restore HP and every move's PP.
|
||||
for (const mon of this.state.party) {
|
||||
@@ -784,10 +1076,19 @@ export class ScriptRun {
|
||||
if (!move) return 0;
|
||||
const ups = mon.pp[slot] >> 6,
|
||||
base = this.host.data.move(move).pp;
|
||||
return (ups << 6) | (base + Math.floor(base / 5) * ups);
|
||||
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
|
||||
// maxes at 61 instead of overflowing the packed byte at 64.
|
||||
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
|
||||
}) as PartyMon["pp"];
|
||||
}
|
||||
return true;
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.checkPokerus:
|
||||
// _CheckPokerus: carry (here, a truthy return) if any party member's
|
||||
// status nybble is infected. No ported content ever sets pokerus,
|
||||
// so this is always 0 until Pokérus itself is modeled.
|
||||
return Number(
|
||||
this.state.party.some((mon) => (mon.pokerus & 0x0f) !== 0),
|
||||
);
|
||||
case SPECIAL_ROUTINES.setDayOfWeek: {
|
||||
const names = Array.from({ length: 7 }, (_, day) =>
|
||||
readString(
|
||||
@@ -821,7 +1122,7 @@ export class ScriptRun {
|
||||
if (!confirmed) continue;
|
||||
this.state.clock.weekdayOffset =
|
||||
(day - this.host.deviceWeekday() + 7) % 7;
|
||||
return true;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
case SPECIAL_ROUTINES.initialSetDst:
|
||||
@@ -839,7 +1140,7 @@ export class ScriptRun {
|
||||
// The box is cleared first; the following yesorno shows this page.
|
||||
take();
|
||||
pages.push(`${time}${text.startsWith(",") ? "" : " "}${text}`);
|
||||
return true;
|
||||
return value;
|
||||
}
|
||||
case SPECIAL_ROUTINES.toggleDecorationsVisibility:
|
||||
// No decorations are placed until the native decoration system exists,
|
||||
@@ -851,12 +1152,12 @@ export class ScriptRun {
|
||||
EVENTS.playersHouseBigDoll,
|
||||
])
|
||||
this.state.events.add(event);
|
||||
return true;
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.toggleMaptileDecorations:
|
||||
// Empty decoration slots leave the room blocks unchanged and clear
|
||||
// the poster's conditional event.
|
||||
this.state.events.delete(EVENTS.playersRoomPoster);
|
||||
return true;
|
||||
return value;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+166
-11
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user