Open Goldenrod City, Whitney's gym and the Radio Tower quiz (#32)
Enable Goldenrod City and its buildings, the department store with its elevator and rooftop, the Underground and its rooms, the Radio Tower floors and the Route 35 gate. Port what their scripts need: the herb shop, bargain shop, pharmacy and rooftop sale marts with ROM stock, prices and text; the Name Rater; the haircut brothers' happiness tables; the Lucky Number show over the party and boxes with its saved number and Friday countdown (RTC day timers with the 140-day wrap); coin and money balance boxes; Game Corner prize registration through NewPokedexEntry; gift Pokémon to the box when the party is full, with OT, nickname and MAIL (Kenya's 15-letter first line now stores). Saves gain hallOfFame, timers and luckyNumber, with defaults for older saves, and wUndergroundSwitchPositions joins the saved script bytes. The playthrough continues to the Bicycle, Whitney, the PLAINBADGE and the radio card. SlotMachine and CardFlip are still refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f4ef244fc7
commit
0930b780fc
19 files changed
+1596
-126
No files matched your search
+11
-3
@@ -24,6 +24,15 @@ const MAIL_CHARACTERS = /^[A-Za-z0-9 ,?!.\-/'&()“”[\]:;é♂♀¥…×]*$/u;
|
||||
|
||||
/** A message the mail entry could have written. */
|
||||
export function validMailMessage(message: unknown): message is string {
|
||||
return (
|
||||
storedMailMessage(message) &&
|
||||
(message.split("\n").length === 1 ||
|
||||
[...message.split("\n")[0]].length === MAIL_LINE_LENGTH)
|
||||
);
|
||||
}
|
||||
/** A message a MAIL can hold: the entry's, or a gift's (Kenya's MAIL
|
||||
* breaks its first line a character early, within the same 32 bytes). */
|
||||
function storedMailMessage(message: unknown): message is string {
|
||||
if (typeof message !== "string") return false;
|
||||
const lines = message.split("\n");
|
||||
return (
|
||||
@@ -31,8 +40,7 @@ export function validMailMessage(message: unknown): message is string {
|
||||
lines.every(
|
||||
(line) =>
|
||||
[...line].length <= MAIL_LINE_LENGTH && MAIL_CHARACTERS.test(line),
|
||||
) &&
|
||||
(lines.length === 1 || [...lines[0]].length === MAIL_LINE_LENGTH)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +59,7 @@ export function validMail(data: PokemonData, value: unknown): value is Mail {
|
||||
return (
|
||||
Boolean(mail) &&
|
||||
typeof mail === "object" &&
|
||||
validMailMessage(mail.message) &&
|
||||
storedMailMessage(mail.message) &&
|
||||
typeof mail.author === "string" &&
|
||||
/^[A-Za-z0-9 -]{1,7}$/.test(mail.author) &&
|
||||
integer(mail.authorId, 0, 0xffff) &&
|
||||
|
||||
@@ -1072,6 +1072,67 @@ export const JOHTO = {
|
||||
SelectApricornForKurt: 0x88018,
|
||||
"Kurt_PrintTextWhichApricorn.WhichApricornText": 0x88007,
|
||||
"Kurt_PrintTextHowMany.HowManyShouldIMakeText": 0x88013,
|
||||
HerbShopLadyIntroText: 0x15e4a,
|
||||
HerbalLadyHowManyText: 0x15e4f,
|
||||
HerbalLadyFinalPriceText: 0x15e54,
|
||||
HerbalLadyThanksText: 0x15e59,
|
||||
HerbalLadyPackFullText: 0x15e5e,
|
||||
HerbalLadyNoMoneyText: 0x15e63,
|
||||
HerbalLadyComeAgainText: 0x15e68,
|
||||
BargainShopIntroText: 0x15e6d,
|
||||
BargainShopFinalPriceText: 0x15e72,
|
||||
BargainShopThanksText: 0x15e77,
|
||||
BargainShopPackFullText: 0x15e7c,
|
||||
BargainShopSoldOutText: 0x15e81,
|
||||
BargainShopNoFundsText: 0x15e86,
|
||||
BargainShopComeAgainText: 0x15e8b,
|
||||
PharmacyIntroText: 0x15e90,
|
||||
PharmacyHowManyText: 0x15e95,
|
||||
PharmacyFinalPriceText: 0x15e9a,
|
||||
PharmacyThanksText: 0x15e9f,
|
||||
PharmacyPackFullText: 0x15ea4,
|
||||
PharmacyNoMoneyText: 0x15ea9,
|
||||
PharmacyComeAgainText: 0x15eae,
|
||||
RooftopSaleMart1: 0x15aee,
|
||||
RooftopSaleMart2: 0x15aff,
|
||||
BargainShopData: 0x15c51,
|
||||
DefaultMart: 0x16214,
|
||||
NameRater: 0xc2b9,
|
||||
GameCornerPrizeMonCheckDex: 0xc230,
|
||||
NameRaterHelloText: 0xfb80f,
|
||||
NameRaterWhichMonText: 0xfb814,
|
||||
NameRaterBetterNameText: 0xfb819,
|
||||
NameRaterWhatNameText: 0xfb81e,
|
||||
NameRaterFinishedText: 0xfb823,
|
||||
NameRaterComeAgainText: 0xfb828,
|
||||
NameRaterPerfectNameText: 0xfb82d,
|
||||
NameRaterEggText: 0xfb832,
|
||||
NameRaterSameNameText: 0xfb837,
|
||||
NameRaterNamedText: 0xfb83c,
|
||||
OlderHaircutBrother: 0x7413,
|
||||
YoungerHaircutBrother: 0x7418,
|
||||
DaisysGrooming: 0x741d,
|
||||
BillsGrandfather: 0x73f7,
|
||||
HappinessData_OlderHaircutBrother: 0x7459,
|
||||
HappinessData_YoungerHaircutBrother: 0x7462,
|
||||
HappinessData_DaisysGrooming: 0x746b,
|
||||
GetFirstPokemonHappiness: 0x718d,
|
||||
CheckFirstMonIsEgg: 0x71ac,
|
||||
PlaceMoneyTopRight: 0x24ae8,
|
||||
DisplayCoinCaseBalance: 0x24b25,
|
||||
DisplayMoneyAndCoinBalance: 0x24b4e,
|
||||
MoneyString: 0x24b83,
|
||||
CoinString: 0x24b89,
|
||||
WasSentToBillsPCText: 0xe3d9,
|
||||
CheckForLuckyNumberWinners: 0x4d87a,
|
||||
CheckLuckyNumberShowFlag: 0xc434,
|
||||
ResetLuckyNumberShowFlag: 0xc422,
|
||||
PrintTodaysLuckyNumber: 0x4d9d3,
|
||||
"CheckForLuckyNumberWinners.LuckyNumberMatchPartyText": 0x4d9c9,
|
||||
"CheckForLuckyNumberWinners.LuckyNumberMatchPCText": 0x4d9ce,
|
||||
BattleTowerAction: 0x170687,
|
||||
GameboyCheck: 0xc478,
|
||||
UnlockMysteryGift: 0x1050b9,
|
||||
} as const;
|
||||
|
||||
/** Saved WRAM bytes that map scripts read and write directly (readmem,
|
||||
@@ -1080,6 +1141,7 @@ export const JOHTO = {
|
||||
export const SAVED_WRAM = {
|
||||
wFarfetchdPosition: 0xd964,
|
||||
wKurtApricornQuantity: 0xdca4,
|
||||
wUndergroundSwitchPositions: 0xd963,
|
||||
} as const;
|
||||
export type SavedByte = keyof typeof SAVED_WRAM;
|
||||
/** Work RAM the native port reads by address but does not save, keyed by
|
||||
@@ -1168,5 +1230,34 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"11:23": "Route 34 gate",
|
||||
"11:1": "Route 34",
|
||||
"11:24": "Day-Care",
|
||||
"11:2": "Goldenrod City",
|
||||
"11:3": "Goldenrod Gym",
|
||||
"11:4": "Bike shop",
|
||||
"11:5": "Happiness rater’s house",
|
||||
"11:6": "Bill’s house",
|
||||
"11:7": "Magnet Train station",
|
||||
"11:8": "Flower shop",
|
||||
"11:9": "PP speech house",
|
||||
"11:10": "Name rater’s house",
|
||||
"11:11": "Dept. Store 1F",
|
||||
"11:12": "Dept. Store 2F",
|
||||
"11:13": "Dept. Store 3F",
|
||||
"11:14": "Dept. Store 4F",
|
||||
"11:15": "Dept. Store 5F",
|
||||
"11:16": "Dept. Store 6F",
|
||||
"11:17": "Dept. Store elevator",
|
||||
"11:18": "Dept. Store rooftop",
|
||||
"11:19": "Game Corner",
|
||||
"11:20": "Pokémon Center",
|
||||
"3:53": "Goldenrod Underground",
|
||||
"3:54": "Underground switch room",
|
||||
"3:55": "Dept. Store B1F",
|
||||
"3:56": "Underground warehouse",
|
||||
"3:17": "Radio Tower 1F",
|
||||
"3:18": "Radio Tower 2F",
|
||||
"3:19": "Radio Tower 3F",
|
||||
"3:20": "Radio Tower 4F",
|
||||
"3:21": "Radio Tower 5F",
|
||||
"10:14": "Route 35 gate",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { BATTLE, JOHTO, RAM, SYMBOLS } from "../rom/profile";
|
||||
import { changedHappiness } from "../battle/battle";
|
||||
import { readString } from "./text";
|
||||
import { NICKNAME_PATTERN } from "../party/pokemon";
|
||||
import type { Gen, SpecialContext } from "./special-context";
|
||||
import {
|
||||
checkCountdown,
|
||||
daysUntilNextFriday,
|
||||
initCountdown,
|
||||
rtcDay,
|
||||
} from "../world/day-timers";
|
||||
import { OW } from "../world/constants";
|
||||
|
||||
/** HAPPINESS_THRESHOLD_1 and _2 (constants/pokemon_data_constants.asm). */
|
||||
const HAPPINESS_THRESHOLD_1 = 100,
|
||||
HAPPINESS_THRESHOLD_2 = 200;
|
||||
|
||||
/** ChangeHappiness outside battle for party slot `index`: HappinessChanges
|
||||
* by tier; Eggs are skipped. */
|
||||
export function changeHappiness(
|
||||
context: SpecialContext,
|
||||
index: number,
|
||||
action: number,
|
||||
) {
|
||||
const mon = context.state.party[index];
|
||||
if (!mon || mon.egg) return;
|
||||
const tier =
|
||||
mon.happiness < HAPPINESS_THRESHOLD_1
|
||||
? 0
|
||||
: mon.happiness < HAPPINESS_THRESHOLD_2
|
||||
? 1
|
||||
: 2;
|
||||
mon.happiness = changedHappiness(
|
||||
mon.happiness,
|
||||
context.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
|
||||
);
|
||||
}
|
||||
|
||||
/** GetFirstPokemonHappiness: the first Pokémon that is not an Egg; its
|
||||
* happiness, and its species name in wStringBuffer1 and 3. */
|
||||
export function firstPokemonHappiness(context: SpecialContext): number {
|
||||
const mon = context.state.party.find((member) => !member.egg);
|
||||
if (!mon) return 0;
|
||||
const name = context.data.speciesName(mon.species);
|
||||
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||
name;
|
||||
return mon.happiness;
|
||||
}
|
||||
|
||||
/** CheckFirstMonIsEgg: TRUE when the first party slot holds an Egg, with
|
||||
* its species name (EGG) in wStringBuffer1 and 3. */
|
||||
export function checkFirstMonIsEgg(context: SpecialContext): number {
|
||||
const first = context.state.party[0];
|
||||
if (!first) return 0;
|
||||
const name = first.egg
|
||||
? context.data.speciesName(EGG)
|
||||
: context.data.speciesName(first.species);
|
||||
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||
name;
|
||||
return Number(Boolean(first.egg));
|
||||
}
|
||||
/** EGG (constants/pokemon_constants.asm). */
|
||||
const EGG = 0xfd;
|
||||
|
||||
/** HaircutOrGrooming (engine/events/haircut.asm): a Pokémon from the
|
||||
* party; an Egg answers 1. Otherwise one Random byte picks a row of the
|
||||
* happiness table (cumulative chances, -1 for the rest), whose second
|
||||
* byte is the answer and third the HAPPINESS_* change. B answers 0. */
|
||||
export function* haircut(context: SpecialContext, table: number): Gen<number> {
|
||||
const slot = yield* context.selectMon();
|
||||
if (slot < 0) return 0;
|
||||
const mon = context.state.party[slot];
|
||||
if (mon.egg) return 1;
|
||||
context.strings[RAM.stringBuffer1] = context.strings[RAM.stringBuffer3] =
|
||||
mon.nickname;
|
||||
const r = context.reader;
|
||||
let roll = context.random();
|
||||
let row = table;
|
||||
// .loop: subtract each chance until one goes below zero.
|
||||
for (;;) {
|
||||
roll -= r.byte(row);
|
||||
if (roll < 0) break;
|
||||
row += 3;
|
||||
}
|
||||
changeHappiness(context, slot, r.byte(row + 2));
|
||||
return r.byte(row + 1);
|
||||
}
|
||||
|
||||
/** _NameRater (engine/events/name_rater.asm): yes, a Pokémon from the
|
||||
* party, then a new nickname for one the player caught (its OT and ID are
|
||||
* the player's); an Egg or a traded Pokémon is refused. An empty name or
|
||||
* the same name keeps the old one. */
|
||||
export function* nameRater(context: SpecialContext): Gen {
|
||||
const { state } = context;
|
||||
const finish = function* (text: number): Gen {
|
||||
context.write(text);
|
||||
yield* context.flush();
|
||||
};
|
||||
context.write(JOHTO.NameRaterHelloText);
|
||||
if (!(yield { kind: "yesno", pages: context.take() }))
|
||||
return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||
context.write(JOHTO.NameRaterWhichMonText);
|
||||
const slot = yield* context.selectMon();
|
||||
if (slot < 0) return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||
const mon = state.party[slot];
|
||||
if (mon.egg) return yield* finish(JOHTO.NameRaterEggText);
|
||||
context.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
// CheckIfMonIsYourOT: the OT name, then the ID.
|
||||
const player = context.player();
|
||||
if (mon.ot !== player.name || mon.otId !== player.id)
|
||||
return yield* finish(JOHTO.NameRaterPerfectNameText);
|
||||
context.write(JOHTO.NameRaterBetterNameText);
|
||||
if (!(yield { kind: "yesno", pages: context.take() }))
|
||||
return yield* finish(JOHTO.NameRaterComeAgainText);
|
||||
context.write(JOHTO.NameRaterWhatNameText);
|
||||
yield* context.flush();
|
||||
// _NamingScreen with NAME_MON: the species' name, "'S" and "NICKNAME?".
|
||||
const r = context.reader;
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (r.byte(second++) !== 0x50);
|
||||
const name = yield {
|
||||
kind: "name",
|
||||
pages: [
|
||||
`${context.data.speciesName(mon.species)}${readString(r, SYMBOLS.nicknameStrings, context.text())}\n${readString(r, second, context.text())}`,
|
||||
],
|
||||
current: mon.nickname,
|
||||
max: 10,
|
||||
};
|
||||
if (typeof name !== "string" || (name && !NICKNAME_PATTERN.test(name)))
|
||||
throw new Error("Invalid nickname.");
|
||||
// IsNewNameEmpty (only spaces) and CompareNewToOld keep the old name.
|
||||
let result: number = JOHTO.NameRaterSameNameText;
|
||||
if (!/^ *$/.test(name) && name !== mon.nickname) {
|
||||
mon.nickname = name;
|
||||
result = JOHTO.NameRaterFinishedText;
|
||||
}
|
||||
context.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
context.write(JOHTO.NameRaterNamedText);
|
||||
context.write(result);
|
||||
yield* context.flush();
|
||||
}
|
||||
|
||||
/** CheckLuckyNumberShowFlag: whether wLuckyNumberDayTimer has run out. */
|
||||
export function checkLuckyNumberShowFlag(context: SpecialContext): number {
|
||||
const { done, timer } = checkCountdown(
|
||||
context.state.timers.luckyNumber,
|
||||
context.today(),
|
||||
);
|
||||
context.state.timers.luckyNumber = timer;
|
||||
return Number(done);
|
||||
}
|
||||
|
||||
/** ResetLuckyNumberShowFlag: the countdown restarts until next Friday,
|
||||
* the show opens again, and LoadOrRegenerateLuckyIDNumber draws a new
|
||||
* number unless one was drawn today (two Random bytes, low byte first). */
|
||||
export function resetLuckyNumberShowFlag(context: SpecialContext) {
|
||||
const { state } = context,
|
||||
today = context.today();
|
||||
state.timers.luckyNumber = initCountdown(
|
||||
daysUntilNextFriday(context.weekday()),
|
||||
today,
|
||||
);
|
||||
state.flags.delete(OW.ENGINE_LUCKY_NUMBER_SHOW);
|
||||
const day = rtcDay(today) + 1;
|
||||
if (state.luckyNumber.day === day) return;
|
||||
const low = context.random(),
|
||||
high = context.random();
|
||||
state.luckyNumber = { id: (high << 8) | low, day };
|
||||
}
|
||||
|
||||
/** PrintTodaysLuckyNumber: five digits with leading zeros in
|
||||
* wStringBuffer3. */
|
||||
export function printTodaysLuckyNumber(context: SpecialContext) {
|
||||
context.strings[RAM.stringBuffer3] = String(
|
||||
context.state.luckyNumber.id,
|
||||
).padStart(5, "0");
|
||||
}
|
||||
|
||||
/** CheckForLuckyNumberWinners (engine/events/lucky_number.asm): every
|
||||
* Pokémon that is not an Egg, in the party, then the current box, then
|
||||
* the other boxes, compares its OT ID's five digits from the right with
|
||||
* the lucky number. 5 digits win 1st place, 3 or 4 2nd, 2 3rd; a later
|
||||
* match only replaces the named species if its prize tier is better.
|
||||
* A winning replacement in a box makes the text say the PC. Returns the
|
||||
* place, or 0. */
|
||||
export function* checkForLuckyNumberWinners(
|
||||
context: SpecialContext,
|
||||
): Gen<number> {
|
||||
const { state } = context;
|
||||
if (!state.party.length) return 0;
|
||||
const lucky = String(state.luckyNumber.id).padStart(5, "0");
|
||||
let place = 0,
|
||||
species = 0,
|
||||
inBox = false;
|
||||
const compare = (mon: { species: number; otId: number; egg?: boolean }) => {
|
||||
if (mon.egg) return false;
|
||||
const id = String(mon.otId).padStart(5, "0");
|
||||
let digits = 0;
|
||||
while (digits < 5 && id[4 - digits] === lucky[4 - digits]) digits++;
|
||||
const tier = digits === 5 ? 1 : digits >= 3 ? 2 : digits === 2 ? 3 : 0;
|
||||
if (!tier || (place && place <= tier)) return false;
|
||||
place = tier;
|
||||
species = mon.species;
|
||||
return true;
|
||||
};
|
||||
for (const mon of state.party) compare(mon);
|
||||
const boxes = state.storage.boxes,
|
||||
current = state.storage.current;
|
||||
for (const mon of boxes[current]) if (compare(mon)) inBox = true;
|
||||
boxes.forEach((box, index) => {
|
||||
if (index === current) return;
|
||||
for (const mon of box) if (compare(mon)) inBox = true;
|
||||
});
|
||||
if (!place) return 0;
|
||||
context.strings[RAM.stringBuffer1] = context.data.speciesName(species);
|
||||
context.write(
|
||||
inBox
|
||||
? JOHTO["CheckForLuckyNumberWinners.LuckyNumberMatchPCText"]
|
||||
: JOHTO["CheckForLuckyNumberWinners.LuckyNumberMatchPartyText"],
|
||||
);
|
||||
yield* context.flush();
|
||||
return place;
|
||||
}
|
||||
+500
-96
@@ -23,6 +23,7 @@ import {
|
||||
type SavedByte,
|
||||
} from "../rom/profile";
|
||||
import { PokemonData } from "../rom/pokemon";
|
||||
import { PokedexData } from "../rom/pokedex";
|
||||
import { C } from "../battle/constants";
|
||||
import type { BugContest } from "../battle/battle";
|
||||
import {
|
||||
@@ -40,6 +41,8 @@ import {
|
||||
currentBox,
|
||||
emptyStorage,
|
||||
receivePCItem,
|
||||
restorePP,
|
||||
toBoxMon,
|
||||
type PokemonStorage,
|
||||
} from "../party/storage";
|
||||
import {
|
||||
@@ -64,10 +67,24 @@ import type { FlyMapView } from "./fly-map";
|
||||
import type { TileScreen } from "./screen";
|
||||
import { unownPuzzle } from "./unown-puzzle";
|
||||
import { displayUnownWords, hoOhChamber, omanyteChamber } from "./ruins";
|
||||
import {
|
||||
checkFirstMonIsEgg,
|
||||
checkForLuckyNumberWinners,
|
||||
checkLuckyNumberShowFlag,
|
||||
firstPokemonHappiness,
|
||||
haircut,
|
||||
nameRater,
|
||||
printTodaysLuckyNumber,
|
||||
resetLuckyNumberShowFlag,
|
||||
} from "./goldenrod";
|
||||
import type { SpecialContext } from "./special-context";
|
||||
import type { Palette } from "../world/types";
|
||||
import type { DayTimer } from "../world/day-timers";
|
||||
/** The RTC day countdowns the native state keeps. */
|
||||
export type DayTimerName = "luckyNumber";
|
||||
import { rockMonEncounter, treeMonEncounter } from "./treemons";
|
||||
import type { TrainerCardView } from "./trainer-card";
|
||||
import type { PokedexView } from "./pokedex";
|
||||
import { Pokedex, type PokedexView } from "./pokedex";
|
||||
import {
|
||||
defaultOptions,
|
||||
type GameOptions,
|
||||
@@ -139,6 +156,8 @@ export interface ScriptState {
|
||||
* (a dark cave lit) and STATUSFLAGS_NO_WILD_ENCOUNTERS_F (wildoff). */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
/** STATUSFLAGS_HALL_OF_FAME_F: the League has been beaten. */
|
||||
hallOfFame: boolean;
|
||||
/** wWhichRegisteredItem (pocket in the top two bits, 1-based slot below)
|
||||
* and wRegisteredItem: the item Select uses; zeros when none. */
|
||||
registeredItem: { which: number; item: number };
|
||||
@@ -159,6 +178,10 @@ export interface ScriptState {
|
||||
* wFirstUnownSeen, the letter of the first Unown met in battle. */
|
||||
unownDex: number[];
|
||||
firstUnownSeen: number;
|
||||
/** The RTC day countdowns by name (wLuckyNumberDayTimer is `luckyNumber`). */
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
/** sLuckyIDNumber and sLuckyNumberDay (wCurDay + 1 when drawn). */
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||
options: GameOptions;
|
||||
/** wParkBallsRemaining and wContestMon while the Bug-Catching Contest
|
||||
@@ -193,6 +216,7 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
digWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
hallOfFame: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
mailbox: [],
|
||||
repel: 0,
|
||||
@@ -202,6 +226,8 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
memory: {},
|
||||
unownDex: [],
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
options: defaultOptions(),
|
||||
});
|
||||
export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
@@ -234,6 +260,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
digWarp: state.digWarp && { ...state.digWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
hallOfFame: state.hallOfFame,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
@@ -243,6 +270,10 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
memory: { ...state.memory },
|
||||
unownDex: [...state.unownDex],
|
||||
firstUnownSeen: state.firstUnownSeen,
|
||||
timers: Object.fromEntries(
|
||||
Object.entries(state.timers).map(([name, timer]) => [name, { ...timer }]),
|
||||
),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
bugContest: {
|
||||
@@ -320,6 +351,13 @@ const NUM_NPC_TRADES = 7,
|
||||
LANDMARK_GIFT = 0x7e,
|
||||
PARTYMENUACTION_CHOOSE_POKEMON = 0,
|
||||
PARTYMENUACTION_GIVE_MON = 6;
|
||||
/** BATTLETOWERACTION_GSBALL and GBCHECK_CGB (constants). */
|
||||
const BATTLETOWERACTION_GSBALL = 0x0b,
|
||||
GBCHECK_CGB = 2;
|
||||
/** RANDY_OT_ID (engine/pokemon/move_mon.asm): the ID of every gift
|
||||
* Pokémon given to the party. MAIL_MSG_LENGTH: two MAIL lines. */
|
||||
const RANDY_OT_ID = 1001,
|
||||
MAIL_MSG_LENGTH = 32;
|
||||
/** checkpokemail's results. */
|
||||
const POKEMAIL_WRONG_MAIL = 0,
|
||||
POKEMAIL_CORRECT = 1,
|
||||
@@ -327,6 +365,56 @@ const POKEMAIL_WRONG_MAIL = 0,
|
||||
POKEMAIL_NO_MAIL = 3,
|
||||
POKEMAIL_LAST_MON = 4;
|
||||
|
||||
/** MARTTYPE_* (constants/mart_constants.asm). */
|
||||
const MARTTYPE_STANDARD = 0,
|
||||
MARTTYPE_BITTER = 1,
|
||||
MARTTYPE_BARGAIN = 2,
|
||||
MARTTYPE_PHARMACY = 3,
|
||||
MARTTYPE_ROOFTOP = 4;
|
||||
/** NUM_MARTS (constants/mart_constants.asm). */
|
||||
const NUM_MARTS = 34;
|
||||
/** GetMartDialogGroup's texts per mart type: the quantity question (none
|
||||
* for the bargain shop, which sells one of each), the price, no money,
|
||||
* pack full and thanks, and the bargain shop's sold out. */
|
||||
interface MartTexts {
|
||||
howMany?: number;
|
||||
finalPrice: number;
|
||||
noMoney: number;
|
||||
packFull: number;
|
||||
thanks: number;
|
||||
soldOut?: number;
|
||||
}
|
||||
const MART_TEXTS = {
|
||||
standard: {
|
||||
howMany: SYMBOLS.martHowManyText,
|
||||
finalPrice: SYMBOLS.martFinalPriceText,
|
||||
noMoney: SYMBOLS.martNoMoneyText,
|
||||
packFull: SYMBOLS.martPackFullText,
|
||||
thanks: SYMBOLS.martThanksText,
|
||||
},
|
||||
herb: {
|
||||
howMany: JOHTO.HerbalLadyHowManyText,
|
||||
finalPrice: JOHTO.HerbalLadyFinalPriceText,
|
||||
noMoney: JOHTO.HerbalLadyNoMoneyText,
|
||||
packFull: JOHTO.HerbalLadyPackFullText,
|
||||
thanks: JOHTO.HerbalLadyThanksText,
|
||||
},
|
||||
bargain: {
|
||||
finalPrice: JOHTO.BargainShopFinalPriceText,
|
||||
noMoney: JOHTO.BargainShopNoFundsText,
|
||||
packFull: JOHTO.BargainShopPackFullText,
|
||||
thanks: JOHTO.BargainShopThanksText,
|
||||
soldOut: JOHTO.BargainShopSoldOutText,
|
||||
},
|
||||
pharmacy: {
|
||||
howMany: JOHTO.PharmacyHowManyText,
|
||||
finalPrice: JOHTO.PharmacyFinalPriceText,
|
||||
noMoney: JOHTO.PharmacyNoMoneyText,
|
||||
packFull: JOHTO.PharmacyPackFullText,
|
||||
thanks: JOHTO.PharmacyThanksText,
|
||||
},
|
||||
} satisfies Record<string, MartTexts>;
|
||||
|
||||
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
|
||||
/** The mart's lists and quantity box, which the PC's item storage reuses. */
|
||||
export type MartMode =
|
||||
@@ -348,7 +436,13 @@ export type ScriptEffect =
|
||||
/** Pages the player acknowledges. With holdTicks the last page instead
|
||||
* stays for a fixed time, as `writetext` followed by `pause` does. */
|
||||
| { kind: "text"; pages: string[]; holdTicks?: number }
|
||||
| { kind: "yesno"; pages: string[] }
|
||||
| {
|
||||
kind: "yesno";
|
||||
pages: string[];
|
||||
/** A money or coin box shown with the choice (PlaceMoneyTopRight,
|
||||
* DisplayCoinCaseBalance, DisplayMoneyAndCoinBalance). */
|
||||
balance?: string;
|
||||
}
|
||||
| { kind: "weekday"; pages: string[]; names: string[] }
|
||||
| { kind: "turn"; object: number; direction: Direction }
|
||||
| { kind: "face"; object: number; target: number }
|
||||
@@ -458,6 +552,8 @@ export type ScriptEffect =
|
||||
selected: number;
|
||||
/** Left and right answer "left" or "right" (MoveScreenLoop). */
|
||||
sides?: boolean;
|
||||
/** A money or coin box shown with the menu. */
|
||||
balance?: string;
|
||||
}
|
||||
/** A native message for presentation the port does not have yet; the
|
||||
* script continues. */
|
||||
@@ -543,6 +639,8 @@ export interface ScriptHost {
|
||||
/** GetFacingObject: the visible object on the facing tile, with its
|
||||
* MAPOBJECT_MOVEMENT. */
|
||||
facingObject(): { id: number; movement: number } | undefined;
|
||||
/** The game clock's day number (wCurDay's source, see day-timers). */
|
||||
day?(): number;
|
||||
/** The map's tileset graphics and background palettes as shown now,
|
||||
* for specials that draw over the map. */
|
||||
tileset?(): { graphics: Uint8Array; palettes: Palette[] };
|
||||
@@ -663,6 +761,9 @@ export class ScriptRun {
|
||||
private numbers: Record<number, number> = {};
|
||||
/** wCurItem: the item named by itemnotify and pocketisfull. */
|
||||
private currentItem = 0;
|
||||
/** A money or coin box the next choice shows (PlaceMoneyTopRight and
|
||||
* the coin displays); it stays until the window closes. */
|
||||
private balance?: string;
|
||||
/** wCurFruitTree and wCurFruit, set by fruittree and GetCurTreeFruit. */
|
||||
private fruitTree = 0;
|
||||
private fruit = 0;
|
||||
@@ -1581,63 +1682,32 @@ export class ScriptRun {
|
||||
break;
|
||||
}
|
||||
case 0x2d: {
|
||||
// givepoke: a gift names its nickname and OT (two pointers in
|
||||
// the script's bank) after the flag.
|
||||
const species = byte(),
|
||||
level = byte(),
|
||||
item = byte(),
|
||||
gift = byte();
|
||||
if (gift)
|
||||
throw new UnsupportedScript(offset, opcode, " (gift with OT)");
|
||||
// A MAIL item needs its message (GivePokeMail).
|
||||
if (item && data.itemIsMail(item))
|
||||
throw new UnsupportedScript(offset, opcode, " (MAIL)");
|
||||
if (this.state.party.length >= PARTY_LENGTH)
|
||||
throw new UnsupportedScript(
|
||||
offset,
|
||||
opcode,
|
||||
" (full party; PC boxes)",
|
||||
);
|
||||
const player = this.host.player();
|
||||
// GeneratePartyMonStats draws the Attack/Defense byte first.
|
||||
const dvs = (this.host.random() << 8) | this.host.random();
|
||||
const mon = generatePartyMon(data, {
|
||||
species,
|
||||
level,
|
||||
item,
|
||||
otId: player.id,
|
||||
ot: this.host.text().player,
|
||||
dvs,
|
||||
caughtData: caughtData(
|
||||
this.host.timeOfDay(),
|
||||
level,
|
||||
player.gender,
|
||||
this.host.landmark(),
|
||||
),
|
||||
});
|
||||
this.state.party.push(mon);
|
||||
updateUnownDex(this.state, mon);
|
||||
this.state.pokedex.seen.add(species);
|
||||
this.state.pokedex.caught.add(species);
|
||||
// GiveANickname_YesNo reads the species name from wStringBuffer1.
|
||||
this.strings[RAM.stringBuffer1] = mon.nickname;
|
||||
write(SYMBOLS.caughtAskNicknameText);
|
||||
if (yield { kind: "yesno", pages: take() }) {
|
||||
// The naming screen's title: "<NAME>'S" / "NICKNAME?".
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (r.byte(second++) !== 0x50);
|
||||
const suffix = readString(r, SYMBOLS.nicknameStrings, this.text());
|
||||
const title = readString(r, second, this.text());
|
||||
const name = yield {
|
||||
kind: "name",
|
||||
pages: [`${mon.nickname}${suffix}\n${title}`],
|
||||
current: mon.nickname,
|
||||
max: 10,
|
||||
};
|
||||
if (typeof name !== "string") throw new Error("Invalid nickname.");
|
||||
if (name && !NICKNAME_PATTERN.test(name))
|
||||
throw new Error("Invalid nickname.");
|
||||
if (name) mon.nickname = name;
|
||||
}
|
||||
value = 0;
|
||||
gift = byte()
|
||||
? { nickname: near(), ot: near() }
|
||||
: undefined;
|
||||
value = yield* this.givePoke(species, level, item, gift, write, take);
|
||||
break;
|
||||
}
|
||||
case 0x2f: {
|
||||
// givepokemail: GivePokeMail on the last party Pokémon, with the
|
||||
// MAIL item and message from the script's data and the holder's
|
||||
// OT and ID as the author.
|
||||
const data = near();
|
||||
const mon = this.state.party.at(-1);
|
||||
if (!mon) throw new UnsupportedScript(offset, opcode, " (no party)");
|
||||
mon.item = r.byte(data);
|
||||
mon.mail = {
|
||||
message: readString(r, data + 1, this.text(), MAIL_MSG_LENGTH),
|
||||
author: mon.ot,
|
||||
authorId: mon.otId,
|
||||
species: mon.species,
|
||||
type: mon.item,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 0x40:
|
||||
@@ -1705,7 +1775,10 @@ export class ScriptRun {
|
||||
menuHeader = near();
|
||||
break;
|
||||
case 0x50:
|
||||
break; // closewindow: the native menu has already closed.
|
||||
// closewindow: the native menu has already closed, and with it
|
||||
// any money box.
|
||||
this.balance = undefined;
|
||||
break;
|
||||
case 0x58: {
|
||||
// _2dmenu over the loaded header's MenuData: its strings, the
|
||||
// default option, and wMenuCursorPosition (0 for B).
|
||||
@@ -1735,6 +1808,48 @@ export class ScriptRun {
|
||||
value = answer < 0 ? 0 : answer + 1;
|
||||
break;
|
||||
}
|
||||
case 0x59: {
|
||||
// verticalmenu: VerticalMenu over the loaded header's static
|
||||
// MenuData (flags, count, then the strings); wMenuCursorY, or 0
|
||||
// for B.
|
||||
if (menuHeader === undefined)
|
||||
throw new UnsupportedScript(offset, opcode, " (no menu header)");
|
||||
const bank = r.bank(menuHeader),
|
||||
menuData = r.address(bank, r.word(menuHeader + 5)),
|
||||
initial = r.byte(menuHeader + 7);
|
||||
const count = r.byte(menuData + 1);
|
||||
let entry = menuData + 2;
|
||||
const items: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
items.push(readString(r, entry, this.text()));
|
||||
while (r.byte(entry++) !== 0x50);
|
||||
}
|
||||
const answer = yield {
|
||||
kind: "menu",
|
||||
pages: take(),
|
||||
items,
|
||||
selected: Math.max(0, initial - 1),
|
||||
...(this.balance && { balance: this.balance }),
|
||||
};
|
||||
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= items.length)
|
||||
throw new Error("Invalid menu choice.");
|
||||
value = answer < 0 ? 0 : answer + 1;
|
||||
break;
|
||||
}
|
||||
case 0x25:
|
||||
case 0x26:
|
||||
case 0x27: {
|
||||
// givecoins (capped at MAX_COINS), takecoins (a shortfall leaves
|
||||
// 0 coins) and checkcoins.
|
||||
const amount = word(),
|
||||
coins = this.state.coins;
|
||||
if (opcode === 0x25) this.state.coins = Math.min(C.MAX_COINS, coins + amount);
|
||||
else if (opcode === 0x26) this.state.coins = Math.max(0, coins - amount);
|
||||
else
|
||||
value =
|
||||
coins > amount ? OW.HAVE_MORE : coins === amount ? OW.HAVE_AMOUNT : OW.HAVE_LESS;
|
||||
break;
|
||||
}
|
||||
case 0x96: {
|
||||
// trade: NPCTrade.
|
||||
const index = byte();
|
||||
@@ -1864,6 +1979,8 @@ export class ScriptRun {
|
||||
case 0x47:
|
||||
break; // opentext: the native dialogue opens with its first page.
|
||||
case 0x49:
|
||||
// closetext: the money box closes with the text box.
|
||||
this.balance = undefined;
|
||||
yield* flush();
|
||||
break;
|
||||
case 0x54:
|
||||
@@ -1879,7 +1996,11 @@ export class ScriptRun {
|
||||
write(near());
|
||||
break;
|
||||
case 0x4e: {
|
||||
const answer = yield { kind: "yesno", pages: take() };
|
||||
const answer = yield {
|
||||
kind: "yesno",
|
||||
pages: take(),
|
||||
...(this.balance && { balance: this.balance }),
|
||||
};
|
||||
value = answer ? 1 : 0;
|
||||
break;
|
||||
}
|
||||
@@ -2081,10 +2202,10 @@ export class ScriptRun {
|
||||
case 0x94: {
|
||||
const type = byte(),
|
||||
martId = word();
|
||||
if (type !== 0 /* MARTTYPE_STANDARD */)
|
||||
if (type > MARTTYPE_ROOFTOP)
|
||||
throw new UnsupportedScript(offset, opcode, " (mart type)");
|
||||
yield* flush();
|
||||
yield* this.pokemart(martId, pages);
|
||||
yield* this.pokemart(type, martId, pages);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -2094,26 +2215,85 @@ 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. */
|
||||
/** OpenMartDialog (engine/items/mart.asm) by MARTTYPE_*: the standard
|
||||
* mart's BUY/SELL/QUIT loop, or straight to BuyMenu for the herb shop,
|
||||
* the bargain shop, the pharmacy and the rooftop sale, each with its own
|
||||
* texts. The standard, herb and pharmacy marts read Marts[martId] with
|
||||
* the items' prices; the bargain shop and rooftop sale have their own
|
||||
* lists and prices. */
|
||||
private *pokemart(
|
||||
type: number,
|
||||
martId: number,
|
||||
pages: string[],
|
||||
): Generator<ScriptEffect, void, Input> {
|
||||
const r = this.reader;
|
||||
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 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++));
|
||||
|
||||
// MartTextbox: the text, then a button.
|
||||
const textbox = function* (address: number): Generator<ScriptEffect, void, Input> {
|
||||
write(address);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
};
|
||||
// GetMart: a mart beyond NUM_MARTS reads DefaultMart.
|
||||
const martStock = () => {
|
||||
const entry =
|
||||
martId < NUM_MARTS
|
||||
? r.near(SYMBOLS.marts + martId * 2, r.bank(SYMBOLS.marts))
|
||||
: JOHTO.DefaultMart;
|
||||
const count = r.byte(entry);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const item = r.byte(entry + 1 + i);
|
||||
return { item, price: data.itemPrice(item) };
|
||||
});
|
||||
};
|
||||
// ReadMart: a count, then each item with its own price.
|
||||
const pricedStock = (list: number) =>
|
||||
Array.from({ length: r.byte(list) }, (_, i) => ({
|
||||
item: r.byte(list + 1 + i * 3),
|
||||
price: r.word(list + 2 + i * 3),
|
||||
}));
|
||||
switch (type) {
|
||||
case MARTTYPE_STANDARD:
|
||||
break;
|
||||
case MARTTYPE_BITTER:
|
||||
yield* textbox(JOHTO.HerbShopLadyIntroText);
|
||||
yield* this.martBuy(martStock(), pages, MART_TEXTS.herb);
|
||||
yield* textbox(JOHTO.HerbalLadyComeAgainText);
|
||||
return;
|
||||
case MARTTYPE_PHARMACY:
|
||||
yield* textbox(JOHTO.PharmacyIntroText);
|
||||
yield* this.martBuy(martStock(), pages, MART_TEXTS.pharmacy);
|
||||
yield* textbox(JOHTO.PharmacyComeAgainText);
|
||||
return;
|
||||
case MARTTYPE_BARGAIN: {
|
||||
yield* textbox(JOHTO.BargainShopIntroText);
|
||||
const bought = yield* this.martBuy(
|
||||
pricedStock(JOHTO.BargainShopData),
|
||||
pages,
|
||||
MART_TEXTS.bargain,
|
||||
);
|
||||
// wBargainShopFlags: any purchase closes the shop for the day.
|
||||
if (bought) this.state.flags.add(OW.ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED);
|
||||
yield* textbox(JOHTO.BargainShopComeAgainText);
|
||||
return;
|
||||
}
|
||||
case MARTTYPE_ROOFTOP:
|
||||
yield* textbox(SYMBOLS.martWelcomeText);
|
||||
yield* this.martBuy(
|
||||
pricedStock(
|
||||
this.state.hallOfFame ? JOHTO.RooftopSaleMart2 : JOHTO.RooftopSaleMart1,
|
||||
),
|
||||
pages,
|
||||
// The rooftop sale shares the standard mart's texts.
|
||||
MART_TEXTS.standard,
|
||||
);
|
||||
yield* textbox(SYMBOLS.martComeAgainText);
|
||||
return;
|
||||
}
|
||||
let first = true;
|
||||
for (;;) {
|
||||
const choice = yield {
|
||||
@@ -2127,7 +2307,7 @@ export class ScriptRun {
|
||||
};
|
||||
first = false;
|
||||
const option = typeof choice === "number" ? choice : 2;
|
||||
if (option === 0) yield* this.martBuy(stock, pages);
|
||||
if (option === 0) yield* this.martBuy(martStock(), pages, MART_TEXTS.standard);
|
||||
else if (option === 1) yield* this.martSell(pages);
|
||||
else {
|
||||
write(SYMBOLS.martComeAgainText);
|
||||
@@ -2137,12 +2317,16 @@ export class ScriptRun {
|
||||
}
|
||||
}
|
||||
|
||||
/** BuyMenu/BuyMenuLoop: pick a stocked item, a 1-99 quantity, confirm the
|
||||
* price, then check funds and bag room before handing it over. */
|
||||
/** BuyMenu/BuyMenuLoop: pick a stocked item, then the quantity
|
||||
* (SelectQuantityToBuy, at the item's price or the sale's), or for the
|
||||
* bargain shop one of each item per visit (BargainShopAskPurchaseQuantity
|
||||
* and wBargainShopFlags); confirm the price, then check funds and bag
|
||||
* room before handing it over. Returns whether anything was bought. */
|
||||
private *martBuy(
|
||||
stock: number[],
|
||||
stock: { item: number; price: number }[],
|
||||
pages: string[],
|
||||
): Generator<ScriptEffect, void, Input> {
|
||||
texts: MartTexts,
|
||||
): Generator<ScriptEffect, boolean, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data;
|
||||
const write = (address: number) => {
|
||||
@@ -2153,11 +2337,13 @@ export class ScriptRun {
|
||||
const flush = function* (): Generator<ScriptEffect, void, Input> {
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
};
|
||||
const soldOut = new Set<number>();
|
||||
let bought = false;
|
||||
for (;;) {
|
||||
const entries: MartEntry[] = stock.map((item) => ({
|
||||
const entries: MartEntry[] = stock.map(({ item, price }) => ({
|
||||
item,
|
||||
name: data.itemName(item),
|
||||
price: data.itemPrice(item),
|
||||
price,
|
||||
}));
|
||||
const selection = yield {
|
||||
kind: "martlist",
|
||||
@@ -2165,39 +2351,52 @@ export class ScriptRun {
|
||||
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 index = typeof selection === "number" ? selection : -1;
|
||||
const entry = stock[index];
|
||||
if (!entry) return bought;
|
||||
const { item, price } = entry;
|
||||
let chosen = 1;
|
||||
if (texts.soldOut !== undefined) {
|
||||
if (soldOut.has(index)) {
|
||||
write(texts.soldOut);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const answer = yield {
|
||||
kind: "martquantity",
|
||||
mode: "buy",
|
||||
item,
|
||||
name: data.itemName(item),
|
||||
price,
|
||||
max: 99,
|
||||
prompt: readText(r, texts.howMany!, this.text()),
|
||||
selected: 1,
|
||||
};
|
||||
if (typeof answer !== "number" || answer < 1) continue;
|
||||
chosen = answer;
|
||||
}
|
||||
const total = price * chosen;
|
||||
this.strings[RAM.stringBuffer2] = data.itemName(item);
|
||||
this.numbers[RAM.itemQuantityChange] = chosen;
|
||||
this.numbers[RAM.moneyTemp] = total;
|
||||
write(SYMBOLS.martFinalPriceText);
|
||||
write(texts.finalPrice);
|
||||
const confirmed = yield { kind: "yesno", pages: take() };
|
||||
if (!confirmed) continue;
|
||||
if (total > this.state.money) {
|
||||
write(SYMBOLS.martNoMoneyText);
|
||||
write(texts.noMoney);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
if (!receiveItem(data, this.state.bag, item, chosen)) {
|
||||
write(SYMBOLS.martPackFullText);
|
||||
write(texts.packFull);
|
||||
yield* flush();
|
||||
continue;
|
||||
}
|
||||
soldOut.add(index);
|
||||
bought = true;
|
||||
this.state.money -= total;
|
||||
write(SYMBOLS.martThanksText);
|
||||
write(texts.thanks);
|
||||
yield* flush();
|
||||
}
|
||||
}
|
||||
@@ -2379,6 +2578,145 @@ export class ScriptRun {
|
||||
}
|
||||
}
|
||||
|
||||
/** GivePoke (engine/pokemon/move_mon.asm): TryAddMonToParty, or with a
|
||||
* full party SendMonIntoBox ("was sent to BILL's PC"); 2 when the box is
|
||||
* full too, else 0 for the party and 1 for the box. A gift takes its
|
||||
* nickname and OT from the script and its caught data from the byte after
|
||||
* the OT's name (SetGiftMonCaughtData); in the party its ID is
|
||||
* RANDY_OT_ID, in a box two Random bytes. Other Pokémon get the player's
|
||||
* OT, their caught data and GiveANickname_YesNo. */
|
||||
private *givePoke(
|
||||
species: number,
|
||||
level: number,
|
||||
item: number,
|
||||
gift: { nickname: number; ot: number } | undefined,
|
||||
write: (address: number) => void,
|
||||
take: () => string[],
|
||||
): Generator<ScriptEffect, number, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data,
|
||||
state = this.state,
|
||||
player = this.host.player();
|
||||
// A MAIL item needs its message (givepokemail follows for gifts).
|
||||
if (item && data.itemIsMail(item) && !gift)
|
||||
throw new Error("A MAIL gift needs its message.");
|
||||
const toBox = state.party.length >= PARTY_LENGTH;
|
||||
if (toBox && currentBox(state.storage).length >= MONS_PER_BOX) return 2;
|
||||
const giftCaught = gift
|
||||
? LANDMARK_GIFT | ((r.byte(this.stringEnd(gift.ot)) & 1) << 7)
|
||||
: undefined;
|
||||
let dvs: number;
|
||||
if (toBox) {
|
||||
// LoadEnemyMon outside battle takes the DVs GetTrainerDVs gives
|
||||
// wOtherTrainerClass, which CleanUpBattleRAM leaves at 0: the two
|
||||
// bytes before TrainerClassDVs.
|
||||
const p = SYMBOLS.trainerClassDVs - 2;
|
||||
dvs = (r.byte(p) << 8) | r.byte(p + 1);
|
||||
} else dvs = (this.host.random() << 8) | this.host.random();
|
||||
const mon = generatePartyMon(data, {
|
||||
species,
|
||||
level,
|
||||
item,
|
||||
otId: player.id,
|
||||
ot: this.host.text().player,
|
||||
dvs,
|
||||
caughtData:
|
||||
giftCaught ??
|
||||
caughtData(this.host.timeOfDay(), level, player.gender, this.host.landmark()),
|
||||
});
|
||||
if (gift) {
|
||||
mon.nickname = readString(r, gift.nickname, this.text(), 10);
|
||||
mon.ot = readString(r, gift.ot, this.text(), 7);
|
||||
if (toBox) mon.otId = (this.host.random() << 8) | this.host.random();
|
||||
else mon.otId = RANDY_OT_ID;
|
||||
}
|
||||
state.pokedex.seen.add(species);
|
||||
state.pokedex.caught.add(species);
|
||||
this.strings[RAM.stringBuffer1] = data.speciesName(species);
|
||||
if (toBox) {
|
||||
const box = toBoxMon(mon);
|
||||
restorePP(data, box);
|
||||
currentBox(state.storage).unshift(box);
|
||||
updateUnownDex(state, mon, false);
|
||||
} else {
|
||||
state.party.push(mon);
|
||||
updateUnownDex(state, mon);
|
||||
}
|
||||
if (!gift) {
|
||||
// GiveANickname_YesNo reads the species name from wStringBuffer1.
|
||||
write(SYMBOLS.caughtAskNicknameText);
|
||||
if (yield { kind: "yesno", pages: take() }) {
|
||||
// The naming screen's title: "<NAME>'S" / "NICKNAME?".
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (r.byte(second++) !== 0x50);
|
||||
const suffix = readString(r, SYMBOLS.nicknameStrings, this.text());
|
||||
const title = readString(r, second, this.text());
|
||||
const name = yield {
|
||||
kind: "name",
|
||||
pages: [`${mon.nickname}${suffix}\n${title}`],
|
||||
current: mon.nickname,
|
||||
max: 10,
|
||||
};
|
||||
if (typeof name !== "string") throw new Error("Invalid nickname.");
|
||||
if (name && !NICKNAME_PATTERN.test(name))
|
||||
throw new Error("Invalid nickname.");
|
||||
// InitName: a name of spaces keeps the species name.
|
||||
if (name && !/^ *$/.test(name)) mon.nickname = name;
|
||||
}
|
||||
if (toBox) currentBox(state.storage)[0].nickname = mon.nickname;
|
||||
}
|
||||
if (!toBox) return 0;
|
||||
write(JOHTO.WasSentToBillsPCText);
|
||||
return 1;
|
||||
}
|
||||
/** The address after a string's terminator. */
|
||||
private stringEnd(address: number) {
|
||||
let p = address;
|
||||
while (this.reader.byte(p++) !== 0x50);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** What translated specials outside this file work with. */
|
||||
private specialContext(
|
||||
pages: string[],
|
||||
take: () => string[],
|
||||
): SpecialContext {
|
||||
const r = this.reader;
|
||||
const write = (address: number) => {
|
||||
const content = readText(r, address, this.text());
|
||||
if (content) pages.push(...content.split("\n\n"));
|
||||
};
|
||||
return {
|
||||
reader: r,
|
||||
data: this.host.data,
|
||||
state: this.state,
|
||||
strings: this.strings,
|
||||
numbers: this.numbers,
|
||||
text: () => this.text(),
|
||||
random: () => this.host.random(),
|
||||
player: () => ({ id: this.host.player().id, name: this.host.text().player }),
|
||||
today: () => {
|
||||
if (!this.host.day) throw new Error("No game clock.");
|
||||
return this.host.day();
|
||||
},
|
||||
weekday: () => this.host.clock().weekday,
|
||||
write,
|
||||
take,
|
||||
flush: function* () {
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
},
|
||||
selectMon: () => this.selectMonFromParty(take()),
|
||||
};
|
||||
}
|
||||
/** PlaceMoneyTopRight's box: the money with the ¥ sign. */
|
||||
private moneyBalance() {
|
||||
return `${readString(this.reader, JOHTO.MoneyString, this.text())} ¥${this.state.money}`;
|
||||
}
|
||||
/** DisplayCoinCaseBalance's box: COIN and the coins. */
|
||||
private coinBalance() {
|
||||
return `${readString(this.reader, JOHTO.CoinString, this.text())} ${this.state.coins}`;
|
||||
}
|
||||
|
||||
/** SelectApricornForKurt (engine/events/kurt.asm): the apricorns in the
|
||||
* Items pocket (FindApricornsInBag, in ApricornBalls' order) with their
|
||||
* counts, then how many (BuySellToss_InterpretJoypad's 1 to the count,
|
||||
@@ -2904,6 +3242,72 @@ export class ScriptRun {
|
||||
}
|
||||
case JOHTO.SelectApricornForKurt:
|
||||
return yield* this.selectApricornForKurt(pages, take);
|
||||
case JOHTO.NameRater:
|
||||
yield* nameRater(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.GameCornerPrizeMonCheckDex:
|
||||
if (this.state.pokedex.caught.has(value)) return value;
|
||||
this.state.pokedex.seen.add(value);
|
||||
this.state.pokedex.caught.add(value);
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* new Pokedex({
|
||||
data: this.host.data,
|
||||
dex: new PokedexData(r),
|
||||
...this.state.pokedex,
|
||||
firstUnownSeen: this.state.firstUnownSeen,
|
||||
}).newEntry(value);
|
||||
return value;
|
||||
case JOHTO.OlderHaircutBrother:
|
||||
return yield* haircut(
|
||||
this.specialContext(pages, take),
|
||||
JOHTO.HappinessData_OlderHaircutBrother,
|
||||
);
|
||||
case JOHTO.YoungerHaircutBrother:
|
||||
return yield* haircut(
|
||||
this.specialContext(pages, take),
|
||||
JOHTO.HappinessData_YoungerHaircutBrother,
|
||||
);
|
||||
case JOHTO.DaisysGrooming:
|
||||
return yield* haircut(
|
||||
this.specialContext(pages, take),
|
||||
JOHTO.HappinessData_DaisysGrooming,
|
||||
);
|
||||
case JOHTO.GetFirstPokemonHappiness:
|
||||
return firstPokemonHappiness(this.specialContext(pages, take));
|
||||
case JOHTO.CheckFirstMonIsEgg:
|
||||
return checkFirstMonIsEgg(this.specialContext(pages, take));
|
||||
case JOHTO.CheckLuckyNumberShowFlag:
|
||||
return checkLuckyNumberShowFlag(this.specialContext(pages, take));
|
||||
case JOHTO.ResetLuckyNumberShowFlag:
|
||||
resetLuckyNumberShowFlag(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.PrintTodaysLuckyNumber:
|
||||
printTodaysLuckyNumber(this.specialContext(pages, take));
|
||||
return value;
|
||||
case JOHTO.CheckForLuckyNumberWinners:
|
||||
return yield* checkForLuckyNumberWinners(this.specialContext(pages, take));
|
||||
case JOHTO.BattleTowerAction:
|
||||
// BATTLETOWERACTION_GSBALL reads sGSBallFlag, which only the
|
||||
// mobile/Virtual Console event sets. The Battle Tower's other
|
||||
// actions are not ported yet.
|
||||
if (value === BATTLETOWERACTION_GSBALL) return 0;
|
||||
return undefined;
|
||||
case JOHTO.GameboyCheck:
|
||||
// The port is a Game Boy Color.
|
||||
return GBCHECK_CGB;
|
||||
case JOHTO.UnlockMysteryGift:
|
||||
// sMysteryGiftUnlocked only adds MYSTERY GIFT to the main menu,
|
||||
// which belongs to the link features.
|
||||
return value;
|
||||
case JOHTO.PlaceMoneyTopRight:
|
||||
this.balance = this.moneyBalance();
|
||||
return value;
|
||||
case JOHTO.DisplayCoinCaseBalance:
|
||||
this.balance = this.coinBalance();
|
||||
return value;
|
||||
case JOHTO.DisplayMoneyAndCoinBalance:
|
||||
this.balance = `${this.moneyBalance()}\n${this.coinBalance()}`;
|
||||
return value;
|
||||
case JOHTO.HoOhChamber:
|
||||
if (hoOhChamber(this.state.party))
|
||||
this.state.events.add(EVENTS.wallOpenedInHoOhChamber);
|
||||
|
||||
@@ -131,6 +131,20 @@ export class Pokedex {
|
||||
yield { kind: "pokedex" };
|
||||
}
|
||||
|
||||
/** NewPokedexEntry: registration shows both pages in order, each
|
||||
* waiting for A or B, with no entry action menu. */
|
||||
*newEntry(species: number): Gen {
|
||||
for (const page of [1, 2] as const) {
|
||||
const view = this.entryView(page, 0, species);
|
||||
view.entry!.actions = [];
|
||||
for (;;) {
|
||||
const button = yield* this.button(view);
|
||||
if (button === "a" || button === "b") break;
|
||||
}
|
||||
}
|
||||
yield { kind: "pokedex" };
|
||||
}
|
||||
|
||||
private *button(view: PokedexView): Gen<string> {
|
||||
const button = yield { kind: "pokedex", view };
|
||||
if (typeof button !== "string" || !POKEDEX_BUTTONS.includes(button))
|
||||
@@ -299,9 +313,12 @@ export class Pokedex {
|
||||
}
|
||||
|
||||
/** DisplayDexEntry. */
|
||||
private entryView(page: 1 | 2, action: number): PokedexView {
|
||||
private entryView(
|
||||
page: 1 | 2,
|
||||
action: number,
|
||||
species = this.selected(),
|
||||
): PokedexView {
|
||||
const { data, dex, caught } = this.host;
|
||||
const species = this.selected();
|
||||
const entry = dex.entry(species);
|
||||
const owned = caught.has(species);
|
||||
const height = dex.dexString("Pokedex_DrawDexEntryScreenBG.Height"),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { ScriptEffect, ScriptState } from "./interpreter";
|
||||
import type { TextContext } from "./text";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
export type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** What a special routine translated outside the interpreter works with:
|
||||
* the script's draft state, its text buffers and pages, and the game. */
|
||||
export interface SpecialContext {
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
state: ScriptState;
|
||||
/** wStringBuffer1 and the other RAM strings text_ram prints. */
|
||||
strings: Record<number, string>;
|
||||
/** Native values for text_decimal, keyed by RAM address. */
|
||||
numbers: Record<number, number>;
|
||||
text(): TextContext;
|
||||
/** The Random routine: one byte. */
|
||||
random(): number;
|
||||
/** wPlayerID and the player's name. */
|
||||
player(): { id: number; name: string };
|
||||
/** The game clock's day number and weekday (wCurDay's and GetWeekday's
|
||||
* source). */
|
||||
today(): number;
|
||||
weekday(): number;
|
||||
/** Print a text into the pending pages. */
|
||||
write(address: number): void;
|
||||
/** Take the pending pages (for an effect that shows them). */
|
||||
take(): string[];
|
||||
/** Show the pending pages and wait for a button, if there are any. */
|
||||
flush(): Gen;
|
||||
/** SelectMonFromParty after the pending pages: a party index, or -1. */
|
||||
selectMon(): Gen<number>;
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export const OW = {
|
||||
ENGINE_UNLOCKED_UNOWNS_A_TO_K: 43,
|
||||
ENGINE_UNOWN_DEX: 12,
|
||||
PREDEFPAL_UNOWN_PUZZLE: 76,
|
||||
ENGINE_GOLDENROD_UNDERGROUND_MERCHANT_CLOSED: 86,
|
||||
ENGINE_LUCKY_NUMBER_SHOW: 78,
|
||||
ENGINE_HIVEBADGE: 28,
|
||||
ENGINE_PLAINBADGE: 29,
|
||||
ENGINE_FOGBADGE: 30,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** The RTC's day counter wraps every 20 weeks (FixDays keeps wCurDay below
|
||||
* 140), and day differences wrap with it (_CalcDaysSince). */
|
||||
export const RTC_DAYS = 20 * 7;
|
||||
|
||||
/** A day countdown (InitNDaysCountdown's two bytes): the days remaining
|
||||
* and the wCurDay it was last checked on. */
|
||||
export interface DayTimer {
|
||||
remaining: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/** wCurDay for a native day number. */
|
||||
export const rtcDay = (day: number) => ((day % RTC_DAYS) + RTC_DAYS) % RTC_DAYS;
|
||||
|
||||
/** InitNDaysCountdown. */
|
||||
export function initCountdown(days: number, today: number): DayTimer {
|
||||
return { remaining: days, day: rtcDay(today) };
|
||||
}
|
||||
|
||||
/** CheckDayDependentEventHL: CalcDaysSince (which also moves the stored
|
||||
* day to today) and UpdateTimeRemaining. True (carry) once the countdown
|
||||
* has run out; a timer never started (`undefined`, all zeros in the
|
||||
* original) is already out. */
|
||||
export function checkCountdown(timer: DayTimer | undefined, today: number) {
|
||||
const current = rtcDay(today);
|
||||
const start = timer ?? { remaining: 0, day: 0 };
|
||||
const elapsed = (current - start.day + RTC_DAYS) % RTC_DAYS;
|
||||
const remaining = Math.max(0, start.remaining - elapsed);
|
||||
return { done: remaining === 0, timer: { remaining, day: current } };
|
||||
}
|
||||
|
||||
/** The days until next Friday (RestartLuckyNumberCountdown's
|
||||
* .GetDaysUntilNextFriday): 7 on Fridays and 6 on Saturdays. */
|
||||
export function daysUntilNextFriday(weekday: number) {
|
||||
const FRIDAY = 5;
|
||||
const days = FRIDAY - weekday;
|
||||
return days > 0 ? days : days + 7;
|
||||
}
|
||||
|
||||
export function validDayTimer(value: unknown): value is DayTimer {
|
||||
const timer = value as DayTimer;
|
||||
return (
|
||||
Boolean(timer) &&
|
||||
typeof timer === "object" &&
|
||||
Number.isInteger(timer.remaining) &&
|
||||
timer.remaining >= 0 &&
|
||||
timer.remaining <= 255 &&
|
||||
Number.isInteger(timer.day) &&
|
||||
timer.day >= 0 &&
|
||||
timer.day < RTC_DAYS
|
||||
);
|
||||
}
|
||||
+53
-6
@@ -55,10 +55,12 @@ import {
|
||||
type ScriptHost,
|
||||
type Portrait,
|
||||
type ScriptState,
|
||||
type DayTimerName,
|
||||
} from "../scripts/interpreter";
|
||||
import { IntroRun } from "../scripts/intro";
|
||||
import { FLY_MAP_BUTTONS, type FlyMapView } from "../scripts/fly-map";
|
||||
import { SCREEN_BUTTONS, type TileScreen } from "../scripts/screen";
|
||||
import { RTC_DAYS, validDayTimer, type DayTimer } from "./day-timers";
|
||||
import { POKEDEX_BUTTONS, type PokedexView } from "../scripts/pokedex";
|
||||
import { regionAtlas, type AtlasRegion, type AtlasView } from "./atlas";
|
||||
import {
|
||||
@@ -109,6 +111,9 @@ import {
|
||||
type Warp,
|
||||
} from "./types";
|
||||
|
||||
/** The day countdowns a save may hold. */
|
||||
const DAY_TIMERS: readonly DayTimerName[] = ["luckyNumber"];
|
||||
|
||||
export interface NativeSave {
|
||||
decorations: Decorations;
|
||||
format: "crystal-native";
|
||||
@@ -163,6 +168,8 @@ export interface NativeSave {
|
||||
/** STATUSFLAGS_FLASH_F and STATUSFLAGS_NO_WILD_ENCOUNTERS_F. */
|
||||
flash: boolean;
|
||||
noWildEncounters: boolean;
|
||||
/** STATUSFLAGS_HALL_OF_FAME_F. */
|
||||
hallOfFame: boolean;
|
||||
/** wWhichRegisteredItem and wRegisteredItem, for Select. */
|
||||
registeredItem: { which: number; item: number };
|
||||
/** sMailboxes: the PC's MAIL. */
|
||||
@@ -178,6 +185,9 @@ export interface NativeSave {
|
||||
/** wUnownDex and wFirstUnownSeen. */
|
||||
unownDex: number[];
|
||||
firstUnownSeen: number;
|
||||
/** RTC day countdowns by name, and the Lucky Number show's number. */
|
||||
timers: Partial<Record<DayTimerName, DayTimer>>;
|
||||
luckyNumber: { id: number; day: number };
|
||||
/** The option bytes, saved with the game as sOptions is. */
|
||||
options: GameOptions;
|
||||
}
|
||||
@@ -274,7 +284,7 @@ export interface Pose {
|
||||
inGrass?: boolean;
|
||||
}
|
||||
export type Choice =
|
||||
| { kind: "yesno"; selected: 0 | 1 }
|
||||
| { kind: "yesno"; selected: 0 | 1; balance?: string }
|
||||
| { kind: "weekday"; names: string[]; selected: number }
|
||||
/** InitClock's hour or minute: Up and Down wrap through `labels`. */
|
||||
| { kind: "clock"; labels: string[]; selected: number }
|
||||
@@ -295,6 +305,8 @@ export type Choice =
|
||||
selected: number;
|
||||
/** Left and right answer "left" or "right". */
|
||||
sides?: boolean;
|
||||
/** A money or coin box shown with the menu. */
|
||||
balance?: string;
|
||||
}
|
||||
/** The mart's BUY/SELL/QUIT box. */
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
@@ -2103,6 +2115,7 @@ export class GameWorld {
|
||||
environment: () => this.assets.map(mapId).environment,
|
||||
phoneService: () => this.assets.map(mapId).phoneService,
|
||||
facingObject: () => this.facingObject(),
|
||||
day: () => this.gameDay(),
|
||||
tileset: () => ({
|
||||
graphics: this.assets.tileset(this.map).graphics,
|
||||
palettes: this.assets.palettes(this.map, this.timeOfDay),
|
||||
@@ -2408,7 +2421,11 @@ export class GameWorld {
|
||||
this.dialogue = {
|
||||
pages: effect.pages.length ? effect.pages : [""],
|
||||
index: 0,
|
||||
choice: { kind: "yesno", selected: 1 },
|
||||
choice: {
|
||||
kind: "yesno",
|
||||
selected: 1,
|
||||
...(effect.balance && { balance: effect.balance }),
|
||||
},
|
||||
};
|
||||
return true;
|
||||
case "weekday":
|
||||
@@ -2718,6 +2735,7 @@ export class GameWorld {
|
||||
...(effect.details && { details: effect.details }),
|
||||
selected: effect.selected,
|
||||
...(effect.sides && { sides: true }),
|
||||
...(effect.balance && { balance: effect.balance }),
|
||||
},
|
||||
};
|
||||
return true;
|
||||
@@ -3332,12 +3350,18 @@ export class GameWorld {
|
||||
|
||||
/** 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.gameNow(this.state.clock);
|
||||
const day =
|
||||
/** The game clock's day number, which moves with SetDayOfWeek's
|
||||
* offset as the RTC's days do. */
|
||||
private gameDay(clock = this.liveState.clock) {
|
||||
const now = this.gameNow(clock);
|
||||
return (
|
||||
Math.floor(
|
||||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86_400_000,
|
||||
) + this.state.clock.weekdayOffset;
|
||||
) + clock.weekdayOffset
|
||||
);
|
||||
}
|
||||
private checkDailyReset() {
|
||||
const day = this.gameDay(this.state.clock);
|
||||
if (this.state.dailyReset === day) return;
|
||||
this.dailyFlags ??= this.assets.dailyEngineFlags();
|
||||
for (const flag of this.dailyFlags) this.state.flags.delete(flag);
|
||||
@@ -4362,6 +4386,7 @@ export class GameWorld {
|
||||
digWarp: state.digWarp && { ...state.digWarp },
|
||||
flash: state.flash,
|
||||
noWildEncounters: state.noWildEncounters,
|
||||
hallOfFame: state.hallOfFame,
|
||||
registeredItem: { ...state.registeredItem },
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
@@ -4371,6 +4396,8 @@ export class GameWorld {
|
||||
memory: { ...state.memory },
|
||||
unownDex: [...state.unownDex],
|
||||
firstUnownSeen: state.firstUnownSeen,
|
||||
timers: structuredClone(state.timers),
|
||||
luckyNumber: { ...state.luckyNumber },
|
||||
options: { ...state.options },
|
||||
};
|
||||
}
|
||||
@@ -4485,6 +4512,7 @@ export class GameWorld {
|
||||
) ||
|
||||
typeof save.flash !== "boolean" ||
|
||||
typeof save.noWildEncounters !== "boolean" ||
|
||||
typeof save.hallOfFame !== "boolean" ||
|
||||
!save.registeredItem ||
|
||||
!integer(save.registeredItem.which, 0, 255) ||
|
||||
!integer(save.registeredItem.item, 0, 255) ||
|
||||
@@ -4503,6 +4531,14 @@ export class GameWorld {
|
||||
!save.unownDex.every((letter) => integer(letter, 1, 26)) ||
|
||||
new Set(save.unownDex).size !== save.unownDex.length ||
|
||||
!integer(save.firstUnownSeen, 0, 26) ||
|
||||
!save.timers ||
|
||||
typeof save.timers !== "object" ||
|
||||
Object.entries(save.timers).some(
|
||||
([name, timer]) => !DAY_TIMERS.includes(name as never) || !validDayTimer(timer),
|
||||
) ||
|
||||
!save.luckyNumber ||
|
||||
!integer(save.luckyNumber.id, 0, 0xffff) ||
|
||||
!integer(save.luckyNumber.day, 0, RTC_DAYS) ||
|
||||
!validOptions(save.options)
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -4569,6 +4605,7 @@ export class GameWorld {
|
||||
digWarp: save.digWarp && { ...save.digWarp },
|
||||
flash: save.flash,
|
||||
noWildEncounters: save.noWildEncounters,
|
||||
hallOfFame: save.hallOfFame,
|
||||
registeredItem: { ...save.registeredItem },
|
||||
mailbox: save.mailbox.map(copyMail),
|
||||
repel: save.repel,
|
||||
@@ -4578,6 +4615,8 @@ export class GameWorld {
|
||||
memory: { ...save.memory },
|
||||
unownDex: [...save.unownDex],
|
||||
firstUnownSeen: save.firstUnownSeen,
|
||||
timers: structuredClone(save.timers),
|
||||
luckyNumber: { ...save.luckyNumber },
|
||||
options: { ...save.options },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
rivalName: save.rivalName,
|
||||
@@ -4686,6 +4725,7 @@ export function migrateSave(
|
||||
...(!Object.hasOwn(save, "noWildEncounters") && {
|
||||
noWildEncounters: false,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "hallOfFame") && { hallOfFame: false }),
|
||||
...(!Object.hasOwn(save, "registeredItem") && {
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
}),
|
||||
@@ -4712,6 +4752,10 @@ export function migrateSave(
|
||||
unownDex: [],
|
||||
firstUnownSeen: 0,
|
||||
}),
|
||||
...(!Object.hasOwn(save, "timers") && {
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
}),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
||||
@@ -4759,6 +4803,7 @@ export function migrateSave(
|
||||
digWarp: null,
|
||||
flash: false,
|
||||
noWildEncounters: false,
|
||||
hallOfFame: false,
|
||||
registeredItem: { which: 0, item: 0 },
|
||||
mailbox: [],
|
||||
repel: 0,
|
||||
@@ -4768,6 +4813,8 @@ export function migrateSave(
|
||||
memory: {},
|
||||
unownDex: [],
|
||||
firstUnownSeen: 0,
|
||||
timers: {},
|
||||
luckyNumber: { id: 0, day: 0 },
|
||||
intro: false,
|
||||
gameTime: emptyGameTime(),
|
||||
options: defaultOptions(),
|
||||
|
||||
+10
-1
@@ -179,6 +179,7 @@ function showChoice(choice: Choice | undefined) {
|
||||
container.hidden = true;
|
||||
} else if (choice.kind === "yesno")
|
||||
container.replaceChildren(
|
||||
...balanceBox(choice.balance),
|
||||
button("YES", choice.selected === 1, () => runtime.choose(1)),
|
||||
button("NO", choice.selected === 0, () => runtime.choose(0)),
|
||||
);
|
||||
@@ -296,13 +297,21 @@ function showChoice(choice: Choice | undefined) {
|
||||
}),
|
||||
);
|
||||
// As in the original there is no CANCEL entry: B leaves the menu.
|
||||
container.replaceChildren(list);
|
||||
container.replaceChildren(...balanceBox(choice.balance), list);
|
||||
// On a small screen the box scrolls; keep the highlighted entry in view.
|
||||
list
|
||||
.querySelector('[aria-selected="true"]')
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
/** The money or coin box a script placed beside a choice. */
|
||||
function balanceBox(balance: string | undefined): HTMLElement[] {
|
||||
if (!balance) return [];
|
||||
const box = document.createElement("output");
|
||||
box.className = "choice-balance";
|
||||
box.textContent = balance;
|
||||
return [box];
|
||||
}
|
||||
/** The naming screen as a text field and OK: an empty name keeps the
|
||||
* current one. */
|
||||
function nameField(
|
||||
|
||||
@@ -1914,3 +1914,14 @@ dialog dd {
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* PlaceMoneyTopRight and the coin case's box beside a choice. */
|
||||
.choice-balance {
|
||||
display: block;
|
||||
justify-self: end;
|
||||
margin-bottom: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 3px double currentColor;
|
||||
white-space: pre;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
Reference in new issue
Block a user