Open Route 31, Violet City and Sprout Tower (#31)
Enable Route 31 and its gate, Dark Cave's Violet entrance, Violet City with its buildings and Sprout Tower. Port the script commands they use: gettrainername, askforphonenumber (with AddPhoneNumber's room for Mom and Elm; addcellnum now reports failure as the original does), checkpokemail, loadmenu/_2dmenu/closewindow, giveegg and trade (NPCTrade, with wTradeFlags saved). Eggs keep their species with an egg flag, as wPartySpecies does. The pack's TM/HM counts receive, check and toss TMs and HMs, which are named TM01-HM07. The playthrough continues through Route 31 (with Mom's call), Violet City and Sprout Tower to Elder Li's Flash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec3d1cd5c4
commit
7909302db1
16 files changed
+585
-63
No files matched your search
@@ -86,6 +86,8 @@ const names = {
|
||||
pocketIsFullText: "PocketIsFullText",
|
||||
findItemInBallScript: "FindItemInBallScript",
|
||||
caughtAskNicknameText: "CaughtAskNicknameText",
|
||||
stringEgg: "String_Egg",
|
||||
tmhmMoves: "TMHMMoves",
|
||||
nicknameStrings: "NamingScreenJumptable.NicknameStrings",
|
||||
rivalNameString: "NamingScreenJumptable.RivalNameString",
|
||||
rivalDefaultName: "NameRival.DefaultName",
|
||||
|
||||
@@ -1391,7 +1391,7 @@ export class Battle {
|
||||
private *useItemSelectMon(): Gen<number> {
|
||||
const slot = yield* this.partyMenu(BATTLE.UseOnWhichPKMNString, false, true);
|
||||
if (slot < 0) return -1;
|
||||
if (this.options.party[slot].species === C.EGG) {
|
||||
if (this.options.party[slot].egg) {
|
||||
yield* this.say(BATTLE.ItemCantUseOnEggText);
|
||||
return -1;
|
||||
}
|
||||
@@ -3465,7 +3465,7 @@ export class Battle {
|
||||
}
|
||||
const mon = party[this.beatUpMon];
|
||||
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
|
||||
if (party.some((member) => member.species === C.EGG))
|
||||
if (party.some((member) => member.egg))
|
||||
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet.");
|
||||
// The player's members go by nickname, a trainer's by species name.
|
||||
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
|
||||
@@ -5467,7 +5467,7 @@ export class Battle {
|
||||
const mon = this.options.party[slot];
|
||||
if (mon.hp) return true;
|
||||
if (!this.battleHasJustStarted)
|
||||
yield* this.say(mon.species === C.EGG ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
|
||||
yield* this.say(mon.egg ? BATTLE.BattleText_AnEGGCantBattle : BATTLE.BattleText_TheresNoWillToBattle);
|
||||
return false;
|
||||
}
|
||||
/** PickSwitchMonInBattle: a fit Pokémon other than the one out, or -1. */
|
||||
@@ -5953,7 +5953,7 @@ export class Battle {
|
||||
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
|
||||
private changeHappiness(index: number, action: number) {
|
||||
const mon = this.options.party[index];
|
||||
if (mon.species === C.EGG) return;
|
||||
if (mon.egg) return;
|
||||
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
|
||||
mon.happiness = changedHappiness(
|
||||
mon.happiness,
|
||||
|
||||
+29
-5
@@ -1,4 +1,4 @@
|
||||
import type { PokemonData, Pocket } from "../rom/pokemon";
|
||||
import { NUM_TMS_HMS, isTMHM, tmhmNumber, type PokemonData, type Pocket } from "../rom/pokemon";
|
||||
|
||||
export interface ItemSlot {
|
||||
item: number;
|
||||
@@ -9,12 +9,20 @@ export interface Bag {
|
||||
items: ItemSlot[];
|
||||
balls: ItemSlot[];
|
||||
keyItems: number[];
|
||||
/** wTMsHMs: how many of each TM and HM, by TM/HM number less one. */
|
||||
tmsHms: number[];
|
||||
}
|
||||
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
|
||||
export const emptyBag = (): Bag => ({
|
||||
items: [],
|
||||
balls: [],
|
||||
keyItems: [],
|
||||
tmsHms: Array<number>(NUM_TMS_HMS).fill(0),
|
||||
});
|
||||
export const copyBag = (bag: Bag): Bag => ({
|
||||
items: bag.items.map((slot) => ({ ...slot })),
|
||||
balls: bag.balls.map((slot) => ({ ...slot })),
|
||||
keyItems: [...bag.keyItems],
|
||||
tmsHms: [...bag.tmsHms],
|
||||
});
|
||||
|
||||
const MAX_ITEMS = 20,
|
||||
@@ -72,7 +80,12 @@ export function receiveItem(
|
||||
bag.keyItems.push(item);
|
||||
return true;
|
||||
}
|
||||
throw new Error("TM and HM items are not ported yet.");
|
||||
// ReceiveTMHM: the count, which fails past 99.
|
||||
const index = tmhmNumber(item) - 1,
|
||||
total = bag.tmsHms[index] + quantity;
|
||||
if (total > MAX_ITEM_STACK) return false;
|
||||
bag.tmsHms[index] = total;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** CheckItem across the pockets. */
|
||||
@@ -80,7 +93,8 @@ export function hasItem(bag: Bag, item: number): boolean {
|
||||
return (
|
||||
bag.items.some((slot) => slot.item === item) ||
|
||||
bag.balls.some((slot) => slot.item === item) ||
|
||||
bag.keyItems.includes(item)
|
||||
bag.keyItems.includes(item) ||
|
||||
(isTMHM(item) && bag.tmsHms[tmhmNumber(item) - 1] > 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,7 +134,10 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
|
||||
validItemSlots(data, bag.balls, MAX_BALLS, "ball") &&
|
||||
Array.isArray(bag.keyItems) &&
|
||||
bag.keyItems.length <= MAX_KEY_ITEMS &&
|
||||
bag.keyItems.every((item) => itemIn(data, item, "key"))
|
||||
bag.keyItems.every((item) => itemIn(data, item, "key")) &&
|
||||
Array.isArray(bag.tmsHms) &&
|
||||
bag.tmsHms.length === NUM_TMS_HMS &&
|
||||
bag.tmsHms.every((count) => Number.isInteger(count) && count >= 0 && count <= MAX_ITEM_STACK)
|
||||
);
|
||||
}
|
||||
function itemIn(data: PokemonData, item: unknown, pocket: Pocket) {
|
||||
@@ -134,6 +151,13 @@ function itemIn(data: PokemonData, item: unknown, pocket: Pocket) {
|
||||
/** TossItem as `takeitem` uses it: only the first matching stack is used,
|
||||
* and taking more than it holds fails. */
|
||||
export function tossItem(bag: Bag, item: number, quantity: number): boolean {
|
||||
// TossTMHM: the count, which fails if it is too small.
|
||||
if (isTMHM(item)) {
|
||||
const index = tmhmNumber(item) - 1;
|
||||
if (bag.tmsHms[index] < quantity) return false;
|
||||
bag.tmsHms[index] -= quantity;
|
||||
return true;
|
||||
}
|
||||
const key = bag.keyItems.indexOf(item);
|
||||
if (key >= 0) {
|
||||
bag.keyItems.splice(key, 1);
|
||||
|
||||
@@ -27,9 +27,29 @@ export interface PartyMon {
|
||||
stats: [number, number, number, number, number, number];
|
||||
/** sPartyMail's entry while the Pokémon holds MAIL. */
|
||||
mail?: Mail;
|
||||
/** An Egg: wPartySpecies holds EGG for this slot. `species` is what
|
||||
* hatches, `happiness` counts the remaining egg cycles and the nickname
|
||||
* is "EGG". */
|
||||
egg?: true;
|
||||
}
|
||||
|
||||
export const PARTY_LENGTH = 6;
|
||||
|
||||
const STATUS_NAMES: [number, string][] = [
|
||||
[0x08, " PSN"],
|
||||
[0x10, " BRN"],
|
||||
[0x20, " FRZ"],
|
||||
[0x40, " PAR"],
|
||||
];
|
||||
function statusName(status: number) {
|
||||
if (status & 7) return " SLP";
|
||||
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
||||
}
|
||||
/** A party menu row's level, HP and status (or FNT); an Egg shows none. */
|
||||
export function partyMonDetails(mon: PartyMon): string {
|
||||
if (mon.egg) return "";
|
||||
return `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`;
|
||||
}
|
||||
/** Characters a nickname can hold: the naming screen's letters and
|
||||
* symbols, plus the digits and apostrophes of default species names. */
|
||||
export const NICKNAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,10}$/u;
|
||||
@@ -215,7 +235,8 @@ export function validBoxFields(value: unknown): boolean {
|
||||
integer(mon.happiness, 0, 255) &&
|
||||
integer(mon.pokerus, 0, 255) &&
|
||||
integer(mon.caughtData, 0, 0xffff) &&
|
||||
integer(mon.level, 1, 100)
|
||||
integer(mon.level, 1, 100) &&
|
||||
(mon.egg === undefined || mon.egg === true)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -91,7 +91,7 @@ export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
|
||||
export function bufferMon(data: PokemonData, box: BoxMon): PartyMon {
|
||||
const mon = structuredClone(box);
|
||||
const stats = calcStats(data, mon.species, mon.dvs, mon.level, mon.statExp);
|
||||
return { ...mon, status: 0, hp: mon.species === 0xfd ? 0 : stats[0], stats };
|
||||
return { ...mon, status: 0, hp: mon.egg ? 0 : stats[0], stats };
|
||||
}
|
||||
|
||||
/** ReceiveItem into wNumPCItems. */
|
||||
|
||||
+50
-1
@@ -11,6 +11,32 @@ import {
|
||||
SYMBOLS,
|
||||
} from "./profile";
|
||||
import { defaultTextContext, readString } from "../scripts/text";
|
||||
|
||||
/** The TM and HM items (constants/item_constants.asm): TM01-TM50, then
|
||||
* HM01-HM07, skipping the dummy ITEM_C3 and ITEM_DC. */
|
||||
export const TM01 = 0xbf,
|
||||
HM01 = 0xf3,
|
||||
NUM_TMS = 50,
|
||||
NUM_HMS = 7,
|
||||
NUM_TMS_HMS = NUM_TMS + NUM_HMS;
|
||||
const ITEM_C3 = 0xc3,
|
||||
ITEM_DC = 0xdc;
|
||||
/** GetTMHMNumber: TM01 is 1, HM01 is 51. */
|
||||
export function tmhmNumber(item: number): number {
|
||||
let value = item;
|
||||
if (item >= ITEM_C3) value--;
|
||||
if (item >= ITEM_DC) value--;
|
||||
return value - TM01 + 1;
|
||||
}
|
||||
/** GetNumberedTMHM: the item for a TM/HM number. */
|
||||
export function tmhmItem(number: number): number {
|
||||
let item = number + TM01 - 1;
|
||||
if (item >= ITEM_C3) item++;
|
||||
if (item >= ITEM_DC) item++;
|
||||
return item;
|
||||
}
|
||||
export const isTMHM = (item: number) =>
|
||||
item >= TM01 && item < HM01 + NUM_HMS && item !== ITEM_C3 && item !== ITEM_DC;
|
||||
import type { Palette } from "../world/types";
|
||||
|
||||
/** data/pokemon/base_stats: one 32-byte record per species. */
|
||||
@@ -131,10 +157,33 @@ export class PokemonData {
|
||||
}
|
||||
itemName(item: number): string {
|
||||
this.checkItem(item);
|
||||
if (isTMHM(item)) {
|
||||
// GetTMHMName: "TM" or "HM" and two digits; HMs count from 1.
|
||||
const number = tmhmNumber(item),
|
||||
hm = item >= HM01;
|
||||
return `${hm ? "HM" : "TM"}${String(hm ? number - NUM_TMS : number).padStart(2, "0")}`;
|
||||
}
|
||||
return this.cachedName(`item:${item}`, () =>
|
||||
this.listName(SYMBOLS.itemNames, item),
|
||||
);
|
||||
}
|
||||
/** GetTMHMMove: the move a TM/HM number teaches. */
|
||||
tmhmMove(number: number): number {
|
||||
if (!Number.isInteger(number) || number < 1 || number > NUM_TMS_HMS)
|
||||
throw new Error("Unknown TM or HM.");
|
||||
return this.reader.byte(SYMBOLS.tmhmMoves + number - 1);
|
||||
}
|
||||
/** CanLearnTMHMMove: the move's place in TMHMMoves (with the tutor
|
||||
* moves after the HMs) as a bit of the species' base TM/HM flags. */
|
||||
canLearnTMHM(species: number, move: number): boolean {
|
||||
this.checkSpecies(species);
|
||||
const r = this.reader;
|
||||
let index = 0;
|
||||
for (let p = SYMBOLS.tmhmMoves; r.byte(p); p++, index++)
|
||||
if (r.byte(p) === move)
|
||||
return Boolean(r.byte(SYMBOLS.baseData + (species - 1) * 32 + 24 + (index >> 3)) & (1 << (index & 7)));
|
||||
return false;
|
||||
}
|
||||
private cachedName(key: string, read: () => string) {
|
||||
let name = this.names.get(key);
|
||||
if (name === undefined) {
|
||||
@@ -144,7 +193,7 @@ export class PokemonData {
|
||||
return name;
|
||||
}
|
||||
private checkItem(item: number) {
|
||||
if (!Number.isInteger(item) || item < 1 || item > NUM_ITEMS)
|
||||
if (!Number.isInteger(item) || ((item < 1 || item > NUM_ITEMS) && !isTMHM(item)))
|
||||
throw new Error("Unknown item.");
|
||||
}
|
||||
/** ItemAttributes: price, held effect, parameter, property, pocket, menus. */
|
||||
|
||||
@@ -57,6 +57,8 @@ export const SYMBOLS = {
|
||||
pocketIsFullText: 0x97060,
|
||||
findItemInBallScript: 0x122ce,
|
||||
caughtAskNicknameText: 0x4db44,
|
||||
stringEgg: 0xe035,
|
||||
tmhmMoves: 0x1167a,
|
||||
nicknameStrings: 0x11780,
|
||||
rivalNameString: 0x117c3,
|
||||
rivalDefaultName: 0xc2b2,
|
||||
@@ -194,6 +196,11 @@ export const ENGINE_FLAGS = {
|
||||
/** Overworld scripts, routines and tables, keyed by their PRET labels so
|
||||
* that `npm run check:reference` verifies each address by name. */
|
||||
export const OVERWORLD = {
|
||||
NPCTrades: 0xfce58,
|
||||
TradeTexts: 0xfcf53,
|
||||
NPCTradeCableText: 0xfcf7b,
|
||||
TradedForText: 0xfcf80,
|
||||
"TradedForText.done": 0xfcf92,
|
||||
BugCatchingContestBattleScript: 0x135eb,
|
||||
ContestMons: 0x97d87,
|
||||
"OverworldTownMap": 0xc2c0,
|
||||
@@ -1028,5 +1035,18 @@ export const NATIVE_MAPS: Record<string, string> = {
|
||||
"26:8": "Evolution speech house",
|
||||
"26:9": "Berry house",
|
||||
"26:10": "Mr. Pokémon’s house",
|
||||
"26:2": "Route 31",
|
||||
"26:11": "Route 31 Violet gate",
|
||||
"3:78": "Dark Cave",
|
||||
"10:5": "Violet City",
|
||||
"10:6": "Violet Mart",
|
||||
"10:7": "Violet Gym",
|
||||
"10:8": "Earl’s Pokémon Academy",
|
||||
"10:9": "Nickname speech house",
|
||||
"10:10": "Pokémon Center",
|
||||
"10:11": "Kyle’s house",
|
||||
"3:1": "Sprout Tower 1F",
|
||||
"3:2": "Sprout Tower 2F",
|
||||
"3:3": "Sprout Tower 3F",
|
||||
};
|
||||
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
|
||||
@@ -7,6 +7,7 @@ import { RomReader } from "../rom/reader";
|
||||
import {
|
||||
EMPTY_ROUTINES,
|
||||
ENGINE_FLAGS,
|
||||
MENU,
|
||||
NATIVE_ROUTINES,
|
||||
NUM_FRUIT_TREES,
|
||||
EVENTS,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
caughtData,
|
||||
generatePartyMon,
|
||||
maxPP,
|
||||
partyMonDetails,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { copyMail, type Mail } from "../party/mail";
|
||||
@@ -137,6 +139,8 @@ export interface ScriptState {
|
||||
repel: number;
|
||||
/** wCoins: the Coin Case's coins. */
|
||||
coins: number;
|
||||
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
|
||||
tradeFlags: number;
|
||||
/** wBlueCardBalance: the Blue Card's points. */
|
||||
blueCardBalance: number;
|
||||
/** wOptions, wTextboxFrame, wGBPrinterBrightness and wOptions2. */
|
||||
@@ -177,6 +181,7 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
mailbox: [],
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
tradeFlags: 0,
|
||||
blueCardBalance: 0,
|
||||
options: defaultOptions(),
|
||||
});
|
||||
@@ -214,6 +219,7 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
coins: state.coins,
|
||||
tradeFlags: state.tradeFlags,
|
||||
blueCardBalance: state.blueCardBalance,
|
||||
options: { ...state.options },
|
||||
...(state.bugContest && {
|
||||
@@ -269,6 +275,33 @@ const UP = 1;
|
||||
const ROUTE_36 = "10:3";
|
||||
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
/** PermanentNumbers: PHONECONTACT_MOM and PHONECONTACT_ELM, which share
|
||||
* PHONE_MOM's and PHONE_ELM's values. */
|
||||
const PERMANENT_NUMBERS = [1, 4];
|
||||
/** askforphonenumber's results (constants/script_constants.asm). */
|
||||
const PHONE_CONTACT_GOT = 0,
|
||||
PHONE_CONTACTS_FULL = 1,
|
||||
PHONE_CONTACT_REFUSED = 2;
|
||||
/** NPCTrades (data/events/npc_trades.asm) and its dialogs. */
|
||||
const NUM_NPC_TRADES = 7,
|
||||
NPCTRADE_STRUCT_LENGTH = 32,
|
||||
TRADE_DIALOG_INTRO = 0,
|
||||
TRADE_DIALOG_CANCEL = 1,
|
||||
TRADE_DIALOG_WRONG = 2,
|
||||
TRADE_DIALOG_COMPLETE = 3,
|
||||
TRADE_DIALOG_AFTER = 4,
|
||||
TRADE_DIALOGSET_GIRL = 3,
|
||||
TRADE_GENDER_MALE = 1,
|
||||
TRADE_GENDER_FEMALE = 2,
|
||||
LANDMARK_GIFT = 0x7e,
|
||||
PARTYMENUACTION_CHOOSE_POKEMON = 0,
|
||||
PARTYMENUACTION_GIVE_MON = 6;
|
||||
/** checkpokemail's results. */
|
||||
const POKEMAIL_WRONG_MAIL = 0,
|
||||
POKEMAIL_CORRECT = 1,
|
||||
POKEMAIL_REFUSED = 2,
|
||||
POKEMAIL_NO_MAIL = 3,
|
||||
POKEMAIL_LAST_MON = 4;
|
||||
|
||||
/** 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. */
|
||||
@@ -667,7 +700,10 @@ export class ScriptRun {
|
||||
asm: (offset) =>
|
||||
offset === OVERWORLD.UseFlashTextScript + 4
|
||||
? OVERWORLD["UseFlashTextScript.BlankText"]
|
||||
: undefined,
|
||||
: // TradedForText: the fanfare after Text_NPCTraded.
|
||||
offset === OVERWORLD.TradedForText + 4
|
||||
? OVERWORLD["TradedForText.done"]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -692,6 +728,7 @@ export class ScriptRun {
|
||||
lossText = this.tempTrainer?.lostScript || undefined;
|
||||
let canLose = false;
|
||||
let battleType: number | undefined;
|
||||
let menuHeader: number | undefined;
|
||||
const temp = (offset: number, opcode: number) => {
|
||||
if (!this.tempTrainer)
|
||||
throw new UnsupportedScript(offset, opcode, " (no map trainer)");
|
||||
@@ -1471,9 +1508,10 @@ export class ScriptRun {
|
||||
break;
|
||||
}
|
||||
case 0x2c: {
|
||||
// checkpoke reads wPartySpecies, where an Egg is EGG.
|
||||
const species = byte();
|
||||
value = Number(
|
||||
this.state.party.some((mon) => mon.species === species),
|
||||
this.state.party.some((mon) => !mon.egg && mon.species === species),
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -1572,13 +1610,120 @@ export class ScriptRun {
|
||||
picture = false;
|
||||
yield synchronous({ kind: "picture", species: 0 });
|
||||
break;
|
||||
case 0x28: {
|
||||
case 0x28:
|
||||
// addcellnum: TRUE when AddPhoneNumber fails.
|
||||
value = Number(!this.addPhoneNumber(byte()));
|
||||
break;
|
||||
case 0x97: {
|
||||
// askforphonenumber: Yes/No for the text already shown, then
|
||||
// AddPhoneNumber.
|
||||
const answer = yield { kind: "yesno", pages: take() };
|
||||
const contact = byte();
|
||||
const phone = this.state.phone;
|
||||
value = Number(
|
||||
!phone.includes(contact) && phone.length < CONTACT_LIST_SIZE,
|
||||
);
|
||||
if (value) phone.push(contact);
|
||||
value = !answer
|
||||
? PHONE_CONTACT_REFUSED
|
||||
: this.addPhoneNumber(contact)
|
||||
? PHONE_CONTACT_GOT
|
||||
: PHONE_CONTACTS_FULL;
|
||||
break;
|
||||
}
|
||||
case 0x43: {
|
||||
// gettrainername: GetTrainerName (the trainer's own name) into a
|
||||
// string buffer.
|
||||
const classId = byte(),
|
||||
id = byte();
|
||||
this.strings[buffer(byte())] = new TrainerData(r).trainer(classId, id).name;
|
||||
break;
|
||||
}
|
||||
case 0x4f:
|
||||
// loadmenu: the header for the next menu command.
|
||||
menuHeader = near();
|
||||
break;
|
||||
case 0x50:
|
||||
break; // closewindow: the native menu has already closed.
|
||||
case 0x58: {
|
||||
// _2dmenu over the loaded header's MenuData: its strings, the
|
||||
// default option, and wMenuCursorPosition (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 shape = r.byte(menuData + 1),
|
||||
count = (shape >> 4) * (shape & 0x0f);
|
||||
if (r.word(menuData + 7))
|
||||
throw new UnsupportedScript(offset, opcode, " (menu display routine)");
|
||||
let entry = r.address(r.byte(menuData + 3), r.word(menuData + 4));
|
||||
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),
|
||||
};
|
||||
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= items.length)
|
||||
throw new Error("Invalid menu choice.");
|
||||
value = answer < 0 ? 0 : answer + 1;
|
||||
break;
|
||||
}
|
||||
case 0x96: {
|
||||
// trade: NPCTrade.
|
||||
const index = byte();
|
||||
yield* this.npcTrade(index, write, take);
|
||||
break;
|
||||
}
|
||||
case 0x30: {
|
||||
// checkpokemail: SelectMonFromParty, its MAIL against the text
|
||||
// (up to the text's terminator, at most two lines), then the
|
||||
// Pokémon leaves the party unless it is the last that can fight.
|
||||
const expected = readString(r, near(), this.text());
|
||||
const slot = yield* this.selectMonFromParty(take());
|
||||
const mon = this.state.party[slot];
|
||||
if (!mon) value = POKEMAIL_REFUSED;
|
||||
else if (!mon.item || !data.itemIsMail(mon.item) || !mon.mail) value = POKEMAIL_NO_MAIL;
|
||||
else if (!mon.mail.message.startsWith(expected.slice(0, 2 * 16 + 1))) value = POKEMAIL_WRONG_MAIL;
|
||||
else if (!this.state.party.some((other, index) => index !== slot && other.hp > 0))
|
||||
value = POKEMAIL_LAST_MON;
|
||||
else {
|
||||
this.state.party.splice(slot, 1);
|
||||
value = POKEMAIL_CORRECT;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 0x2e: {
|
||||
// giveegg: GiveEgg. TryAddMonToParty generates the base form
|
||||
// (GetPreEvolution twice) at the level, outside battle; the slot
|
||||
// then holds the given species as an EGG named "EGG", with the
|
||||
// egg cycles as happiness and no HP. The Pokédex is unchanged.
|
||||
const species = byte(),
|
||||
level = byte();
|
||||
if (this.state.party.length >= PARTY_LENGTH)
|
||||
throw new UnsupportedScript(offset, opcode, " (full party)");
|
||||
const base = this.preEvolution(this.preEvolution(species));
|
||||
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: base,
|
||||
level,
|
||||
item: 0,
|
||||
otId: player.id,
|
||||
ot: this.host.text().player,
|
||||
dvs,
|
||||
caughtData: 0,
|
||||
});
|
||||
Object.assign(mon, {
|
||||
species,
|
||||
egg: true,
|
||||
nickname: readString(r, SYMBOLS.stringEgg, this.text()),
|
||||
happiness: data.baseData(base).eggCycles,
|
||||
hp: 0,
|
||||
});
|
||||
this.state.party.push(mon);
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
case 0x29: {
|
||||
@@ -2177,7 +2322,7 @@ export class ScriptRun {
|
||||
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
|
||||
private partyMove(move: number): number {
|
||||
return this.state.party.findIndex(
|
||||
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
|
||||
(mon) => !mon.egg && mon.moves.includes(move as never),
|
||||
);
|
||||
}
|
||||
/** GetPartyNickname: wCurPartyMon's nickname into wStringBuffer1-3. */
|
||||
@@ -2267,6 +2412,115 @@ export class ScriptRun {
|
||||
};
|
||||
}
|
||||
|
||||
/** NPCTrade: the trader's request, the Pokémon to give from the party
|
||||
* menu, then TryAddMonToParty's Pokémon (two DV draws, both replaced)
|
||||
* with the trade's nickname, OT, ID, DVs, item and gift caught data. The
|
||||
* trade animation is not presented. */
|
||||
private *npcTrade(
|
||||
index: number,
|
||||
write: (address: number) => void,
|
||||
take: () => string[],
|
||||
): Generator<ScriptEffect, void, Input> {
|
||||
const r = this.reader,
|
||||
data = this.host.data,
|
||||
party = this.state.party;
|
||||
if (index >= NUM_NPC_TRADES) throw new Error("Invalid trade.");
|
||||
const entry = OVERWORLD.NPCTrades + index * NPCTRADE_STRUCT_LENGTH;
|
||||
const dialogSet = r.byte(entry),
|
||||
give = r.byte(entry + 1),
|
||||
get = r.byte(entry + 2),
|
||||
gender = r.byte(entry + 30);
|
||||
// GetTradeMonNames: GetBasePokemonName drops Nidoran's symbols; the
|
||||
// requested Pokémon's name takes the gender the trader wants.
|
||||
const baseName = (species: number) => data.speciesName(species).replace(/[♂♀]/gu, "");
|
||||
this.strings[RAM.stringBuffer2] = baseName(get);
|
||||
this.strings[RAM.monOrItemNameBuffer] = baseName(give);
|
||||
this.strings[RAM.stringBuffer1] =
|
||||
baseName(give) + (gender === TRADE_GENDER_MALE ? "♂" : gender === TRADE_GENDER_FEMALE ? "♀" : "");
|
||||
const tradeText = (dialog: number) => {
|
||||
const pointer = OVERWORLD.TradeTexts + (dialog * 4 + dialogSet) * 2;
|
||||
write(r.near(pointer, r.bank(pointer)));
|
||||
};
|
||||
const flag = 1 << index;
|
||||
if (this.state.tradeFlags & flag) return tradeText(TRADE_DIALOG_AFTER);
|
||||
tradeText(TRADE_DIALOG_INTRO);
|
||||
if (!(yield { kind: "yesno", pages: take() })) return tradeText(TRADE_DIALOG_CANCEL);
|
||||
const slot = yield* this.selectMonFromParty(take(), PARTYMENUACTION_GIVE_MON);
|
||||
if (slot < 0) return tradeText(TRADE_DIALOG_CANCEL);
|
||||
const mon = party[slot];
|
||||
const monGender = data.gender(mon.species, mon.dvs);
|
||||
if (
|
||||
mon.egg ||
|
||||
mon.species !== give ||
|
||||
(gender === TRADE_GENDER_MALE && monGender !== "male") ||
|
||||
(gender === TRADE_GENDER_FEMALE && monGender !== "female")
|
||||
)
|
||||
return tradeText(TRADE_DIALOG_WRONG);
|
||||
this.state.tradeFlags |= flag;
|
||||
write(OVERWORLD.NPCTradeCableText);
|
||||
// DoNPCTrade: RemoveMonFromPartyOrBox (with any MAIL), then the new one.
|
||||
party.splice(slot, 1);
|
||||
this.host.random();
|
||||
this.host.random();
|
||||
const traded = generatePartyMon(data, {
|
||||
species: get,
|
||||
level: mon.level,
|
||||
item: r.byte(entry + 16),
|
||||
otId: r.byte(entry + 17) | (r.byte(entry + 18) << 8),
|
||||
ot: readString(r, entry + 19, this.text()),
|
||||
dvs: (r.byte(entry + 14) << 8) | r.byte(entry + 15),
|
||||
// SetGiftPartyMonCaughtData: a girl's dialog set marks CAUGHT_BY_GIRL.
|
||||
caughtData: LANDMARK_GIFT | (dialogSet >= TRADE_DIALOGSET_GIRL ? 0x80 : 0),
|
||||
});
|
||||
traded.nickname = readString(r, entry + 3, this.text());
|
||||
party.push(traded);
|
||||
this.state.pokedex.seen.add(get);
|
||||
this.state.pokedex.caught.add(get);
|
||||
write(OVERWORLD.TradedForText);
|
||||
tradeText(TRADE_DIALOG_COMPLETE);
|
||||
}
|
||||
/** SelectMonFromParty (or SelectTradeOrDayCareMon with another party
|
||||
* menu prompt). Answers a party index, or -1 for B. */
|
||||
private *selectMonFromParty(
|
||||
pages: string[],
|
||||
action = PARTYMENUACTION_CHOOSE_POKEMON,
|
||||
): Generator<ScriptEffect, number, Input> {
|
||||
const r = this.reader,
|
||||
party = this.state.party,
|
||||
strings = MENU.PartyMenuStrings + action * 2;
|
||||
const prompt = readString(r, r.near(strings, r.bank(strings)), this.text());
|
||||
const answer = yield {
|
||||
kind: "menu",
|
||||
pages: [...pages, prompt],
|
||||
items: party.map((mon) => mon.nickname),
|
||||
details: party.map((mon) => partyMonDetails(mon)),
|
||||
selected: 0,
|
||||
};
|
||||
if (typeof answer !== "number" || !Number.isInteger(answer) || answer >= party.length)
|
||||
throw new Error("Invalid party choice.");
|
||||
return answer < 0 ? -1 : answer;
|
||||
}
|
||||
/** AddPhoneNumber: false when the contact is already listed or no slot
|
||||
* is free. GetRemainingSpaceInPhoneList keeps room for Mom and Elm until
|
||||
* they are registered. */
|
||||
private addPhoneNumber(contact: number): boolean {
|
||||
const phone = this.state.phone;
|
||||
if (phone.includes(contact)) return false;
|
||||
const reserved = PERMANENT_NUMBERS.filter(
|
||||
(permanent) => permanent !== contact && !phone.includes(permanent),
|
||||
).length;
|
||||
if (phone.length >= CONTACT_LIST_SIZE - reserved) return false;
|
||||
phone.push(contact);
|
||||
return true;
|
||||
}
|
||||
/** GetPreEvolution: the first species whose evolutions lead to this
|
||||
* one, or the species itself. */
|
||||
private preEvolution(species: number): number {
|
||||
const data = this.host.data;
|
||||
for (let from = 1; from <= 251; from++)
|
||||
if (data.evolutions(from).some((evolution) => evolution.species === species)) return from;
|
||||
return species;
|
||||
}
|
||||
/** RandomRange: a byte below `range`, rejecting the draws past its
|
||||
* largest multiple, as Script_random also does. */
|
||||
private randomRange(range: number) {
|
||||
|
||||
@@ -66,7 +66,6 @@ const BOX_SWITCH = 0,
|
||||
/** DelayFrames after a Bill's PC message, in overworld ticks (two frames). */
|
||||
const MESSAGE_TICKS = 25,
|
||||
RELEASED_TICKS = 40;
|
||||
const EGG = 0xfd;
|
||||
|
||||
/** A pack entry and where it came from, for TossItem's wCurItemQuantity. */
|
||||
interface PackSlot extends ItemSlot {
|
||||
@@ -287,7 +286,7 @@ export class PcSession {
|
||||
);
|
||||
if (slot < 0) return;
|
||||
const mon = party[slot];
|
||||
if (mon.species === EGG) {
|
||||
if (mon.egg) {
|
||||
yield* this.print(PC["MailboxPC.MailEggText"]);
|
||||
continue;
|
||||
}
|
||||
@@ -698,7 +697,7 @@ export class PcSession {
|
||||
}
|
||||
if (action !== SUBMENU_RELEASE) break;
|
||||
if (!(yield* this.partyMonMayLeave(index))) break;
|
||||
if (mon.species === EGG) {
|
||||
if (mon.egg) {
|
||||
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
|
||||
break;
|
||||
}
|
||||
@@ -751,7 +750,7 @@ export class PcSession {
|
||||
continue;
|
||||
}
|
||||
if (action !== SUBMENU_RELEASE) break;
|
||||
if (mon.species === EGG) {
|
||||
if (mon.egg) {
|
||||
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
restoredPP,
|
||||
} from "../battle/battle";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
import { calcStats, maxPP, type PartyMon } from "../party/pokemon";
|
||||
import { calcStats, maxPP, partyMonDetails, type PartyMon } from "../party/pokemon";
|
||||
import type { GameTime } from "../world/game-time";
|
||||
import { PartyEvolution, evoStoneCompatible } from "./evolution";
|
||||
import {
|
||||
@@ -60,7 +60,6 @@ const ITEM_MENUS: Record<string, ItemAction[]> = {
|
||||
"MenuHeader_HoldableItem.MenuData": ["give", "toss", "quit"],
|
||||
};
|
||||
const {
|
||||
EGG,
|
||||
ITEMMENU_CURRENT,
|
||||
ITEMMENU_PARTY,
|
||||
ITEMMENU_CLOSE,
|
||||
@@ -435,14 +434,14 @@ export class StartMenuRun {
|
||||
party.map((mon) =>
|
||||
// PlacePartyMonEvoStoneCompatibility replaces the HP bar.
|
||||
action === C.PARTYMENUACTION_EVO_STONE
|
||||
? mon.species === EGG
|
||||
? mon.egg
|
||||
? ""
|
||||
: this.string(
|
||||
evoStoneCompatible(r, mon.species, item)
|
||||
? MENU["PlacePartyMonEvoStoneCompatibility.string_able"]
|
||||
: MENU["PlacePartyMonEvoStoneCompatibility.string_not_able"],
|
||||
)
|
||||
: `:L${mon.level} HP ${mon.hp}/${mon.stats[0]}${mon.hp ? statusName(mon.status) : " FNT"}`,
|
||||
: partyMonDetails(mon),
|
||||
),
|
||||
// InitPartyMenuWithCancel: the last row chosen, if still listed.
|
||||
this.cursors.party >= 1 && this.cursors.party <= party.length
|
||||
@@ -467,7 +466,7 @@ export class StartMenuRun {
|
||||
/** GetMonSubmenuItems: field moves the Pokémon knows, then STATS,
|
||||
* SWITCH, MOVE, ITEM or MAIL, and CANCEL if there is room. */
|
||||
private monSubmenuItems(mon: PartyMon) {
|
||||
if (mon.species === EGG)
|
||||
if (mon.egg)
|
||||
return [MONMENUITEM_STATS, MONMENUITEM_SWITCH, MONMENUITEM_CANCEL];
|
||||
const r = this.reader,
|
||||
items: number[] = [];
|
||||
@@ -621,7 +620,7 @@ export class StartMenuRun {
|
||||
/** GiveTakePartyMonItem: GIVE opens the pack, TAKE returns the item. */
|
||||
private *giveTakePartyMonItem(slot: number): Gen {
|
||||
const mon = this.state.party[slot];
|
||||
if (mon.species === EGG) return;
|
||||
if (mon.egg) return;
|
||||
const choice = yield* this.menu(
|
||||
[mon.nickname],
|
||||
this.stringList(MENU["GiveTakeItemMenuData.Items"] + 2, 2),
|
||||
@@ -751,7 +750,7 @@ export class StartMenuRun {
|
||||
* or next party member's moves, skipping Eggs, unless a move is being
|
||||
* moved. */
|
||||
private *managePokemonMoves(slot: number): Gen {
|
||||
if (this.state.party[slot].species === EGG) return;
|
||||
if (this.state.party[slot].egg) return;
|
||||
const where = this.string(MENU.String_MoveWhere);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
@@ -807,7 +806,7 @@ export class StartMenuRun {
|
||||
private cycleMoveScreen(slot: number, direction: "left" | "right") {
|
||||
const party = this.state.party;
|
||||
let current = slot;
|
||||
const egg = (index: number) => party[index].species === EGG;
|
||||
const egg = (index: number) => party[index].egg;
|
||||
let step: "left" | "right" | "done" = direction;
|
||||
for (let guard = 0; step !== "done" && guard < 64; guard++) {
|
||||
if (step === "right") {
|
||||
@@ -1021,7 +1020,7 @@ export class StartMenuRun {
|
||||
for (;;) {
|
||||
const slot = yield* this.partyMenu(PARTYMENUACTION_GIVE_ITEM);
|
||||
if (slot < 0) return;
|
||||
if (this.state.party[slot].species === EGG) {
|
||||
if (this.state.party[slot].egg) {
|
||||
yield* this.print(MENU["GiveItem.AnEggCantHoldAnItemText"]);
|
||||
continue;
|
||||
}
|
||||
@@ -1152,7 +1151,7 @@ export class StartMenuRun {
|
||||
case MENU.SacredAshEffect:
|
||||
// _SacredAsh: SacredAshScript heals the party once the menu closes,
|
||||
// if a Pokémon that is not an Egg has fainted (CheckAnyFaintedMon).
|
||||
if (!this.state.party.some((mon) => mon.species !== EGG && !mon.hp))
|
||||
if (!this.state.party.some((mon) => !mon.egg && !mon.hp))
|
||||
return false;
|
||||
this.useDisposableItem(use);
|
||||
this.deferred = MENU.SacredAshScript;
|
||||
@@ -1275,7 +1274,7 @@ export class StartMenuRun {
|
||||
): Gen<number> {
|
||||
const slot = yield* this.partyMenu(action, undefined, item);
|
||||
if (slot < 0) return -1;
|
||||
if (this.state.party[slot].species === EGG) {
|
||||
if (this.state.party[slot].egg) {
|
||||
yield* this.print(BATTLE.ItemCantUseOnEggText);
|
||||
return -1;
|
||||
}
|
||||
@@ -1305,7 +1304,7 @@ export class StartMenuRun {
|
||||
}
|
||||
private changeHappiness(slot: number, action: number) {
|
||||
const mon = this.state.party[slot];
|
||||
if (mon.species === EGG) return;
|
||||
if (mon.egg) return;
|
||||
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
|
||||
mon.happiness = changedHappiness(
|
||||
mon.happiness,
|
||||
@@ -1563,13 +1562,3 @@ interface FieldItemUse {
|
||||
index: number;
|
||||
}
|
||||
|
||||
const STATUS_NAMES: [number, string][] = [
|
||||
[0x08, " PSN"],
|
||||
[0x10, " BRN"],
|
||||
[0x20, " FRZ"],
|
||||
[0x40, " PAR"],
|
||||
];
|
||||
function statusName(status: number) {
|
||||
if (status & 7) return " SLP";
|
||||
return STATUS_NAMES.find(([bit]) => status & bit)?.[1] ?? "";
|
||||
}
|
||||
+20
-1
@@ -72,6 +72,7 @@ import {
|
||||
} from "../party/mail";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
import { NUM_TMS_HMS } from "../rom/pokemon";
|
||||
import {
|
||||
copyStorage,
|
||||
emptyStorage,
|
||||
@@ -165,6 +166,8 @@ export interface NativeSave {
|
||||
repel: number;
|
||||
coins: number;
|
||||
blueCardBalance: number;
|
||||
/** wTradeFlags: the in-game trades done, one bit per NPCTRADE_*. */
|
||||
tradeFlags: number;
|
||||
/** The option bytes, saved with the game as sOptions is. */
|
||||
options: GameOptions;
|
||||
}
|
||||
@@ -3852,7 +3855,7 @@ export class GameWorld {
|
||||
/** CheckPartyMove: the first party member (not an Egg) knowing `move`. */
|
||||
private partyMove(move: number): number {
|
||||
return this.state.party.findIndex(
|
||||
(mon) => mon.species !== OW.EGG && mon.moves.includes(move as never),
|
||||
(mon) => !mon.egg && mon.moves.includes(move as never),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4280,6 +4283,7 @@ export class GameWorld {
|
||||
mailbox: state.mailbox.map(copyMail),
|
||||
repel: state.repel,
|
||||
coins: state.coins,
|
||||
tradeFlags: state.tradeFlags,
|
||||
blueCardBalance: state.blueCardBalance,
|
||||
options: { ...state.options },
|
||||
};
|
||||
@@ -4400,6 +4404,7 @@ export class GameWorld {
|
||||
!integer(save.registeredItem.item, 0, 255) ||
|
||||
!integer(save.repel, 0, 255) ||
|
||||
!integer(save.coins, 0, C.MAX_COINS) ||
|
||||
!integer(save.tradeFlags, 0, 0x7f) ||
|
||||
!integer(save.blueCardBalance, 0, 255) ||
|
||||
!validOptions(save.options)
|
||||
) {
|
||||
@@ -4471,6 +4476,7 @@ export class GameWorld {
|
||||
mailbox: save.mailbox.map(copyMail),
|
||||
repel: save.repel,
|
||||
coins: save.coins,
|
||||
tradeFlags: save.tradeFlags,
|
||||
blueCardBalance: save.blueCardBalance,
|
||||
options: { ...save.options },
|
||||
specialPhoneCall: save.specialPhoneCall,
|
||||
@@ -4589,6 +4595,18 @@ export function migrateSave(
|
||||
coins: 0,
|
||||
blueCardBalance: 0,
|
||||
}),
|
||||
// Packs saved before the TM/HM pocket had no TMs or HMs.
|
||||
...(typeof save.bag === "object" &&
|
||||
save.bag !== null &&
|
||||
!Object.hasOwn(save.bag, "tmsHms")
|
||||
? {
|
||||
bag: {
|
||||
...(save.bag as object),
|
||||
tmsHms: Array<number>(NUM_TMS_HMS).fill(0),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(!Object.hasOwn(save, "tradeFlags") && { tradeFlags: 0 }),
|
||||
...(!Object.hasOwn(save, "intro") && { intro: false }),
|
||||
...(!Object.hasOwn(save, "gameTime") && { gameTime: emptyGameTime() }),
|
||||
...(!Object.hasOwn(save, "options") && { options: defaultOptions() }),
|
||||
@@ -4641,6 +4659,7 @@ export function migrateSave(
|
||||
repel: 0,
|
||||
coins: 0,
|
||||
blueCardBalance: 0,
|
||||
tradeFlags: 0,
|
||||
intro: false,
|
||||
gameTime: emptyGameTime(),
|
||||
options: defaultOptions(),
|
||||
|
||||
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
|
||||
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
|
||||
const SPAWN_NEW_BARK = 14,
|
||||
SPAWN_CHERRYGROVE = 15,
|
||||
SPAWN_VIOLET = 16;
|
||||
SPAWN_AZALEA = 18;
|
||||
const ENGINE_STORMBADGE = 32,
|
||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
|
||||
const FLY = 0x13,
|
||||
@@ -203,10 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
|
||||
it("refuses to fly to a flypoint that is not ported", () => {
|
||||
const { world } = fixture([FLY], ROUTE_29, 50, 10);
|
||||
world.state.flags.add(ENGINE_STORMBADGE);
|
||||
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_VIOLET);
|
||||
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_AZALEA);
|
||||
useMove(world, "FLY");
|
||||
world.moveCursor("down"); // wraps to Violet City
|
||||
expect(flyMap(world)?.name).toBe("VIOLET CITY");
|
||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
|
||||
world.moveCursor("down");
|
||||
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
|
||||
world.choose("a");
|
||||
until(world);
|
||||
expect(world.notice).toBe("This destination is not ported yet.");
|
||||
|
||||
+3
-3
@@ -15,8 +15,7 @@ const romPath =
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const POKEMON_CENTER = "26:5",
|
||||
CYNDAQUIL = 155,
|
||||
PIDGEY = 16,
|
||||
EGG = 0xfd;
|
||||
PIDGEY = 16;
|
||||
const POTION = 0x12,
|
||||
BERRY = 0xad,
|
||||
FLOWER_MAIL = 0x9e,
|
||||
@@ -282,7 +281,8 @@ describe.skipIf(!existsSync(romPath))("MAIL", () => {
|
||||
];
|
||||
world.state.party.push({
|
||||
...structuredClone(world.state.party[1]),
|
||||
species: EGG,
|
||||
egg: true,
|
||||
nickname: "EGG",
|
||||
});
|
||||
mailbox(world);
|
||||
expect(menuItems(world)).toEqual([
|
||||
|
||||
@@ -517,14 +517,16 @@ describe.skipIf(!existsSync(romPath))(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
const route = world.assets.map("26:1");
|
||||
const edge = Array.from({ length: route.width }, (_, x) => x).find(
|
||||
// Violet City's south edge leads to Route 32, which stays closed.
|
||||
const city = world.assets.map("10:5"),
|
||||
bottom = city.height - 1;
|
||||
const edge = Array.from({ length: city.width }, (_, x) => x).find(
|
||||
(x) =>
|
||||
world.assets.permission(world.assets.collision(route, x, 0)) === 0,
|
||||
world.assets.permission(world.assets.collision(city, x, bottom)) === 0,
|
||||
)!;
|
||||
world.restore({ ...world.save(), map: "26:1", x: edge, y: 0 });
|
||||
expect(world.move("up")).toBe(false);
|
||||
expect(world.notice).toBe("Route 31 is not ported yet.");
|
||||
world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.notice).toBe("Route 32 is not ported yet.");
|
||||
const {
|
||||
scenes,
|
||||
phone,
|
||||
|
||||
+143
-1
@@ -14,7 +14,23 @@ const NEW_BARK = "24:4",
|
||||
ROUTE_29 = "24:3",
|
||||
CHERRYGROVE = "26:3",
|
||||
ROUTE_30 = "26:1",
|
||||
MR_POKEMONS_HOUSE = "26:10";
|
||||
MR_POKEMONS_HOUSE = "26:10",
|
||||
CHERRYGROVE_CENTER = "26:5",
|
||||
ROUTE_31 = "26:2",
|
||||
ROUTE_31_GATE = "26:11",
|
||||
VIOLET = "10:5",
|
||||
VIOLET_CENTER = "10:10",
|
||||
VIOLET_GYM = "10:7",
|
||||
SPROUT_1F = "3:1",
|
||||
SPROUT_2F = "3:2",
|
||||
SPROUT_3F = "3:3";
|
||||
const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||
EVENT_GOT_HM05_FLASH = 0x14,
|
||||
EVENT_BEAT_FALKNER = 0x2bd,
|
||||
EVENT_GOT_TM31_MUD_SLAP = 0x8,
|
||||
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
|
||||
EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER = 0x48c,
|
||||
ENGINE_ZEPHYRBADGE = 0x1b;
|
||||
|
||||
/** The acceptance run for PLAN section 2: a new game played from the
|
||||
* introduction to the catching tutorial through the world's own inputs,
|
||||
@@ -196,6 +212,89 @@ describe.skipIf(!existsSync(romPath))(
|
||||
expect(world.save()).toEqual(saved);
|
||||
};
|
||||
|
||||
const lead = () => world.state.party[0];
|
||||
/** PokecenterNurseScript from in front of the counter, then back out. */
|
||||
const heal = (center: string, city: string) => {
|
||||
enter(center, "up");
|
||||
walk(3, 3);
|
||||
world.move("up"); // the counter: this only turns the player
|
||||
world.interact();
|
||||
play();
|
||||
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
|
||||
exit(city);
|
||||
};
|
||||
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
||||
* the lead reaches `level`. `rest` heals and comes back here. */
|
||||
const train = (level: number, rest: () => void) => {
|
||||
const map = world.mapId,
|
||||
grass = world.assets.grassCollisions();
|
||||
const isGrass = (x: number, y: number) =>
|
||||
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
|
||||
grass.includes(world.assets.collision(world.map, x, y));
|
||||
for (let round = 0; round < 2000 && lead().level < level; round++) {
|
||||
if (lead().hp * 2 < lead().stats[0]) {
|
||||
rest();
|
||||
expect(world.mapId).toBe(map);
|
||||
}
|
||||
const pairs: { x: number; y: number; length: number }[] = [];
|
||||
for (let y = 0; y < world.map.height; y++)
|
||||
for (let x = 0; x < world.map.width; x++)
|
||||
if (isGrass(x, y) && isGrass(x + 1, y))
|
||||
try {
|
||||
pairs.push({ x, y, length: findPath(world, x, y).length });
|
||||
} catch {
|
||||
/* unreachable */
|
||||
}
|
||||
pairs.sort((a, b) => a.length - b.length);
|
||||
const { x, y } = pairs[0];
|
||||
walk(x, y);
|
||||
walk(x + 1, y);
|
||||
}
|
||||
expect(lead().level).toBeGreaterThanOrEqual(level);
|
||||
};
|
||||
|
||||
/** Take the stairs (or another warp) to `target`, trying each. */
|
||||
const stairs = (target: string) => {
|
||||
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId === target) return;
|
||||
}
|
||||
throw new Error(`No way from ${world.mapId} to ${target}`);
|
||||
};
|
||||
/** Find a way through warps among `maps` to `target`, as a player
|
||||
* trying staircases would: depth first, backing out of dead ends. */
|
||||
const route = (target: string, maps: string[], depth = 6): boolean => {
|
||||
if (world.mapId === target) return true;
|
||||
if (!depth) return false;
|
||||
const here = world.save();
|
||||
for (const warp of world.map.warps) {
|
||||
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const from = world.mapId;
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId !== from && route(target, maps, depth - 1)) return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||
const restInViolet = () => {
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
heal(VIOLET_CENTER, VIOLET);
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
log.length = 0;
|
||||
});
|
||||
@@ -317,5 +416,48 @@ describe.skipIf(!existsSync(romPath))(
|
||||
expect(save.rivalName).toBe("SILVER");
|
||||
expect(save.intro).toBe(false);
|
||||
});
|
||||
|
||||
it("trains and walks Route 30 and Route 31 to Violet City", () => {
|
||||
traceFailures();
|
||||
const restInCherrygrove = () => {
|
||||
cross("left", CHERRYGROVE);
|
||||
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
||||
cross("right", ROUTE_29);
|
||||
};
|
||||
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
||||
cross("right", ROUTE_29);
|
||||
train(10, restInCherrygrove);
|
||||
cross("left", CHERRYGROVE);
|
||||
heal(CHERRYGROVE_CENTER, CHERRYGROVE);
|
||||
cross("up", ROUTE_30);
|
||||
cross("up", ROUTE_31);
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
// Mom worries when the player has not come home since the Egg; the
|
||||
// call rings on a step after Route 31's callback queued it.
|
||||
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
|
||||
exit(VIOLET, "left");
|
||||
expect(world.state.flags.size).toBeGreaterThan(0);
|
||||
reload();
|
||||
});
|
||||
|
||||
it("trains on Route 31 and earns Flash from Elder Li in Sprout Tower", () => {
|
||||
traceFailures();
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
train(14, restInViolet);
|
||||
restInViolet();
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
enter(SPROUT_1F, "up");
|
||||
expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true);
|
||||
walk(10, 3);
|
||||
world.move("up"); // Elder Li: this only turns the player
|
||||
world.interact();
|
||||
play();
|
||||
const pages = log.join("\n").replace(/\n/g, " ");
|
||||
expect(pages).toContain("FLASH");
|
||||
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
||||
reload();
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -316,7 +316,7 @@ describe.skipIf(!existsSync(romPath))("the start menu", () => {
|
||||
it("shows the other party members' moves with left and right, skipping Eggs", () => {
|
||||
const world = fixture();
|
||||
const party = world.state.party;
|
||||
party.push({ ...structuredClone(party[1]), species: 0xfd }); // EGG
|
||||
party.push({ ...structuredClone(party[1]), egg: true, nickname: "EGG" });
|
||||
open(world);
|
||||
pick(world, "POKéMON");
|
||||
pick(world, "CYNDAQUIL");
|
||||
|
||||
Reference in new issue
Block a user