Read TileCollisionStdScripts from the ROM so the Pokémon Center PC runs PokemonCenterPC: Bill's PC (deposit, withdraw, release, change and name boxes), the player's item storage (withdraw, deposit, toss, mail box) and Oak's Pokédex rating. The house PC now opens the full player's PC menu. A catch with a full party goes to the current box through SendMonIntoBox; a full box keeps the ball, and filling it queues Bill's call as a memory script after the battle. Saves gain boxes, box names and PC items, migrated by absence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
451464f7c6
commit
3bdca0099e
21 files changed
+1999
-131
No files matched your search
@@ -12,9 +12,11 @@ North of Cherrygrove, Route 30's trainers battle you. Bug Catcher Don spots you
|
||||
|
||||
Mr. Pokémon gives you the Mystery Egg, and Professor Oak gives you the Pokédex. Your rival challenges you in Cherrygrove on the way back, and Elm's aide gives you Poké Balls. Back on Route 29, the Dude shows you how to catch Pokémon. From then on, open PACK in a wild battle to throw a Poké Ball. Caught Pokémon join your party, and you can give them a nickname.
|
||||
|
||||
Use Start or Select for Key Items, including the Bicycle when owned. The bedroom PC lets you arrange owned decorations, and the poster opens the Town Map.
|
||||
Use Start or Select for Key Items, including the Bicycle when owned. The poster opens the Town Map.
|
||||
|
||||
Route 31 and beyond, PC storage, full party and bag menus, other items in battle and audio are still being ported. Catching with six Pokémon in your party needs the PC and is refused for now. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
The Pokémon Center PC opens Bill's PC, your own PC and, once you have the Pokédex, Professor Oak's rating. Deposit, withdraw and release Pokémon, and switch or name boxes. Store, withdraw or toss items. With six Pokémon in your party, a catch goes to the current box. When that box fills up, Bill calls you. The bedroom PC also lets you arrange owned decorations.
|
||||
|
||||
Route 31 and beyond, full party and bag menus, moving Pokémon between boxes, the stats screen, other items in battle and audio are still being ported. Unported neighboring areas appear at the edges but cannot be entered yet.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ copy and a revision counter, so rollback and the minimap see the same terrain.
|
||||
Surf, Cut and Strength use the ROM scripts. Start/Select opens the Key Items
|
||||
menu, pausing overworld ticks while choosing the Bicycle. The bedroom PC edits
|
||||
decorations on the script draft and reloads the room when the menu commits.
|
||||
`scripts/pc.ts` runs the Pokémon Center and house PCs on the same draft, so a
|
||||
refused step leaves boxes and items unchanged. `party/storage.ts` holds the
|
||||
boxes and the PC's items. A `memscript` effect queues Bill's call (like
|
||||
`LoadMemScript`); the world keeps it in memory only and runs it before scene
|
||||
scripts.
|
||||
The wall/poster Town Map is a ROM-backed canvas presented by a script effect.
|
||||
|
||||
`TilesetAnimator` translates the ROM animation command tables, advancing two
|
||||
|
||||
+4
-3
@@ -30,9 +30,10 @@ These paths refer to the pinned [PRET source](https://github.com/pret/pokecrysta
|
||||
| `game/world/world.ts` | `engine/overworld/events.asm`, `player_movement.asm`, `warp_connection.asm`, `data/collision/collision_permissions.asm` | 30 Hz overworld ticks, 8-tick walks, 16-tick ledge jumps, directional mats, animated door/stair exits, connection coordinates, events, scenes and callbacks. NPC random walks and spins follow `HandleNPCStep`. Forced movement, Surf, Cut, Strength, cycling rules, pits and elevators implemented. Fourteen reference traces compare turn timing, actors and event flags; map-entry boundaries verify the completed destination. Bicycle access uses the Key Items menu. |
|
||||
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Resumable; yields text, choices, movement, emotes and pauses. Scenes, phone contacts, string buffers and object commands. Unknown commands and specials abort without committing. |
|
||||
| Movement interpreter | `macros/scripts/movement.asm`, `engine/overworld/movement.asm`, `engine/overworld/map_objects.asm` | Turns, steps, slides, jumps (two tiles with the original arc), sleeps, facing locks, visibility and emotes. `follow` takes one tile toward the leader's previous square. All movement opcodes decode, including teleport, fishing, skyfall, shake and flicker poses; Controlled reference scenes check spin, teleport, skyfall, fishing and flicker direction, visibility and lift. |
|
||||
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, the decoration PC and Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
||||
| Specials | `data/events/special_pointers.asm`, `engine/rtc/timeset.asm`, `engine/overworld/decorations.asm` | Selected by routine address from the ROM table: `SetDayOfWeek`, initial DST flags, `RestartMapMusic` (no audio yet), room decoration toggles, `PokemonCenterPC` and `PlayersHousePC` (Bill's, the player's and Oak's PCs), the Town Map, `HealParty`, `HealMachineAnim` (presentation only), `CheckPokerus` and the `StubbedTrainerRankings_Healings` stub. |
|
||||
| `game/scripts/pc.ts` | `engine/events/pokecenter_pc.asm`, `engine/pokemon/bills_pc_top.asm`, `engine/pokemon/bills_pc.asm`, `engine/events/prof_oaks_pc.asm`, `engine/events/std_collision.asm` | The Pokémon Center and house PCs: Bill's PC (deposit, withdraw, release, change and name boxes), item storage, the empty mail box and Oak's rating. Menu strings and texts come from the ROM; lists are native. Moving Pokémon, the stats screen and printing are not ported. |
|
||||
| `game/rom/pokemon.ts` | `data/pokemon/base_stats`, `evos_attacks.asm`, `data/moves/moves.asm`, `data/items/attributes.asm`, `engine/gfx/load_pics.asm` | Species, move and item names, base data, learnsets, move PP, item pockets, growth-rate exp and front pictures (`FixPicBank`, `PadFrontpic`). Starter pictures match rebuilt bytes. |
|
||||
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. PC boxes, TMs and HMs are refused. |
|
||||
| `game/party` | `engine/pokemon/move_mon.asm`, `engine/pokemon/caught_data.asm`, `engine/items/items.asm` | `GeneratePartyMonStats` for script gifts: DVs, `FillMoves`, PP, exp, `CalcMonStatC`, happiness and caught data. `PutItemInPocket`, key items and `TossItem`. `SendMonIntoBox`, `SendGetMonIntoFromBox` (deposit and withdraw), `RestorePPOfDepositedPokemon` and `CalcLevel` for the PC's boxes and item storage. TMs and HMs are refused. |
|
||||
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm`, `home/text.asm` | Literal/far text, RAM names and string buffers from native state, numbers of dots, and sound or pause cues (consumed). `text_asm` and other RAM reads are refused. |
|
||||
|
||||
Supported event opcodes are explicit in the interpreter switch. Audio commands consume their operands, but sound sequencing is pending; those opcodes do not imply complete audiovisual behavior. `pause` holds the current text or waits the original number of overworld ticks.
|
||||
@@ -87,7 +88,7 @@ A trainer sighting whose script is refused is not restarted until the player mov
|
||||
|
||||
## Next slice
|
||||
|
||||
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. Next: the PC and boxes, then party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
||||
Route 29, Cherrygrove, Route 30 and Mr. Pokémon's house are enabled with item balls, hidden items, fruit trees and the daily reset, the Pokémon Center nurse, the recorded whiteout spawn, map trainers, the first rival battle, Elm's special call, capture and the catching tutorial. NPCs roam. The PC and boxes are ported. Next: party, bag and Pokégear menus over the native models. Other specials stay refused until translated.
|
||||
|
||||
## Reflect and Light Screen
|
||||
|
||||
|
||||
+18
-5
@@ -116,15 +116,28 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- `GetPokeBallWobble` runs with `WobbleProbabilities` for each shake and picks the break-free message. Every throw uses the ball and, if the Pokémon breaks free, the turn. In trainer battles the ball is blocked, but the ball and turn are still used.
|
||||
- A caught Pokémon is reloaded as `LoadEnemyMon` does, which redraws the wild item roll (the item itself is kept). It keeps its current HP, status, item and DVs, and gets back the moves and full PP it appeared with. It joins the party with `SetCaughtData`, is registered as caught (with the new-data message once the Pokédex is owned) and can be nicknamed; a blank name keeps the species name. The Pokédex page itself is not presented yet.
|
||||
- Route 29's `Route29Tutorial1/2` and `CatchingTutorialDudeScript` run from the ROM. `catchtutorial` runs the battle as "DUDE": no Pokémon is sent out, the Dude's own Poké Ball always catches, and the Rattata is only seen, not kept. Messages advance on their own, as with `CatchTutorial`'s auto input.
|
||||
- A catch with six Pokémon in the party would go to the PC (`SendMonIntoBox`), which is not ported, so the battle is refused and discarded. Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
|
||||
- A catch with six Pokémon in the party goes to the current box (see the PC section below). Other balls (Kurt's balls, Park and Safari) and non-ball items in battle are not usable yet.
|
||||
- A local stress run of 2,160 wild battles (three starters, ten early species, six levels) caught 2,047 Pokémon without refusals.
|
||||
- Next: the PC and boxes, player switching and other items in battle, then party and bag menus.
|
||||
|
||||
## Implemented: the PC and boxes
|
||||
|
||||
- `TileCollisionStdScripts` is read from the ROM, so facing a `COLL_PC` tile (in any direction, as in the original) runs `PCScript`. Bookshelves, TVs and similar tiles no longer need the player to face up.
|
||||
- `PokemonCenterPC` checks for a Pokémon, then lists the PCs from `PokemonCenterPC.WhichPC`: Bill's PC and the player's PC, plus Oak's PC once the Pokédex is owned. The Hall of Fame PC is refused, since no Hall of Fame entry can exist yet.
|
||||
- Bill's PC: deposit (`BillsPC_CheckMail_PreventBlackout` keeps the last Pokémon and the last one that has not fainted; `RestorePPOfDepositedPokemon`), withdraw (`CalcLevel`, then `CalcMonStats` with stat experience, full HP and no status), release with confirmation ("Bye," with the species name), and change box: switch with the ROM's confirmation, name (a blank name keeps the old one) and print (only the empty-box message). Menus and messages come from the ROM; lists are presented natively with each Pokémon's level or each box's count.
|
||||
- The player's PC withdraws, deposits (from every pocket; untossable items move one at a time) and tosses items (up to 50 stacks, `RemoveItemFromPocket` slot rules), and shows the empty mail box. The house PC adds DECORATION, which closes the PC after a change.
|
||||
- Oak's PC rates the Pokédex from `OakRatings`.
|
||||
- Capture with a full party runs `SendMonIntoBox`: the Pokémon goes first in the current box, with restored PP and `SetBoxMonCaughtData`, can be nicknamed and shows `BallSentToPCText`. If the box is already full the ball is not used (`Ball_BoxIsFullMessage`). A catch that fills the box sets `BATTLERESULT_BOX_FULL`; `reloadmapafterbattle` then queues Bill's call (`Script_SpecialBillCall`) as a memory script that runs at the next `PlayerEvents` and is not saved, as in the original.
|
||||
- `VAR_BOXSPACE` reads the current box.
|
||||
- Save format 3 gained `storage` (boxes, the current box and box names; a null name is the ROM default) and `pcItems`, migrated by absence to empty storage.
|
||||
- Not ported: MOVE PKMN W/O MAIL and the stats screen (both show a notice and return to the menu), printing, a `givepoke` with a full party (refused; no open map reaches it) and the tutorial with a full party and box (the original repeats the unusable ball). Changing boxes does not show the save messages; the native session saves continuously.
|
||||
- Next: the introduction's naming and clock screens, player switching and other items in battle, then party and bag menus.
|
||||
|
||||
## Native battles: current state
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching, items other than balls in battle and the PC remain unfinished.
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage and type effectiveness. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, and rollback of unsupported effects. Real Route 29 encounters now use the ROM's encounter tables. Player switching and items other than balls in battle remain unfinished.
|
||||
|
||||
This is still not a complete new game. Full party/bag/Pokégear menus, PC storage, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the bedroom PC supports decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
|
||||
This is still not a complete new game. Full party/bag/Pokégear menus, story progression beyond Route 30 and Mr. Pokémon's house, and audio remain unfinished. The Key Items menu supports the Bicycle; the PCs store Pokémon, items and decorations. Surfing and terrain animation now have native implementations; later maps remain gated. Save slots, a Johto/Kanto atlas, Pokédex records, online Link, friends and real-world steps come later.
|
||||
|
||||
## 1. Complete the overworld foundation
|
||||
|
||||
@@ -191,7 +204,7 @@ their scripts and story progression are supported.
|
||||
- [x] Capture and the catching tutorial
|
||||
- [x] Battles: stats, damage, types, accuracy, criticals, status, turn order, fainting, experience, move learning, trainer AI, trainer send-outs and prize money
|
||||
- [ ] Player switching, and items other than balls, in battle
|
||||
- [ ] The PC and boxes (catching with a full party)
|
||||
- [x] The PC and boxes (catching with a full party)
|
||||
- [ ] The introduction's naming and clock screens
|
||||
- [ ] Controlled-seed comparisons with the original beyond damage and type effectiveness
|
||||
- [ ] Acceptance: one continuous playthrough of the opening quest with save/reload. The scenes are tested individually from fixtures so far.
|
||||
@@ -200,7 +213,7 @@ Introduce native player, party, Pokémon, inventory, money and quest models. Rea
|
||||
|
||||
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
|
||||
|
||||
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture and the catching tutorial run now. Next: the PC, then the introduction's naming and clock screens.
|
||||
Meeting Mom, Elm's introduction, the starter, the aide's Potion, Route 29, Cherrygrove, Route 30's trainers, Mr. Pokémon's house, the Cherrygrove rival battle, the return to Elm, capture, the catching tutorial and the PC run now. Next: the introduction's naming and clock screens.
|
||||
|
||||
Complete and validate the native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
|
||||
|
||||
|
||||
+45
-2
@@ -1,7 +1,7 @@
|
||||
# Recovered Claude session
|
||||
|
||||
Latest continuation: see **Overworld foundation completed: 2026-09-25** at
|
||||
the end of this file. Earlier sections are historical snapshots.
|
||||
Latest continuation: see **The PC and boxes: 2026-09-25** at the end of
|
||||
this file. Earlier sections are historical snapshots.
|
||||
|
||||
Recovered on 2026-09-23 from the local Claude Code transcript
|
||||
`~/.claude/projects/-Users-kawa-Crystal/4ac88f30-29e6-4fd5-a4b2-b6a318dd83b3.jsonl`.
|
||||
@@ -432,3 +432,46 @@ screens. Also open there: player switching and non-ball items in battle,
|
||||
controlled-seed comparisons beyond damage and type effectiveness, and a
|
||||
continuous opening-quest playthrough with save/reload. Later maps stay gated
|
||||
until their scripts and story are supported.
|
||||
|
||||
## The PC and boxes: 2026-09-25
|
||||
|
||||
The foundation above was committed and pushed as `451464f`. This increment
|
||||
continues PLAN section 2 with its first open item.
|
||||
|
||||
- `party/storage.ts`: 14 boxes of `BoxMon` (a party struct without status,
|
||||
HP and stats), the current box, box names (null is the ROM's "BOX" and
|
||||
number) and 50 PC item stacks. `SendGetMonIntoFromBox` deposit and
|
||||
withdraw, `RestorePPOfDepositedPokemon` and `CalcLevel` are shared helpers.
|
||||
`maxPP` (ComputeMaxPP) now also serves `HealParty`.
|
||||
- `scripts/pc.ts`: `PokemonCenterPC` and the house PC. The Pokémon Center PC
|
||||
opens Bill's PC (deposit, withdraw, release, change and name boxes) and the
|
||||
player's PC (item withdraw, deposit and toss, the empty mail box), plus
|
||||
Oak's rating once the Pokédex is owned. The house PC adds DECORATION. Menu
|
||||
strings, lists and texts are read from the ROM (`PC` table in the profile,
|
||||
verified by `check:reference`). The generic menu effect gained per-item
|
||||
`details` (levels, box counts). A `notice` effect covers unported
|
||||
options without aborting.
|
||||
- Facing tiles now use the ROM's `TileCollisionStdScripts` in any direction,
|
||||
as in the original (the old table only fired when facing up).
|
||||
- Capture with a full party: `SendMonIntoBox` puts the catch first in the
|
||||
current box, with nickname and `BallSentToPCText`. A full box keeps the ball
|
||||
(`Ball_BoxIsFullMessage`). Filling the box sets `BATTLERESULT_BOX_FULL`.
|
||||
`reloadmapafterbattle` then queues Bill's call through a new `memscript`
|
||||
effect (`LoadMemScript`). It is kept in memory only and runs before scene
|
||||
scripts.
|
||||
- Save format 3 gained `storage` and `pcItems`, migrated by absence.
|
||||
|
||||
Not ported: MOVE PKMN W/O MAIL and the stats screen (notices), printing,
|
||||
`givepoke` with a full party (still refused; unreachable in the open maps),
|
||||
the save messages after a box switch, and the tutorial with a full party and
|
||||
box (the original loops forever; refused).
|
||||
|
||||
Checks: 290 unit tests with the local ROM (130 passed, 160 skipped without
|
||||
it); 83 Chromium/WebKit browser tests passed, 19 skipped intentionally
|
||||
(including a new Pokémon Center PC flow in both browsers); native and
|
||||
reference builds; reference, vendor and release checks; `git diff --check`.
|
||||
Not committed.
|
||||
|
||||
Next (PLAN section 2): the introduction's naming and clock screens; player
|
||||
switching and non-ball items in battle; MOVE PKMN W/O MAIL and the stats
|
||||
screen with the party menu; a continuous opening playthrough.
|
||||
@@ -201,8 +201,8 @@ for (const [key, symbol] of Object.entries(ramNames)) {
|
||||
if (!match || Number(match[1]) !== ram.get(symbol))
|
||||
throw new Error(`Profile RAM mismatch: ${key} / ${symbol}`);
|
||||
}
|
||||
// BATTLE and OVERWORLD entries are keyed by their PRET labels.
|
||||
for (const table of ["BATTLE", "OVERWORLD"]) {
|
||||
// BATTLE, OVERWORLD and PC entries are keyed by their PRET labels.
|
||||
for (const table of ["BATTLE", "OVERWORLD", "PC"]) {
|
||||
const block = new RegExp(`export const ${table} = \\{([\\s\\S]*?)\\} as const;`).exec(profile)?.[1];
|
||||
if (!block) throw new Error(`${table} symbol table is missing.`);
|
||||
for (const [, quoted, bare, literal] of block.matchAll(/^\s+(?:"([^"]+)"|(\w+)):\s*(0x[0-9a-f]+),/gm)) {
|
||||
|
||||
+84
-25
@@ -2,10 +2,18 @@ import { RomReader } from "../rom/reader";
|
||||
import { BATTLE, RAM, SYMBOLS } from "../rom/profile";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import type { Bag } from "../party/bag";
|
||||
import {
|
||||
MONS_PER_BOX,
|
||||
currentBox,
|
||||
restorePP,
|
||||
type BoxMon,
|
||||
type PokemonStorage,
|
||||
} from "../party/storage";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
calcStats,
|
||||
levelForExp,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { readString, readTextEnd, type TextContext } from "../scripts/text";
|
||||
@@ -140,6 +148,8 @@ export interface BattleOptions {
|
||||
text: () => TextContext;
|
||||
/** The player's pack, needed to throw Poké Balls. */
|
||||
bag?: Bag;
|
||||
/** Bill's PC: a catch with a full party goes to the current box. */
|
||||
storage?: PokemonStorage;
|
||||
/** ENGINE_POKEDEX: a first catch shows the new-data message. */
|
||||
hasPokedex?: boolean;
|
||||
/** SetCaughtData's word for a Pokémon caught at this level. */
|
||||
@@ -148,6 +158,8 @@ export interface BattleOptions {
|
||||
tutorial?: boolean;
|
||||
}
|
||||
|
||||
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||
const BATTLERESULT_BOX_FULL = 0x80;
|
||||
const SUB1 = 1,
|
||||
SUB2 = 2,
|
||||
SUB3 = 3,
|
||||
@@ -844,12 +856,22 @@ export class Battle {
|
||||
this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
// With a full party the Pokémon would go to the current box; if that is
|
||||
// full too, the ball is not used (Ball_BoxIsFullMessage).
|
||||
const storage = this.options.storage;
|
||||
if (
|
||||
this.options.party.length >= PARTY_LENGTH &&
|
||||
(!storage || currentBox(storage).length >= MONS_PER_BOX)
|
||||
) {
|
||||
// The Dude's tutorial reaches this check too, and the original then
|
||||
// repeats the unusable ball forever (docs/bugs_and_glitches.md).
|
||||
if (tutorial) throw new UnsupportedBattle("The Dude's Poké Ball cannot be used with a full party and box.");
|
||||
if (!storage) throw new UnsupportedBattle("Missing PC boxes.");
|
||||
yield* this.say(BATTLE.BallBoxFullText);
|
||||
return false;
|
||||
}
|
||||
if (![C.MASTER_BALL, C.ULTRA_BALL, C.GREAT_BALL, C.POKE_BALL].includes(item as never))
|
||||
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
|
||||
// A full party sends the Pokémon into the current box (SendMonIntoBox).
|
||||
// The tutorial never keeps its Pokémon, so it does not reach the box.
|
||||
if (!tutorial && this.options.party.length >= PARTY_LENGTH)
|
||||
throw new UnsupportedBattle("PC boxes are not ported yet.");
|
||||
this.wildMon = 0;
|
||||
yield* this.say(BATTLE.ItemUsedText);
|
||||
|
||||
@@ -920,10 +942,15 @@ export class Battle {
|
||||
// NewPokedexEntry's Pokédex page is not presented yet.
|
||||
yield* this.say(BATTLE.NewDexDataText);
|
||||
}
|
||||
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
|
||||
// the wild Pokémon, then SetCaughtData.
|
||||
if (!this.options.caughtData) throw new UnsupportedBattle("Missing caught data.");
|
||||
const name = data.speciesName(species);
|
||||
if (this.options.party.length >= PARTY_LENGTH) {
|
||||
yield* this.sendToPC(species, level, name);
|
||||
this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
// TryAddMonToParty with wBattleMode set: GeneratePartyMonStats copies
|
||||
// the wild Pokémon, then SetCaughtData.
|
||||
const mon: PartyMon = {
|
||||
species,
|
||||
item: enemy.item,
|
||||
@@ -944,24 +971,59 @@ export class Battle {
|
||||
stats: calcStats(data, species, enemy.dvs, level),
|
||||
};
|
||||
this.options.party.push(mon);
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
yield* this.say(BATTLE.AskGiveNicknameText);
|
||||
if (yield { kind: "yesno", text: "" }) {
|
||||
// NamingScreen (NAME_MON): "<SPECIES>'S" / "NICKNAME?".
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (this.reader.byte(second++) !== 0x50);
|
||||
const context = this.context(),
|
||||
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
|
||||
title = readString(this.reader, second, context);
|
||||
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
|
||||
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
|
||||
throw new UnsupportedBattle("Invalid nickname.");
|
||||
// InitName: a name of zero or more spaces takes the species name.
|
||||
if (!/^ *$/.test(answer)) mon.nickname = answer;
|
||||
}
|
||||
mon.nickname = yield* this.askNickname(name);
|
||||
this.tossBall(item, slot);
|
||||
return true;
|
||||
}
|
||||
/** AskGiveNicknameText, then NamingScreen (NAME_MON) if accepted: the
|
||||
* chosen name, or the species name for a declined or blank one. */
|
||||
private *askNickname(name: string): Gen<string> {
|
||||
this.strings[RAM.stringBuffer1] = name;
|
||||
yield* this.say(BATTLE.AskGiveNicknameText);
|
||||
if (!(yield { kind: "yesno", text: "" })) return name;
|
||||
// "<SPECIES>'S" / "NICKNAME?".
|
||||
let second = SYMBOLS.nicknameStrings;
|
||||
while (this.reader.byte(second++) !== 0x50);
|
||||
const context = this.context(),
|
||||
suffix = readString(this.reader, SYMBOLS.nicknameStrings, context),
|
||||
title = readString(this.reader, second, context);
|
||||
const answer = yield { kind: "name", title: `${name}${suffix}\n${title}`, current: name, max: 10 };
|
||||
if (typeof answer !== "string" || answer.length > 10 || (answer && !NICKNAME_PATTERN.test(answer)))
|
||||
throw new UnsupportedBattle("Invalid nickname.");
|
||||
// InitName: a name of zero or more spaces takes the species name.
|
||||
return /^ *$/.test(answer) ? name : answer;
|
||||
}
|
||||
/** PokeBallEffect's .SendToPC: SendMonIntoBox puts the caught Pokémon
|
||||
* first in the current box (from wEnemyMon, with restored PP), then
|
||||
* SetBoxMonCaughtData. A box filled by it sets BATTLERESULT_BOX_FULL for
|
||||
* Bill's call; the nickname and BallSentToPCText follow. */
|
||||
private *sendToPC(species: number, level: number, name: string): Gen {
|
||||
const data = this.options.data,
|
||||
storage = this.options.storage!,
|
||||
box = currentBox(storage);
|
||||
const mon: BoxMon = {
|
||||
species,
|
||||
item: this.enemy.mon.item,
|
||||
moves: [...this.wildMoves] as BoxMon["moves"],
|
||||
otId: this.options.playerId,
|
||||
ot: this.options.playerName,
|
||||
nickname: name,
|
||||
exp: data.expAtLevel(data.baseData(species).growthRate, level),
|
||||
statExp: [0, 0, 0, 0, 0],
|
||||
dvs: this.enemy.mon.dvs,
|
||||
pp: [...this.wildPP] as BoxMon["pp"],
|
||||
happiness: C.BASE_HAPPINESS,
|
||||
pokerus: 0,
|
||||
caughtData: this.options.caughtData!(level),
|
||||
level,
|
||||
};
|
||||
restorePP(data, mon);
|
||||
box.unshift(mon);
|
||||
if (box.length === MONS_PER_BOX) this.battleResult |= BATTLERESULT_BOX_FULL;
|
||||
mon.nickname = yield* this.askNickname(name);
|
||||
this.strings[RAM.monOrItemNameBuffer] = mon.nickname;
|
||||
yield* this.say(BATTLE.BallSentToPCText);
|
||||
}
|
||||
/** TossItem after a throw: RemoveItemFromPocket takes the ball from the
|
||||
* selected slot if it matches, otherwise from the first matching slot. */
|
||||
private tossBall(item: number, slot: number) {
|
||||
@@ -2798,10 +2860,7 @@ export class Battle {
|
||||
}
|
||||
/** CalcLevel: the highest level whose exp does not exceed the total. */
|
||||
private levelForExp(species: number, exp: number) {
|
||||
const growth = this.options.data.baseData(species).growthRate;
|
||||
let level = 1;
|
||||
while (level < C.MAX_LEVEL && this.options.data.expAtLevel(growth, level + 1) <= exp) level++;
|
||||
return level;
|
||||
return levelForExp(this.options.data, species, exp);
|
||||
}
|
||||
/** LearnLevelMoves: moves the species learns at exactly this level. */
|
||||
private *learnLevelMoves(mon: PartyMon, index: number, level: number): Gen {
|
||||
|
||||
+50
-11
@@ -1,9 +1,13 @@
|
||||
import type { PokemonData, Pocket } from "../rom/pokemon";
|
||||
|
||||
export interface ItemSlot {
|
||||
item: number;
|
||||
quantity: number;
|
||||
}
|
||||
/** The player's pack, with the original pocket capacities. */
|
||||
export interface Bag {
|
||||
items: { item: number; quantity: number }[];
|
||||
balls: { item: number; quantity: number }[];
|
||||
items: ItemSlot[];
|
||||
balls: ItemSlot[];
|
||||
keyItems: number[];
|
||||
}
|
||||
export const emptyBag = (): Bag => ({ items: [], balls: [], keyItems: [] });
|
||||
@@ -20,8 +24,8 @@ const MAX_ITEMS = 20,
|
||||
|
||||
/** PutItemInPocket: room exists if matching stacks can absorb the quantity
|
||||
* or a new slot is free; stacks then fill to 99 before a new one starts. */
|
||||
function putInPocket(
|
||||
pocket: { item: number; quantity: number }[],
|
||||
export function putInPocket(
|
||||
pocket: ItemSlot[],
|
||||
capacity: number,
|
||||
item: number,
|
||||
quantity: number,
|
||||
@@ -80,9 +84,15 @@ export function hasItem(bag: Bag, item: number): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function validBag(data: PokemonData, value: unknown): value is Bag {
|
||||
const bag = value as Bag;
|
||||
const slots = (list: unknown, capacity: number, pocket: Pocket) =>
|
||||
/** A list of item stacks. Without `pocket`, any item with a pack pocket
|
||||
* (the PC's list holds items from every pocket). */
|
||||
export function validItemSlots(
|
||||
data: PokemonData,
|
||||
list: unknown,
|
||||
capacity: number,
|
||||
pocket?: Pocket,
|
||||
): list is ItemSlot[] {
|
||||
return (
|
||||
Array.isArray(list) &&
|
||||
list.length <= capacity &&
|
||||
list.every(
|
||||
@@ -92,13 +102,22 @@ export function validBag(data: PokemonData, value: unknown): value is Bag {
|
||||
Number.isInteger(slot.quantity) &&
|
||||
slot.quantity >= 1 &&
|
||||
slot.quantity <= MAX_ITEM_STACK &&
|
||||
itemIn(data, slot.item, pocket),
|
||||
);
|
||||
(pocket
|
||||
? itemIn(data, slot.item, pocket)
|
||||
: ["item", "ball", "key"].some((each) =>
|
||||
itemIn(data, slot.item, each as Pocket),
|
||||
)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function validBag(data: PokemonData, value: unknown): value is Bag {
|
||||
const bag = value as Bag;
|
||||
return (
|
||||
Boolean(bag) &&
|
||||
typeof bag === "object" &&
|
||||
slots(bag.items, MAX_ITEMS, "item") &&
|
||||
slots(bag.balls, MAX_BALLS, "ball") &&
|
||||
validItemSlots(data, bag.items, MAX_ITEMS, "item") &&
|
||||
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"))
|
||||
@@ -131,3 +150,23 @@ export function tossItem(bag: Bag, item: number, quantity: number): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** RemoveItemFromPocket: take from the stack at `index` (wCurItemQuantity)
|
||||
* if it holds `item`, otherwise from the first stack that does. Fails if
|
||||
* that stack holds fewer. */
|
||||
export function removeFromSlot(
|
||||
list: ItemSlot[],
|
||||
index: number,
|
||||
item: number,
|
||||
quantity: number,
|
||||
): boolean {
|
||||
const at =
|
||||
list[index]?.item === item
|
||||
? index
|
||||
: list.findIndex((slot) => slot.item === item);
|
||||
const slot = list[at];
|
||||
if (!slot || quantity > slot.quantity) return false;
|
||||
slot.quantity -= quantity;
|
||||
if (!slot.quantity) list.splice(at, 1);
|
||||
return true;
|
||||
}
|
||||
+64
-23
@@ -30,9 +30,12 @@ export const PARTY_LENGTH = 6;
|
||||
/** 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;
|
||||
/** The same characters, for box names (up to eight). */
|
||||
export const BOX_NAME_PATTERN = /^[A-Za-z0-9 ()":;[\]?!♂♀/.,×'é-]{1,8}$/u;
|
||||
/** BASE_HAPPINESS */
|
||||
const BASE_HAPPINESS = 70;
|
||||
const MAX_STAT_VALUE = 999;
|
||||
const MAX_LEVEL = 100;
|
||||
|
||||
/** GetSquareRoot: the first of 1..255 whose square is not below `value`. */
|
||||
function squareRoot(value: number) {
|
||||
@@ -159,11 +162,30 @@ export function generatePartyMon(
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
|
||||
export function validPartyMon(
|
||||
/** CalcLevel: the highest level whose experience the total reaches. */
|
||||
export function levelForExp(
|
||||
data: PokemonData,
|
||||
value: unknown,
|
||||
): value is PartyMon {
|
||||
species: number,
|
||||
exp: number,
|
||||
): number {
|
||||
const growth = data.baseData(species).growthRate;
|
||||
let level = 1;
|
||||
while (level < MAX_LEVEL && data.expAtLevel(growth, level + 1) <= exp)
|
||||
level++;
|
||||
return level;
|
||||
}
|
||||
|
||||
/** ComputeMaxPP for a move with `ups` PP Ups: each adds a fifth of the base
|
||||
* PP, clamped to 7, so a 40 PP move maxes at 61 instead of overflowing the
|
||||
* packed byte at 64. The PP Ups stay in the top two bits. */
|
||||
export function maxPP(data: PokemonData, move: number, ups: number): number {
|
||||
const base = data.move(move).pp;
|
||||
return (ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups);
|
||||
}
|
||||
|
||||
/** The box_struct fields of a stored Pokémon: everything but status, HP
|
||||
* and stats. */
|
||||
export function validBoxFields(value: unknown): boolean {
|
||||
const mon = value as PartyMon;
|
||||
const integer = (value: unknown, min: number, max: number): value is number =>
|
||||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||||
@@ -171,28 +193,47 @@ export function validPartyMon(
|
||||
Array.isArray(value) &&
|
||||
value.length === length &&
|
||||
value.every((item) => integer(item, 0, max));
|
||||
try {
|
||||
return (
|
||||
Boolean(mon) &&
|
||||
typeof mon === "object" &&
|
||||
integer(mon.species, 1, 251) &&
|
||||
integer(mon.item, 0, 255) &&
|
||||
tuple(mon.moves, 4, 251) &&
|
||||
integer(mon.otId, 0, 0xffff) &&
|
||||
typeof mon.ot === "string" &&
|
||||
/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) &&
|
||||
typeof mon.nickname === "string" &&
|
||||
NICKNAME_PATTERN.test(mon.nickname) &&
|
||||
integer(mon.exp, 0, 0xffffff) &&
|
||||
tuple(mon.statExp, 5, 0xffff) &&
|
||||
integer(mon.dvs, 0, 0xffff) &&
|
||||
tuple(mon.pp, 4, 255) &&
|
||||
integer(mon.happiness, 0, 255) &&
|
||||
integer(mon.pokerus, 0, 255) &&
|
||||
integer(mon.caughtData, 0, 0xffff) &&
|
||||
integer(mon.level, 1, 100)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a stored Pokémon against the ROM's rules before restoring it. */
|
||||
export function validPartyMon(
|
||||
data: PokemonData,
|
||||
value: unknown,
|
||||
): value is PartyMon {
|
||||
const mon = value as PartyMon;
|
||||
const integer = (value: unknown, min: number, max: number): value is number =>
|
||||
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
|
||||
try {
|
||||
if (
|
||||
!mon ||
|
||||
typeof mon !== "object" ||
|
||||
!integer(mon.species, 1, 251) ||
|
||||
!integer(mon.item, 0, 255) ||
|
||||
!tuple(mon.moves, 4, 251) ||
|
||||
!integer(mon.otId, 0, 0xffff) ||
|
||||
typeof mon.ot !== "string" ||
|
||||
!/^[A-Za-z0-9 -]{1,7}$/.test(mon.ot) ||
|
||||
typeof mon.nickname !== "string" ||
|
||||
!NICKNAME_PATTERN.test(mon.nickname) ||
|
||||
!integer(mon.exp, 0, 0xffffff) ||
|
||||
!tuple(mon.statExp, 5, 0xffff) ||
|
||||
!integer(mon.dvs, 0, 0xffff) ||
|
||||
!tuple(mon.pp, 4, 255) ||
|
||||
!integer(mon.happiness, 0, 255) ||
|
||||
!integer(mon.pokerus, 0, 255) ||
|
||||
!integer(mon.caughtData, 0, 0xffff) ||
|
||||
!integer(mon.level, 1, 100) ||
|
||||
!validBoxFields(mon) ||
|
||||
!integer(mon.status, 0, 255) ||
|
||||
!tuple(mon.stats, 6, MAX_STAT_VALUE) ||
|
||||
!Array.isArray(mon.stats) ||
|
||||
mon.stats.length !== 6 ||
|
||||
!mon.stats.every((stat) => integer(stat, 0, MAX_STAT_VALUE)) ||
|
||||
!integer(mon.hp, 0, mon.stats[0])
|
||||
)
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import {
|
||||
BOX_NAME_PATTERN,
|
||||
calcStats,
|
||||
levelForExp,
|
||||
maxPP,
|
||||
validBoxFields,
|
||||
type PartyMon,
|
||||
} from "./pokemon";
|
||||
import { putInPocket, validItemSlots, type ItemSlot } from "./bag";
|
||||
|
||||
/** NUM_BOXES and MONS_PER_BOX (constants/pokemon_data_constants.asm). */
|
||||
export const NUM_BOXES = 14;
|
||||
export const MONS_PER_BOX = 20;
|
||||
/** BOX_NAME_LENGTH minus the terminator. */
|
||||
export const BOX_NAME_MAX = 8;
|
||||
/** MAX_PC_ITEMS (constants/item_data_constants.asm). */
|
||||
export const MAX_PC_ITEMS = 50;
|
||||
/** The fields a stored BoxMon has, so no party fields slip into a box. */
|
||||
const BOX_MON_FIELDS = 14;
|
||||
|
||||
/** box_struct: a party_struct without status, HP and stats. */
|
||||
export type BoxMon = Omit<PartyMon, "status" | "hp" | "stats">;
|
||||
|
||||
/** Bill's PC: every box, wCurBox and wBoxNames. A null name is the one
|
||||
* SetDefaultBoxNames writes ("BOX" and the box number, from the ROM). */
|
||||
export interface PokemonStorage {
|
||||
current: number;
|
||||
names: (string | null)[];
|
||||
boxes: BoxMon[][];
|
||||
}
|
||||
export const emptyStorage = (): PokemonStorage => ({
|
||||
current: 0,
|
||||
names: Array.from({ length: NUM_BOXES }, () => null),
|
||||
boxes: Array.from({ length: NUM_BOXES }, () => []),
|
||||
});
|
||||
export const copyStorage = (storage: PokemonStorage): PokemonStorage => ({
|
||||
current: storage.current,
|
||||
names: [...storage.names],
|
||||
boxes: structuredClone(storage.boxes),
|
||||
});
|
||||
export const currentBox = (storage: PokemonStorage) =>
|
||||
storage.boxes[storage.current];
|
||||
|
||||
/** The first BOXMON_STRUCT_LENGTH bytes of a party struct. */
|
||||
export function toBoxMon(mon: PartyMon): BoxMon {
|
||||
const {
|
||||
status: _status,
|
||||
hp: _hp,
|
||||
stats: _stats,
|
||||
...box
|
||||
} = structuredClone(mon);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** RestorePPOfDepositedPokemon: each move's PP becomes its maximum,
|
||||
* keeping the PP Ups (GetMaxPPOfMove through ComputeMaxPP). */
|
||||
export function restorePP(data: PokemonData, mon: BoxMon) {
|
||||
mon.pp = mon.moves.map((move, slot) =>
|
||||
move ? maxPP(data, move, mon.pp[slot] >> 6) : mon.pp[slot],
|
||||
) as BoxMon["pp"];
|
||||
}
|
||||
|
||||
/** SendGetMonIntoFromBox with PC_DEPOSIT: the Pokémon joins the end of
|
||||
* the current box with its PP restored. Returns false if the box is full. */
|
||||
export function depositMon(
|
||||
data: PokemonData,
|
||||
storage: PokemonStorage,
|
||||
mon: PartyMon,
|
||||
): boolean {
|
||||
const box = currentBox(storage);
|
||||
if (box.length >= MONS_PER_BOX) return false;
|
||||
const stored = toBoxMon(mon);
|
||||
restorePP(data, stored);
|
||||
box.push(stored);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** SendGetMonIntoFromBox with PC_WITHDRAW: CalcLevel from the experience,
|
||||
* then CalcMonStats with stat experience, no status and full HP. */
|
||||
export function withdrawnMon(data: PokemonData, box: BoxMon): PartyMon {
|
||||
const mon = structuredClone(box);
|
||||
const level = levelForExp(data, mon.species, mon.exp);
|
||||
const stats = calcStats(data, mon.species, mon.dvs, level, mon.statExp);
|
||||
return { ...mon, level, status: 0, hp: stats[0], stats };
|
||||
}
|
||||
|
||||
/** ReceiveItem into wNumPCItems. */
|
||||
export function receivePCItem(
|
||||
items: ItemSlot[],
|
||||
item: number,
|
||||
quantity: number,
|
||||
): boolean {
|
||||
return putInPocket(items, MAX_PC_ITEMS, item, quantity);
|
||||
}
|
||||
|
||||
export function validStorage(value: unknown): value is PokemonStorage {
|
||||
const storage = value as PokemonStorage;
|
||||
return (
|
||||
Boolean(storage) &&
|
||||
typeof storage === "object" &&
|
||||
Number.isInteger(storage.current) &&
|
||||
storage.current >= 0 &&
|
||||
storage.current < NUM_BOXES &&
|
||||
Array.isArray(storage.names) &&
|
||||
storage.names.length === NUM_BOXES &&
|
||||
storage.names.every(
|
||||
(name) =>
|
||||
name === null ||
|
||||
(typeof name === "string" &&
|
||||
BOX_NAME_PATTERN.test(name) &&
|
||||
!/^ *$/.test(name)),
|
||||
) &&
|
||||
Array.isArray(storage.boxes) &&
|
||||
storage.boxes.length === NUM_BOXES &&
|
||||
storage.boxes.every(
|
||||
(box) =>
|
||||
Array.isArray(box) &&
|
||||
box.length <= MONS_PER_BOX &&
|
||||
box.every(
|
||||
(mon) =>
|
||||
validBoxFields(mon) &&
|
||||
Object.keys(mon as object).length === BOX_MON_FIELDS,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
export function validPCItems(
|
||||
data: PokemonData,
|
||||
value: unknown,
|
||||
): value is ItemSlot[] {
|
||||
return validItemSlots(data, value, MAX_PC_ITEMS);
|
||||
}
|
||||
@@ -281,6 +281,73 @@ export const OVERWORLD = {
|
||||
MansionPalette2: 0x496fe,
|
||||
} as const;
|
||||
|
||||
/** Pokémon Center and house PCs, Bill's PC and Oak's PC: routines, menu
|
||||
* data, strings and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const PC = {
|
||||
TileCollisionStdScripts: 0x13681,
|
||||
PokemonCenterPC: 0x1559a,
|
||||
"PokemonCenterPC.WhichPC": 0x1562c,
|
||||
"PokemonCenterPC.Jumptable": 0x155e6,
|
||||
"PC_CheckPartyForPokemon.PokecenterPCCantUseText": 0x15663,
|
||||
PokecenterPCTurnOnText: 0x15a27,
|
||||
PokecenterPCWhoseText: 0x15a2c,
|
||||
PokecenterBillsPCText: 0x15a31,
|
||||
PokecenterPlayersPCText: 0x15a36,
|
||||
PokecenterOaksPCText: 0x15a3b,
|
||||
PokecenterPCOaksClosedText: 0x15a40,
|
||||
PlayersPCTurnOnText: 0x156ff,
|
||||
PlayersPCAskWhatDoText: 0x157cc,
|
||||
"PlayersPCMenuData.WhichPC": 0x157ac,
|
||||
"PlayersPCMenuData.PlayersPCMenuPointers": 0x15746,
|
||||
"PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText": 0x15850,
|
||||
"PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText": 0x15855,
|
||||
"PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText": 0x1585a,
|
||||
"PlayerDepositItemMenu.PlayersPCNoItemsText": 0x158c7,
|
||||
"PlayerDepositItemMenu.PlayersPCHowManyDepositText": 0x1596e,
|
||||
"PlayerDepositItemMenu.PlayersPCDepositItemsText": 0x15973,
|
||||
"PlayerDepositItemMenu.PlayersPCNoRoomDepositText": 0x15978,
|
||||
"TossItemFromPC.ItemsTossOutHowManyText": 0x12a45,
|
||||
"TossItemFromPC.ItemsThrowAwayText": 0x12a4a,
|
||||
"TossItemFromPC.ItemsDiscardedText": 0x12a4f,
|
||||
"TossItemFromPC.ItemsTooImportantText": 0x12a5b,
|
||||
"_PlayerMailBoxMenu.EmptyMailboxText": 0x447b4,
|
||||
"_BillsPC.PCGottaHavePokemonText": 0xe417,
|
||||
"_BillsPC.PCWhatText": 0xe43a,
|
||||
"_BillsPC.strings": 0xe47f,
|
||||
"BillsPCDepositMenuHeader.MenuData": 0xe2545,
|
||||
"BillsPC_Withdraw.MenuData": 0xe2739,
|
||||
"BillsPC_ChangeBoxSubmenu.MenuData": 0xe3783,
|
||||
PCString_ChooseaPKMN: 0xe34dd,
|
||||
PCString_WhatsUp: 0xe34ea,
|
||||
PCString_ReleasePKMN: 0xe34f4,
|
||||
PCString_ItsYourLastPKMN: 0xe350f,
|
||||
PCString_NoMoreUsablePKMN: 0xe3531,
|
||||
PCString_ReleasedPKMN: 0xe3551,
|
||||
PCString_Bye: 0xe355e,
|
||||
PCString_Stored: 0xe3563,
|
||||
PCString_Got: 0xe356b,
|
||||
PCString_BoxFull: 0xe3575,
|
||||
PCString_PartyFull: 0xe3586,
|
||||
PCString_NoReleasingEGGS: 0xe3597,
|
||||
"BillsPC_PlaceChooseABoxString.ChooseABox": 0xe37a1,
|
||||
"BillsPC_PlaceEmptyBoxString_SFX.NoMonString": 0xe37d3,
|
||||
"BillsPC_PrintBoxName.Current": 0xe36f1,
|
||||
"BillsPC_PrintBoxCountAndCapacity.OutOf20": 0xe3668,
|
||||
"BillsPC_RefreshTextboxes.CancelString": 0xe2c67,
|
||||
"BillsPC_BoxName.PartyPKMN": 0xe2abd,
|
||||
"NamingScreenJumptable.BoxNameString": 0x11822,
|
||||
"SetDefaultBoxNames.Box": 0x5ccf,
|
||||
ChangeBoxSaveText: 0x152a1,
|
||||
OakPCText1: 0x266de,
|
||||
OakPCText2: 0x266e3,
|
||||
OakPCText3: 0x266e8,
|
||||
OakPCText4: 0x2674c,
|
||||
OakRatings: 0x2667f,
|
||||
PhoneContacts: 0x9045f,
|
||||
Script_ReceivePhoneCall: 0x90241,
|
||||
} as const;
|
||||
|
||||
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||
* \`npm run check:reference\` can verify each address by name. */
|
||||
export const BATTLE = {
|
||||
@@ -294,6 +361,8 @@ export const BATTLE = {
|
||||
NewDexDataText: 0xedf0,
|
||||
AskGiveNicknameText: 0xedf5,
|
||||
BallBlockedText: 0xf824,
|
||||
BallBoxFullText: 0xf838,
|
||||
BallSentToPCText: 0xedeb,
|
||||
BallDontBeAThiefText: 0xf829,
|
||||
WobbleProbabilities: 0xf9ba,
|
||||
"CatchTutorial.Dude": 0x4e5da,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
NUM_FRUIT_TREES,
|
||||
EVENTS,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
RAM,
|
||||
SPECIAL_ROUTINES,
|
||||
SYMBOLS,
|
||||
@@ -24,18 +25,28 @@ import {
|
||||
receiveItem,
|
||||
tossItem,
|
||||
type Bag,
|
||||
type ItemSlot,
|
||||
} from "../party/bag";
|
||||
import {
|
||||
MONS_PER_BOX,
|
||||
copyStorage,
|
||||
currentBox,
|
||||
emptyStorage,
|
||||
type PokemonStorage,
|
||||
} from "../party/storage";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
PARTY_LENGTH,
|
||||
caughtData,
|
||||
generatePartyMon,
|
||||
maxPP,
|
||||
type PartyMon,
|
||||
} from "../party/pokemon";
|
||||
import { TrainerData, type TrainerHeader } from "../rom/trainers";
|
||||
import { DIRECTION_ORDER, type Direction } from "../world/types";
|
||||
import { OW } from "../world/constants";
|
||||
import { readMovement, type MovementStep } from "./movement";
|
||||
import { PcSession } from "./pc";
|
||||
import {
|
||||
defaultTextContext,
|
||||
readString,
|
||||
@@ -59,6 +70,10 @@ export interface ScriptState {
|
||||
clock: ClockSettings;
|
||||
party: PartyMon[];
|
||||
bag: Bag;
|
||||
/** Bill's PC: the boxes, their names and wCurBox. */
|
||||
storage: PokemonStorage;
|
||||
/** wPCItems: the Player's PC item storage. */
|
||||
pcItems: ItemSlot[];
|
||||
pokedex: { seen: Set<number>; caught: Set<number> };
|
||||
/** wMoney: a 24-bit amount. */
|
||||
money: number;
|
||||
@@ -94,6 +109,8 @@ export const emptyScriptState = (): ScriptState => ({
|
||||
clock: { weekdayOffset: 0, dst: false },
|
||||
party: [],
|
||||
bag: emptyBag(),
|
||||
storage: emptyStorage(),
|
||||
pcItems: [],
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
money: C.START_MONEY,
|
||||
fruitTrees: new Set(),
|
||||
@@ -116,6 +133,8 @@ export const copyScriptState = (state: ScriptState): ScriptState => ({
|
||||
clock: { ...state.clock },
|
||||
party: structuredClone(state.party),
|
||||
bag: copyBag(state.bag),
|
||||
storage: copyStorage(state.storage),
|
||||
pcItems: state.pcItems.map((slot) => ({ ...slot })),
|
||||
pokedex: {
|
||||
seen: new Set(state.pokedex.seen),
|
||||
caught: new Set(state.pokedex.caught),
|
||||
@@ -138,9 +157,10 @@ export const PLAYER = 0;
|
||||
const LAST_TALKED = 0xfe;
|
||||
/** The phone holds at most ten contacts (CONTACT_LIST_SIZE). */
|
||||
const CONTACT_LIST_SIZE = 10;
|
||||
const MONS_PER_BOX = 20;
|
||||
|
||||
/** One row of a mart's buy or sell list. `owned` is set only when selling. */
|
||||
/** The mart's lists and quantity box, which the PC's item storage reuses. */
|
||||
export type MartMode = "buy" | "sell" | "withdraw" | "deposit" | "toss";
|
||||
export interface MartEntry {
|
||||
item: number;
|
||||
name: string;
|
||||
@@ -174,7 +194,7 @@ export type ScriptEffect =
|
||||
* answered with the chosen entry's index, or a negative number to cancel. */
|
||||
| {
|
||||
kind: "martlist";
|
||||
mode: "buy" | "sell";
|
||||
mode: MartMode;
|
||||
entries: MartEntry[];
|
||||
selected: number;
|
||||
}
|
||||
@@ -182,7 +202,7 @@ export type ScriptEffect =
|
||||
* number below 1 to cancel back to the item list. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
mode: "buy" | "sell";
|
||||
mode: MartMode;
|
||||
item: number;
|
||||
name: string;
|
||||
price: number;
|
||||
@@ -209,9 +229,22 @@ export type ScriptEffect =
|
||||
| { kind: "warp"; map: string; x: number; y: number; facing?: Direction }
|
||||
/** A vertical menu (the elevator's floors); answered with the chosen
|
||||
* item's index, or -1 when cancelled with B. */
|
||||
| { kind: "menu"; pages: string[]; items: string[]; selected: number }
|
||||
| {
|
||||
kind: "menu";
|
||||
pages: string[];
|
||||
items: string[];
|
||||
/** Secondary text per item, such as a level or a box's count. */
|
||||
details?: string[];
|
||||
selected: number;
|
||||
}
|
||||
/** A native message for presentation the port does not have yet; the
|
||||
* script continues. */
|
||||
| { kind: "notice"; text: string }
|
||||
/** warpcheck: the player's tile events run again after the script. */
|
||||
| { kind: "warpcheck" }
|
||||
/** LoadMemScript: a script (with a phone caller's script) that runs at
|
||||
* the next PlayerEvents, unless one is already queued. */
|
||||
| { kind: "memscript"; script: number; callerScript?: number }
|
||||
/** changeblock: the block covering map tile (x, y) becomes `block`. */
|
||||
| { kind: "changeblock"; x: number; y: number; block: number }
|
||||
/** changemapblocks: replace the map's block data from the ROM. */
|
||||
@@ -265,6 +298,10 @@ type Input = number | string | undefined;
|
||||
const ITEM_FROM_MEM = 0xff;
|
||||
/** wCallerContact's script-2 pointer (bank, address) within the contact. */
|
||||
const PHONE_CONTACT_SCRIPT2_BANK = 9;
|
||||
/** PhoneContacts entries (data/phone/phone_contacts.asm) and PHONE_BILL. */
|
||||
const PHONE_CONTACT_SIZE = 12;
|
||||
const PHONE_BILL = 3;
|
||||
const BATTLERESULT_BOX_FULL = 0x80;
|
||||
|
||||
/** TrainerWalkToPlayer and ComputePathToWalkToPlayer (home/movement.asm):
|
||||
* a one-tick sleep, then normal steps to the player's tile, dropping the
|
||||
@@ -871,6 +908,20 @@ export class ScriptRun {
|
||||
case 0x60:
|
||||
// Script_reloadmapafterbattle: a loss continues with the whiteout.
|
||||
if ((battleResult & 0x3f) === 1) p = SYMBOLS.battleWhiteoutScript;
|
||||
// A wild catch that filled the box queues Bill's call
|
||||
// (Script_SpecialBillCall: LoadCallerScript for PHONE_BILL, then
|
||||
// Script_ReceivePhoneCall). BATTLESCRIPT_WILD_F is set by trainer
|
||||
// battles, which instead run the unported MomTriesToBuySomething.
|
||||
else if (!trainer && battleResult & BATTLERESULT_BOX_FULL)
|
||||
yield synchronous({
|
||||
kind: "memscript",
|
||||
script: PC.Script_ReceivePhoneCall,
|
||||
callerScript: r.far(
|
||||
PC.PhoneContacts +
|
||||
PHONE_BILL * PHONE_CONTACT_SIZE +
|
||||
PHONE_CONTACT_SCRIPT2_BANK,
|
||||
),
|
||||
});
|
||||
canLose = false;
|
||||
break;
|
||||
case 0x7b:
|
||||
@@ -1597,6 +1648,19 @@ export class ScriptRun {
|
||||
}
|
||||
}
|
||||
|
||||
/** The PCs, sharing this script's state and text buffers. */
|
||||
private pc() {
|
||||
return new PcSession({
|
||||
state: this.state,
|
||||
reader: this.reader,
|
||||
data: this.host.data,
|
||||
text: () => this.text(),
|
||||
strings: this.strings,
|
||||
numbers: this.numbers,
|
||||
decorationMenu: () => this.decorationMenu(),
|
||||
});
|
||||
}
|
||||
|
||||
/** _PlayerDecorationMenu: owned categories, replacement and ornament sides. */
|
||||
private *decorationMenu(): Generator<
|
||||
ScriptEffect,
|
||||
@@ -1782,10 +1846,8 @@ export class ScriptRun {
|
||||
case 0x0d:
|
||||
return number;
|
||||
case 0x10:
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount. Nothing can be
|
||||
// deposited yet (catching with a full party is refused and the PC
|
||||
// is not ported), so the current box is empty.
|
||||
return MONS_PER_BOX;
|
||||
// .BoxFreeSpace: MONS_PER_BOX minus sBoxCount.
|
||||
return MONS_PER_BOX - currentBox(this.state.storage).length;
|
||||
case 0x12:
|
||||
return this.host.position().x;
|
||||
case 0x13:
|
||||
@@ -1862,16 +1924,9 @@ export class ScriptRun {
|
||||
for (const mon of this.state.party) {
|
||||
mon.status = 0;
|
||||
mon.hp = mon.stats[0];
|
||||
mon.pp = mon.moves.map((move, slot) => {
|
||||
if (!move) return 0;
|
||||
const ups = mon.pp[slot] >> 6,
|
||||
base = this.host.data.move(move).pp;
|
||||
// ComputeMaxPP clamps each PP Up's bonus to 7, so a 40 PP move
|
||||
// maxes at 61 instead of overflowing the packed byte at 64.
|
||||
return (
|
||||
(ups << 6) | (base + Math.min(Math.floor(base / 5), 7) * ups)
|
||||
);
|
||||
}) as PartyMon["pp"];
|
||||
mon.pp = mon.moves.map((move, slot) =>
|
||||
move ? maxPP(this.host.data, move, mon.pp[slot] >> 6) : 0,
|
||||
) as PartyMon["pp"];
|
||||
}
|
||||
return value;
|
||||
case SPECIAL_ROUTINES.checkPokerus:
|
||||
@@ -1937,9 +1992,13 @@ export class ScriptRun {
|
||||
case OVERWORLD.OverworldTownMap:
|
||||
yield { kind: "townmap" };
|
||||
return value;
|
||||
case PC.PokemonCenterPC:
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
yield* this.pc().pokemonCenterPC();
|
||||
return value;
|
||||
case OVERWORLD.PlayersHousePC: {
|
||||
take();
|
||||
const changed = yield* this.decorationMenu();
|
||||
if (pages.length) yield { kind: "text", pages: take() };
|
||||
const changed = yield* this.pc().playersHousePC();
|
||||
if (changed)
|
||||
yield {
|
||||
kind: "warp",
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
import type { RomReader } from "../rom/reader";
|
||||
import type { PokemonData } from "../rom/pokemon";
|
||||
import { ENGINE_FLAGS, PC, RAM } from "../rom/profile";
|
||||
import { receiveItem, removeFromSlot, type ItemSlot } from "../party/bag";
|
||||
import { BOX_NAME_PATTERN, PARTY_LENGTH } from "../party/pokemon";
|
||||
import {
|
||||
NUM_BOXES,
|
||||
currentBox,
|
||||
depositMon,
|
||||
receivePCItem,
|
||||
withdrawnMon,
|
||||
} from "../party/storage";
|
||||
import { readString, readText, type TextContext } from "./text";
|
||||
import type { MartEntry, ScriptEffect, ScriptState } from "./interpreter";
|
||||
|
||||
type Input = number | string | undefined;
|
||||
type Gen<T = void> = Generator<ScriptEffect, T, Input>;
|
||||
|
||||
/** What the PC routines need from the running script. */
|
||||
export interface PcHost {
|
||||
state: ScriptState;
|
||||
reader: RomReader;
|
||||
data: PokemonData;
|
||||
text(): TextContext;
|
||||
/** text_ram and text_decimal values, keyed by RAM address. */
|
||||
strings: Record<number, string>;
|
||||
numbers: Record<number, number>;
|
||||
/** _PlayerDecorationMenu: true if a decoration changed. */
|
||||
decorationMenu(): Gen<boolean>;
|
||||
}
|
||||
|
||||
/** PokemonCenterPC's menu items (PCPCITEM_*). */
|
||||
const PCPCITEM_PLAYERS_PC = 0,
|
||||
PCPCITEM_BILLS_PC = 1,
|
||||
PCPCITEM_OAKS_PC = 2,
|
||||
PCPCITEM_TURN_OFF = 4;
|
||||
/** _PlayersPC's menu items (PLAYERSPCITEM_*). */
|
||||
const PLAYERSPCITEM_WITHDRAW_ITEM = 0,
|
||||
PLAYERSPCITEM_DEPOSIT_ITEM = 1,
|
||||
PLAYERSPCITEM_TOSS_ITEM = 2,
|
||||
PLAYERSPCITEM_MAIL_BOX = 3,
|
||||
PLAYERSPCITEM_DECORATION = 4;
|
||||
/** _BillsPC's menu items. */
|
||||
const BILLSPC_WITHDRAW = 0,
|
||||
BILLSPC_DEPOSIT = 1,
|
||||
BILLSPC_CHANGE_BOX = 2,
|
||||
BILLSPC_MOVE = 3;
|
||||
/** The box submenus: DEPOSIT or WITHDRAW, STATS, RELEASE, CANCEL; and
|
||||
* SWITCH, NAME, PRINT, QUIT for a box. */
|
||||
const SUBMENU_TAKE = 0,
|
||||
SUBMENU_STATS = 1,
|
||||
SUBMENU_RELEASE = 2;
|
||||
const BOX_SWITCH = 0,
|
||||
BOX_NAME = 1,
|
||||
BOX_PRINT = 2;
|
||||
/** 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 {
|
||||
pocket: "items" | "balls" | "keyItems";
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** The Pokémon Center PC (engine/events/pokecenter_pc.asm), Bill's PC
|
||||
* (engine/pokemon/bills_pc_top.asm and bills_pc.asm) and Oak's PC
|
||||
* (engine/events/prof_oaks_pc.asm). The original draws its own screens;
|
||||
* here menus, lists and messages are presented natively. */
|
||||
export class PcSession {
|
||||
constructor(private host: PcHost) {}
|
||||
|
||||
private get state() {
|
||||
return this.host.state;
|
||||
}
|
||||
private string(address: number) {
|
||||
return readString(this.host.reader, address, this.host.text());
|
||||
}
|
||||
private pages(address: number) {
|
||||
return readText(this.host.reader, address, this.host.text()).split("\n\n");
|
||||
}
|
||||
private *print(address: number): Gen {
|
||||
yield { kind: "text", pages: this.pages(address) };
|
||||
}
|
||||
/** BillsPC_PlaceString followed by DelayFrames: a message that stays
|
||||
* for a fixed time without waiting for a button. */
|
||||
private *placeString(text: string, ticks = MESSAGE_TICKS): Gen {
|
||||
yield { kind: "text", pages: [text], holdTicks: ticks };
|
||||
}
|
||||
/** A menu of `items`; the chosen index, or -1 for B. */
|
||||
private *menu(
|
||||
pages: string[],
|
||||
items: string[],
|
||||
details?: string[],
|
||||
selected = 0,
|
||||
): Gen<number> {
|
||||
const choice = yield { kind: "menu", pages, items, details, selected };
|
||||
return typeof choice === "number" && choice >= 0 && choice < items.length
|
||||
? choice
|
||||
: -1;
|
||||
}
|
||||
/** Consecutive "@"-terminated strings, as static menus store them. */
|
||||
private strings(address: number, count: number) {
|
||||
const r = this.host.reader,
|
||||
result: string[] = [];
|
||||
let p = address;
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(this.string(p));
|
||||
while (r.byte(p++) !== 0x50);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/** A WhichPC list: a count, the item ids, then -1. */
|
||||
private indexSet(address: number, set: number) {
|
||||
const r = this.host.reader;
|
||||
let p = address;
|
||||
for (let i = 0; i < set; i++) p += r.byte(p) + 2;
|
||||
const count = r.byte(p);
|
||||
return Array.from({ length: count }, (_, i) => r.byte(p + 1 + i));
|
||||
}
|
||||
/** A jumptable of (routine, string) pointers: the item's string. */
|
||||
private itemString(table: number, item: number) {
|
||||
const r = this.host.reader,
|
||||
entry = table + item * 4 + 2;
|
||||
return this.string(r.near(entry, r.bank(entry)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ PC menus
|
||||
|
||||
/** PokemonCenterPC: a Pokémon is needed, then the PCs listed for the
|
||||
* player's progress until TURN OFF or B. */
|
||||
*pokemonCenterPC(): Gen {
|
||||
if (!this.state.party.length) {
|
||||
yield* this.print(PC["PC_CheckPartyForPokemon.PokecenterPCCantUseText"]);
|
||||
return;
|
||||
}
|
||||
yield* this.print(PC.PokecenterPCTurnOnText);
|
||||
const whose = this.pages(PC.PokecenterPCWhoseText);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
// .ChooseWhichPCListToUse: PCPC_BEFORE_POKEDEX or PCPC_BEFORE_HOF.
|
||||
// No Hall of Fame entry exists before the League is ported.
|
||||
const set = this.state.flags.has(ENGINE_FLAGS.pokedex) ? 1 : 0;
|
||||
const items = this.indexSet(PC["PokemonCenterPC.WhichPC"], set);
|
||||
const choice = yield* this.menu(
|
||||
whose,
|
||||
items.map((item) =>
|
||||
this.itemString(PC["PokemonCenterPC.Jumptable"], item),
|
||||
),
|
||||
undefined,
|
||||
cursor,
|
||||
);
|
||||
if (choice < 0) return;
|
||||
cursor = choice;
|
||||
switch (items[choice]) {
|
||||
case PCPCITEM_BILLS_PC:
|
||||
yield* this.print(PC.PokecenterBillsPCText);
|
||||
yield* this.billsPC();
|
||||
break;
|
||||
case PCPCITEM_PLAYERS_PC:
|
||||
yield* this.print(PC.PokecenterPlayersPCText);
|
||||
yield* this.playersPC(false);
|
||||
break;
|
||||
case PCPCITEM_OAKS_PC:
|
||||
yield* this.print(PC.PokecenterOaksPCText);
|
||||
yield* this.oaksPC();
|
||||
break;
|
||||
case PCPCITEM_TURN_OFF:
|
||||
yield* this.print(PC.PokecenterPCOaksClosedText);
|
||||
return;
|
||||
default:
|
||||
throw new Error("The Hall of Fame PC is not ported.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** _PlayersHousePC: returns true when a decoration changed, which makes
|
||||
* the script reload the room. */
|
||||
*playersHousePC(): Gen<boolean> {
|
||||
yield* this.print(PC.PlayersPCTurnOnText);
|
||||
return yield* this.playersPC(true);
|
||||
}
|
||||
|
||||
/** _PlayersPC: PLAYERSPC_NORMAL or PLAYERSPC_HOUSE. A changed
|
||||
* decoration closes the PC. */
|
||||
private *playersPC(house: boolean): Gen<boolean> {
|
||||
const items = this.indexSet(PC["PlayersPCMenuData.WhichPC"], house ? 1 : 0);
|
||||
const names = items.map((item) =>
|
||||
this.itemString(PC["PlayersPCMenuData.PlayersPCMenuPointers"], item),
|
||||
);
|
||||
const ask = this.pages(PC.PlayersPCAskWhatDoText);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const choice = yield* this.menu(ask, names, undefined, cursor);
|
||||
if (choice < 0) return false;
|
||||
cursor = choice;
|
||||
switch (items[choice]) {
|
||||
case PLAYERSPCITEM_WITHDRAW_ITEM:
|
||||
yield* this.withdrawItems();
|
||||
break;
|
||||
case PLAYERSPCITEM_DEPOSIT_ITEM:
|
||||
yield* this.depositItems();
|
||||
break;
|
||||
case PLAYERSPCITEM_TOSS_ITEM:
|
||||
yield* this.tossItems();
|
||||
break;
|
||||
case PLAYERSPCITEM_MAIL_BOX:
|
||||
// _PlayerMailBoxMenu: no MAIL can be stored yet.
|
||||
yield* this.print(PC["_PlayerMailBoxMenu.EmptyMailboxText"]);
|
||||
break;
|
||||
case PLAYERSPCITEM_DECORATION:
|
||||
if (yield* this.host.decorationMenu()) return true;
|
||||
break;
|
||||
default:
|
||||
// LOG OFF and TURN OFF.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ item storage
|
||||
|
||||
private itemEntries(slots: ItemSlot[]): MartEntry[] {
|
||||
const data = this.host.data;
|
||||
return slots.map((slot) => ({
|
||||
item: slot.item,
|
||||
name: data.itemName(slot.item),
|
||||
price: 0,
|
||||
owned: slot.quantity,
|
||||
}));
|
||||
}
|
||||
/** The item named by PartyMonItemName (wStringBuffer1 and 2) and the
|
||||
* quantity text_decimal shows (wItemQuantityChange). */
|
||||
private nameItem(item: number, quantity?: number) {
|
||||
const name = this.host.data.itemName(item);
|
||||
this.host.strings[RAM.stringBuffer1] = name;
|
||||
this.host.strings[RAM.stringBuffer2] = name;
|
||||
if (quantity !== undefined)
|
||||
this.host.numbers[RAM.itemQuantityChange] = quantity;
|
||||
}
|
||||
/** SelectQuantityToToss for a stack, unless _CheckTossableItem makes it
|
||||
* always one. Returns 0 if cancelled. */
|
||||
private *quantity(
|
||||
mode: "withdraw" | "deposit" | "toss",
|
||||
slot: ItemSlot,
|
||||
prompt: number,
|
||||
): Gen<number> {
|
||||
if (this.host.data.itemUnsellable(slot.item)) return 1;
|
||||
this.nameItem(slot.item);
|
||||
const chosen = yield {
|
||||
kind: "martquantity",
|
||||
mode,
|
||||
item: slot.item,
|
||||
name: this.host.data.itemName(slot.item),
|
||||
price: 0,
|
||||
max: slot.quantity,
|
||||
prompt: readText(this.host.reader, prompt, this.host.text()),
|
||||
selected: 1,
|
||||
};
|
||||
return typeof chosen === "number" &&
|
||||
Number.isInteger(chosen) &&
|
||||
chosen >= 1 &&
|
||||
chosen <= slot.quantity
|
||||
? chosen
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** PlayerWithdrawItemMenu: into the matching pack pocket. */
|
||||
private *withdrawItems(): Gen {
|
||||
const items = this.state.pcItems;
|
||||
for (;;) {
|
||||
const index = yield {
|
||||
kind: "martlist",
|
||||
mode: "withdraw",
|
||||
entries: this.itemEntries(items),
|
||||
selected: 0,
|
||||
};
|
||||
const slot = typeof index === "number" ? items[index] : undefined;
|
||||
if (!slot) return;
|
||||
const quantity = yield* this.quantity(
|
||||
"withdraw",
|
||||
slot,
|
||||
PC["PlayerWithdrawItemMenu.PlayersPCHowManyWithdrawText"],
|
||||
);
|
||||
if (!quantity) continue;
|
||||
const item = slot.item;
|
||||
if (!receiveItem(this.host.data, this.state.bag, item, quantity)) {
|
||||
yield* this.print(
|
||||
PC["PlayerWithdrawItemMenu.PlayersPCNoRoomWithdrawText"],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
removeFromSlot(items, index as number, item, quantity);
|
||||
this.nameItem(item, quantity);
|
||||
yield* this.print(
|
||||
PC["PlayerWithdrawItemMenu.PlayersPCWithdrewItemsText"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pack as DepositSellPack lists it: items, balls and key items,
|
||||
* with the pocket and index each entry comes from. */
|
||||
private packSlots(): PackSlot[] {
|
||||
const bag = this.state.bag;
|
||||
return [
|
||||
...bag.items.map((slot, index) => ({
|
||||
...slot,
|
||||
pocket: "items" as const,
|
||||
index,
|
||||
})),
|
||||
...bag.balls.map((slot, index) => ({
|
||||
...slot,
|
||||
pocket: "balls" as const,
|
||||
index,
|
||||
})),
|
||||
...bag.keyItems.map((item, index) => ({
|
||||
item,
|
||||
quantity: 1,
|
||||
pocket: "keyItems" as const,
|
||||
index,
|
||||
})),
|
||||
];
|
||||
}
|
||||
/** PlayerDepositItemMenu: from any pocket into the PC. */
|
||||
private *depositItems(): Gen {
|
||||
if (!this.packSlots().length) {
|
||||
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCNoItemsText"]);
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
const slots = this.packSlots();
|
||||
const choice = yield {
|
||||
kind: "martlist",
|
||||
mode: "deposit",
|
||||
entries: this.itemEntries(slots),
|
||||
selected: 0,
|
||||
};
|
||||
const slot = typeof choice === "number" ? slots[choice] : undefined;
|
||||
if (!slot) return;
|
||||
const quantity = yield* this.quantity(
|
||||
"deposit",
|
||||
slot,
|
||||
PC["PlayerDepositItemMenu.PlayersPCHowManyDepositText"],
|
||||
);
|
||||
if (!quantity) continue;
|
||||
if (!receivePCItem(this.state.pcItems, slot.item, quantity)) {
|
||||
yield* this.print(
|
||||
PC["PlayerDepositItemMenu.PlayersPCNoRoomDepositText"],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const bag = this.state.bag;
|
||||
if (slot.pocket === "keyItems") bag.keyItems.splice(slot.index, 1);
|
||||
else removeFromSlot(bag[slot.pocket], slot.index, slot.item, quantity);
|
||||
this.nameItem(slot.item, quantity);
|
||||
yield* this.print(PC["PlayerDepositItemMenu.PlayersPCDepositItemsText"]);
|
||||
}
|
||||
}
|
||||
|
||||
/** PlayerTossItemMenu and TossItemFromPC. */
|
||||
private *tossItems(): Gen {
|
||||
const items = this.state.pcItems;
|
||||
for (;;) {
|
||||
const index = yield {
|
||||
kind: "martlist",
|
||||
mode: "toss",
|
||||
entries: this.itemEntries(items),
|
||||
selected: 0,
|
||||
};
|
||||
const slot = typeof index === "number" ? items[index] : undefined;
|
||||
if (!slot) return;
|
||||
if (this.host.data.itemUnsellable(slot.item)) {
|
||||
yield* this.print(PC["TossItemFromPC.ItemsTooImportantText"]);
|
||||
continue;
|
||||
}
|
||||
const quantity = yield* this.quantity(
|
||||
"toss",
|
||||
slot,
|
||||
PC["TossItemFromPC.ItemsTossOutHowManyText"],
|
||||
);
|
||||
if (!quantity) continue;
|
||||
const item = slot.item;
|
||||
this.nameItem(item, quantity);
|
||||
const confirmed = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(PC["TossItemFromPC.ItemsThrowAwayText"]),
|
||||
};
|
||||
if (!confirmed) continue;
|
||||
removeFromSlot(items, index as number, item, quantity);
|
||||
this.nameItem(item, quantity);
|
||||
yield* this.print(PC["TossItemFromPC.ItemsDiscardedText"]);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- Bill's PC
|
||||
|
||||
/** GetBoxName: the saved name, or SetDefaultBoxNames' "BOX" and number. */
|
||||
boxName(box: number) {
|
||||
return (
|
||||
this.state.storage.names[box] ??
|
||||
`${this.string(PC["SetDefaultBoxNames.Box"])}${box + 1}`
|
||||
);
|
||||
}
|
||||
private monDetails(mons: { level: number }[]) {
|
||||
return [...mons.map((mon) => `:L${mon.level}`), ""];
|
||||
}
|
||||
|
||||
/** _BillsPC: the top menu until SEE YA! or B. */
|
||||
private *billsPC(): Gen {
|
||||
if (!this.state.party.length) {
|
||||
yield* this.print(PC["_BillsPC.PCGottaHavePokemonText"]);
|
||||
return;
|
||||
}
|
||||
const names = this.strings(PC["_BillsPC.strings"], 5);
|
||||
const what = this.pages(PC["_BillsPC.PCWhatText"]);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const choice = yield* this.menu(what, names, undefined, cursor);
|
||||
if (choice < 0) return;
|
||||
cursor = choice;
|
||||
if (choice === BILLSPC_WITHDRAW) yield* this.withdrawMons();
|
||||
else if (choice === BILLSPC_DEPOSIT) yield* this.depositMons();
|
||||
else if (choice === BILLSPC_CHANGE_BOX) yield* this.changeBox();
|
||||
else if (choice === BILLSPC_MOVE)
|
||||
yield {
|
||||
kind: "notice",
|
||||
text: "Moving Pokémon between boxes is not ported yet.",
|
||||
};
|
||||
else return;
|
||||
}
|
||||
}
|
||||
|
||||
/** BillsPC_CheckMail_PreventBlackout for a party Pokémon: the last one,
|
||||
* or the last that has not fainted, stays. No MAIL exists yet. */
|
||||
private *partyMonMayLeave(index: number): Gen<boolean> {
|
||||
const party = this.state.party;
|
||||
let text: number | undefined;
|
||||
if (party.length < 2) text = PC.PCString_ItsYourLastPKMN;
|
||||
else if (party.every((mon, i) => i === index || !mon.hp))
|
||||
text = PC.PCString_NoMoreUsablePKMN;
|
||||
if (text === undefined) return true;
|
||||
yield* this.placeString(this.string(text));
|
||||
return false;
|
||||
}
|
||||
/** ReleasePKMN_ByePKMN: the species name, not the nickname. */
|
||||
private *released(species: number): Gen {
|
||||
yield* this.placeString(
|
||||
this.string(PC.PCString_ReleasedPKMN),
|
||||
RELEASED_TICKS,
|
||||
);
|
||||
yield* this.placeString(
|
||||
`${this.string(PC.PCString_Bye)} ${this.host.data.speciesName(species)}!`,
|
||||
);
|
||||
}
|
||||
private *confirmRelease(): Gen<boolean> {
|
||||
return Boolean(
|
||||
yield { kind: "yesno", pages: [this.string(PC.PCString_ReleasePKMN)] },
|
||||
);
|
||||
}
|
||||
|
||||
/** _DepositPKMN: choose a party Pokémon, then DEPOSIT, STATS or RELEASE. */
|
||||
private *depositMons(): Gen {
|
||||
const party = this.state.party,
|
||||
options = this.strings(PC["BillsPCDepositMenuHeader.MenuData"] + 2, 4);
|
||||
for (;;) {
|
||||
const index = yield* this.menu(
|
||||
[
|
||||
`${this.string(PC["BillsPC_BoxName.PartyPKMN"])}\n${this.string(PC.PCString_ChooseaPKMN)}`,
|
||||
],
|
||||
[
|
||||
...party.map((mon) => mon.nickname),
|
||||
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
|
||||
],
|
||||
this.monDetails(party),
|
||||
);
|
||||
if (index < 0 || index >= party.length) return;
|
||||
for (;;) {
|
||||
const action = yield* this.menu(
|
||||
[this.string(PC.PCString_WhatsUp)],
|
||||
options,
|
||||
);
|
||||
const mon = party[index];
|
||||
if (action === SUBMENU_TAKE) {
|
||||
if (!(yield* this.partyMonMayLeave(index))) break;
|
||||
if (!depositMon(this.host.data, this.state.storage, mon)) {
|
||||
yield* this.placeString(this.string(PC.PCString_BoxFull));
|
||||
continue;
|
||||
}
|
||||
party.splice(index, 1);
|
||||
yield* this.placeString(
|
||||
`${this.string(PC.PCString_Stored)}${mon.nickname}!`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (action === SUBMENU_STATS) {
|
||||
yield { kind: "notice", text: "The stats screen is not ported yet." };
|
||||
continue;
|
||||
}
|
||||
if (action !== SUBMENU_RELEASE) break;
|
||||
if (!(yield* this.partyMonMayLeave(index))) break;
|
||||
if (mon.species === EGG) {
|
||||
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
|
||||
break;
|
||||
}
|
||||
if (!(yield* this.confirmRelease())) continue;
|
||||
party.splice(index, 1);
|
||||
yield* this.released(mon.species);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** _WithdrawPKMN: choose a Pokémon in the current box, then WITHDRAW,
|
||||
* STATS or RELEASE. */
|
||||
private *withdrawMons(): Gen {
|
||||
const options = this.strings(PC["BillsPC_Withdraw.MenuData"] + 2, 4);
|
||||
for (;;) {
|
||||
const box = currentBox(this.state.storage);
|
||||
const index = yield* this.menu(
|
||||
[
|
||||
`${this.boxName(this.state.storage.current)}\n${this.string(PC.PCString_ChooseaPKMN)}`,
|
||||
],
|
||||
[
|
||||
...box.map((mon) => mon.nickname),
|
||||
this.string(PC["BillsPC_RefreshTextboxes.CancelString"]),
|
||||
],
|
||||
this.monDetails(box),
|
||||
);
|
||||
if (index < 0 || index >= box.length) return;
|
||||
for (;;) {
|
||||
const action = yield* this.menu(
|
||||
[this.string(PC.PCString_WhatsUp)],
|
||||
options,
|
||||
);
|
||||
const mon = box[index];
|
||||
if (action === SUBMENU_TAKE) {
|
||||
if (this.state.party.length >= PARTY_LENGTH) {
|
||||
yield* this.placeString(this.string(PC.PCString_PartyFull));
|
||||
continue;
|
||||
}
|
||||
box.splice(index, 1);
|
||||
this.state.party.push(withdrawnMon(this.host.data, mon));
|
||||
yield* this.placeString(
|
||||
`${this.string(PC.PCString_Got)}${mon.nickname}!`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (action === SUBMENU_STATS) {
|
||||
yield { kind: "notice", text: "The stats screen is not ported yet." };
|
||||
continue;
|
||||
}
|
||||
if (action !== SUBMENU_RELEASE) break;
|
||||
if (mon.species === EGG) {
|
||||
yield* this.placeString(this.string(PC.PCString_NoReleasingEGGS));
|
||||
continue;
|
||||
}
|
||||
if (!(yield* this.confirmRelease())) continue;
|
||||
box.splice(index, 1);
|
||||
yield* this.released(mon.species);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** _ChangeBox: choose a box, then SWITCH, NAME, PRINT or QUIT. */
|
||||
private *changeBox(): Gen {
|
||||
const storage = this.state.storage,
|
||||
options = this.strings(PC["BillsPC_ChangeBoxSubmenu.MenuData"] + 2, 4),
|
||||
current = this.string(PC["BillsPC_PrintBoxName.Current"]),
|
||||
outOf = this.string(PC["BillsPC_PrintBoxCountAndCapacity.OutOf20"]);
|
||||
let cursor = 0;
|
||||
for (;;) {
|
||||
const names = Array.from({ length: NUM_BOXES }, (_, box) =>
|
||||
this.boxName(box),
|
||||
);
|
||||
const box = yield* this.menu(
|
||||
[
|
||||
`${current}\n${this.boxName(storage.current)}\n${this.string(PC["BillsPC_PlaceChooseABoxString.ChooseABox"])}`,
|
||||
],
|
||||
names,
|
||||
storage.boxes.map((mons) => `${mons.length}${outOf}`),
|
||||
cursor,
|
||||
);
|
||||
if (box < 0) return;
|
||||
cursor = box;
|
||||
const action = yield* this.menu(
|
||||
[this.string(PC.PCString_WhatsUp)],
|
||||
options,
|
||||
);
|
||||
if (action === BOX_SWITCH) {
|
||||
if (box === storage.current) continue;
|
||||
// ChangeBoxSaveGame: the native session saves continuously, so the
|
||||
// switch itself is what the confirmation commits.
|
||||
const confirmed = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(PC.ChangeBoxSaveText),
|
||||
};
|
||||
if (confirmed) storage.current = box;
|
||||
} else if (action === BOX_NAME) {
|
||||
const name = yield {
|
||||
kind: "name",
|
||||
pages: [this.string(PC["NamingScreenJumptable.BoxNameString"])],
|
||||
current: names[box],
|
||||
max: 8,
|
||||
};
|
||||
if (typeof name !== "string" || (name && !BOX_NAME_PATTERN.test(name)))
|
||||
throw new Error("Invalid box name.");
|
||||
// InitString: a blank name keeps the box's name.
|
||||
if (!/^ *$/.test(name)) storage.names[box] = name;
|
||||
} else if (action === BOX_PRINT) {
|
||||
if (!storage.boxes[box].length)
|
||||
yield* this.placeString(
|
||||
this.string(PC["BillsPC_PlaceEmptyBoxString_SFX.NoMonString"]),
|
||||
);
|
||||
else
|
||||
yield {
|
||||
kind: "notice",
|
||||
text: "Printing needs a Game Boy Printer, which is not supported.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- Oak's PC
|
||||
|
||||
/** ProfOaksPC: an optional Pokédex rating from OakRatings. */
|
||||
private *oaksPC(): Gen {
|
||||
const r = this.host.reader;
|
||||
const rate = yield {
|
||||
kind: "yesno",
|
||||
pages: this.pages(PC.OakPCText1),
|
||||
};
|
||||
if (rate) {
|
||||
yield* this.print(PC.OakPCText2);
|
||||
const { seen, caught } = this.state.pokedex;
|
||||
this.host.strings[RAM.stringBuffer3] = String(seen.size);
|
||||
this.host.strings[RAM.stringBuffer4] = String(caught.size);
|
||||
yield* this.print(PC.OakPCText3);
|
||||
// FindOakRating: the first entry whose count reaches the owned total.
|
||||
let entry = PC.OakRatings;
|
||||
while (r.byte(entry) < caught.size) entry += 5;
|
||||
yield* this.print(r.near(entry + 3, r.bank(entry)));
|
||||
}
|
||||
yield* this.print(PC.OakPCText4);
|
||||
}
|
||||
}
|
||||
+67
-15
@@ -10,6 +10,7 @@ import {
|
||||
INITIAL_POSITION,
|
||||
NUM_FRUIT_TREES,
|
||||
OVERWORLD,
|
||||
PC,
|
||||
SYMBOLS,
|
||||
} from "../rom/profile";
|
||||
import { OW } from "./constants";
|
||||
@@ -19,12 +20,20 @@ import {
|
||||
copyScriptState,
|
||||
emptyScriptState,
|
||||
type MartEntry,
|
||||
type MartMode,
|
||||
type ScriptEffect,
|
||||
type ScriptHost,
|
||||
type ScriptState,
|
||||
} from "../scripts/interpreter";
|
||||
import type { MovementPose, MovementStep } from "../scripts/movement";
|
||||
import { emptyBag, validBag, type Bag } from "../party/bag";
|
||||
import { emptyBag, validBag, type Bag, type ItemSlot } from "../party/bag";
|
||||
import {
|
||||
copyStorage,
|
||||
emptyStorage,
|
||||
validPCItems,
|
||||
validStorage,
|
||||
type PokemonStorage,
|
||||
} from "../party/storage";
|
||||
import {
|
||||
NICKNAME_PATTERN,
|
||||
caughtData,
|
||||
@@ -75,6 +84,8 @@ export interface NativeSave {
|
||||
playerId: number;
|
||||
party: PartyMon[];
|
||||
bag: Bag;
|
||||
storage: PokemonStorage;
|
||||
pcItems: ItemSlot[];
|
||||
pokedex: { seen: number[]; caught: number[] };
|
||||
money: number;
|
||||
fruitTrees: number[];
|
||||
@@ -186,20 +197,20 @@ export type Choice =
|
||||
/** The naming screen: an empty answer keeps `current`. */
|
||||
| { kind: "name"; current: string; max: number; selected?: undefined }
|
||||
/** A vertical menu; `selected` answers with that item's index. */
|
||||
| { kind: "menu"; items: string[]; selected: number }
|
||||
| { kind: "menu"; items: string[]; details?: string[]; selected: number }
|
||||
/** The mart's BUY/SELL/QUIT box. */
|
||||
| { kind: "martmenu"; prompt: string; selected: number }
|
||||
/** The mart's item list; `selected` answers with that entry's index. */
|
||||
| {
|
||||
kind: "martlist";
|
||||
mode: "buy" | "sell";
|
||||
mode: MartMode;
|
||||
entries: MartEntry[];
|
||||
selected: number;
|
||||
}
|
||||
/** The mart's 1-99 quantity stepper; `selected` is the current amount. */
|
||||
| {
|
||||
kind: "martquantity";
|
||||
mode: "buy" | "sell";
|
||||
mode: MartMode;
|
||||
item: number;
|
||||
name: string;
|
||||
price: number;
|
||||
@@ -263,10 +274,18 @@ interface ActiveScript {
|
||||
warp?: { map: string; x: number; y: number; facing?: Direction };
|
||||
/** warpcheck: the player's tile events run on the tick after the script. */
|
||||
warpCheck?: boolean;
|
||||
/** LoadMemScript: queued for the next PlayerEvents once this commits. */
|
||||
memScript?: MemScript;
|
||||
/** The trainer whose sight started this script, if any. */
|
||||
sighted?: number;
|
||||
}
|
||||
|
||||
/** wMapReentryScriptQueueFlag's script: work RAM, so it is not saved. */
|
||||
interface MemScript {
|
||||
script: number;
|
||||
callerScript?: number;
|
||||
}
|
||||
|
||||
const OPPOSITE: Record<Direction, Direction> = {
|
||||
down: "up",
|
||||
up: "down",
|
||||
@@ -455,6 +474,7 @@ export class GameWorld {
|
||||
notice = "";
|
||||
diagnostic = "";
|
||||
private actors = new Map<number, Actor>();
|
||||
private memScript?: MemScript;
|
||||
private currentMap?: GameMap;
|
||||
/** wCmdQueue's CMDQUEUE_STONETABLE entries for the current map. */
|
||||
private stoneTables: number[] = [];
|
||||
@@ -1731,6 +1751,7 @@ export class GameWorld {
|
||||
canLose: effect.canLose,
|
||||
text: () => this.host(PLAYER).text(),
|
||||
bag: draft.bag,
|
||||
storage: draft.storage,
|
||||
hasPokedex: draft.flags.has(ENGINE_FLAGS.pokedex),
|
||||
caughtData: (level) =>
|
||||
caughtData(
|
||||
@@ -1757,6 +1778,16 @@ export class GameWorld {
|
||||
case "warpcheck":
|
||||
script.warpCheck = true;
|
||||
return false;
|
||||
case "notice":
|
||||
this.notice = effect.text;
|
||||
return false;
|
||||
case "memscript":
|
||||
// LoadMemScript keeps a script that is already queued.
|
||||
script.memScript ??= {
|
||||
script: effect.script,
|
||||
callerScript: effect.callerScript,
|
||||
};
|
||||
return false;
|
||||
case "townmap":
|
||||
this.townMap = true;
|
||||
this.dialogue = { pages: ["Town Map"], index: 0 };
|
||||
@@ -1768,6 +1799,7 @@ export class GameWorld {
|
||||
choice: {
|
||||
kind: "menu",
|
||||
items: effect.items,
|
||||
...(effect.details && { details: effect.details }),
|
||||
selected: effect.selected,
|
||||
},
|
||||
};
|
||||
@@ -2153,6 +2185,7 @@ export class GameWorld {
|
||||
this.arrival = { x: this.x, y: this.y };
|
||||
}
|
||||
this.script = undefined;
|
||||
this.memScript ??= script.memScript;
|
||||
this.townMap = false;
|
||||
this.dialogue = undefined;
|
||||
this.emote = undefined;
|
||||
@@ -2686,6 +2719,15 @@ export class GameWorld {
|
||||
}
|
||||
if (this.busy || this.mapId !== map) return;
|
||||
} else if (this.checkTrainers()) return;
|
||||
// RunMemScript precedes RunSceneScript.
|
||||
const memScript = this.memScript;
|
||||
if (memScript) {
|
||||
this.memScript = undefined;
|
||||
this.startScript(memScript.script, PLAYER, {
|
||||
callerScript: memScript.callerScript,
|
||||
});
|
||||
if (this.busy) return;
|
||||
}
|
||||
this.checkScene();
|
||||
if (this.busy) return;
|
||||
this.checkDailyReset();
|
||||
@@ -2791,17 +2833,16 @@ export class GameWorld {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const standard: Record<number, number> = {
|
||||
0x91: 2,
|
||||
0x97: 9,
|
||||
0x9d: 8,
|
||||
0x96: 6,
|
||||
0x9f: 5,
|
||||
};
|
||||
if (standard[collision] !== undefined && this.player.direction === "up")
|
||||
script = this.assets.reader.far(
|
||||
SYMBOLS.standardScripts + standard[collision] * 3,
|
||||
);
|
||||
// CheckFacingTileForStdScript: TileCollisionStdScripts pairs a
|
||||
// collision type with a std script index, in any facing direction.
|
||||
const r = this.assets.reader;
|
||||
for (let entry = PC.TileCollisionStdScripts; ; entry += 3) {
|
||||
const type = r.byte(entry);
|
||||
if (type === 0xff) break;
|
||||
if (type !== collision) continue;
|
||||
script = r.far(SYMBOLS.standardScripts + r.word(entry + 1) * 3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (script === undefined) {
|
||||
const [fx, fy] = VECTORS[this.player.direction];
|
||||
@@ -3136,6 +3177,8 @@ export class GameWorld {
|
||||
playerId: this.playerId,
|
||||
party: structuredClone(state.party),
|
||||
bag: structuredClone(state.bag),
|
||||
storage: copyStorage(state.storage),
|
||||
pcItems: structuredClone(state.pcItems),
|
||||
pokedex: {
|
||||
seen: [...state.pokedex.seen].sort((a, b) => a - b),
|
||||
caught: [...state.pokedex.caught].sort((a, b) => a - b),
|
||||
@@ -3215,6 +3258,8 @@ export class GameWorld {
|
||||
save.party.length > 6 ||
|
||||
!save.party.every((mon) => validPartyMon(this.assets.pokemon, mon)) ||
|
||||
!validBag(this.assets.pokemon, save.bag) ||
|
||||
!validStorage(save.storage) ||
|
||||
!validPCItems(this.assets.pokemon, save.pcItems) ||
|
||||
!save.pokedex ||
|
||||
!species(save.pokedex.seen) ||
|
||||
!species(save.pokedex.caught) ||
|
||||
@@ -3288,6 +3333,8 @@ export class GameWorld {
|
||||
clock: { ...save.clock },
|
||||
party: structuredClone(save.party),
|
||||
bag: structuredClone(save.bag),
|
||||
storage: copyStorage(save.storage),
|
||||
pcItems: structuredClone(save.pcItems),
|
||||
pokedex: {
|
||||
seen: new Set(save.pokedex.seen),
|
||||
caught: new Set(save.pokedex.caught),
|
||||
@@ -3351,6 +3398,7 @@ export class GameWorld {
|
||||
this.picture = undefined;
|
||||
this.battle = undefined;
|
||||
this.battleRun = undefined;
|
||||
this.memScript = undefined;
|
||||
this.clearNotice();
|
||||
}
|
||||
}
|
||||
@@ -3376,6 +3424,8 @@ export function migrateSave(
|
||||
decorations: initialDecorations(),
|
||||
}),
|
||||
...(!Object.hasOwn(save, "money") && { money: C.START_MONEY }),
|
||||
...(!Object.hasOwn(save, "storage") && { storage: emptyStorage() }),
|
||||
...(!Object.hasOwn(save, "pcItems") && { pcItems: [] }),
|
||||
...(!Object.hasOwn(save, "fruitTrees") && { fruitTrees: [] }),
|
||||
...(!Object.hasOwn(save, "dailyReset") && { dailyReset: null }),
|
||||
...(!Object.hasOwn(save, "lastSpawnMap") && { lastSpawnMap: null }),
|
||||
@@ -3407,6 +3457,8 @@ export function migrateSave(
|
||||
playerId: newPlayerId(),
|
||||
party: [],
|
||||
bag: emptyBag(),
|
||||
storage: emptyStorage(),
|
||||
pcItems: [],
|
||||
pokedex: { seen: [], caught: [] },
|
||||
lastSpawnMap: null,
|
||||
specialPhoneCall: 0,
|
||||
|
||||
+23
-8
@@ -148,8 +148,14 @@ function showChoice(choice: Choice | undefined) {
|
||||
const option = button(item, choice.selected === index, () =>
|
||||
runtime.choose(index),
|
||||
);
|
||||
const detail = choice.details?.[index];
|
||||
if (detail) {
|
||||
const small = document.createElement("small");
|
||||
small.textContent = detail;
|
||||
option.append(small);
|
||||
}
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-label", item);
|
||||
option.setAttribute("aria-label", detail ? `${item} ${detail}` : item);
|
||||
option.setAttribute("aria-selected", String(choice.selected === index));
|
||||
return option;
|
||||
}),
|
||||
@@ -218,6 +224,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
||||
martMenuKey = "";
|
||||
return;
|
||||
}
|
||||
// The PC's item storage reuses these lists without prices.
|
||||
const shop =
|
||||
mart.kind === "martmenu" || mart.mode === "buy" || mart.mode === "sell";
|
||||
get("mart").setAttribute("aria-label", shop ? "Poké Mart" : "Item storage");
|
||||
get("mart-money").hidden = !shop;
|
||||
get("mart-money").textContent = `MONEY ¥${world.liveState.money}`;
|
||||
get("mart-prompt").textContent = mart.kind === "martlist" ? "" : mart.prompt;
|
||||
const key = JSON.stringify(mart);
|
||||
@@ -260,7 +271,11 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
||||
const buttons = mart.entries.map((entry, index) =>
|
||||
button(entry.name, () => runtime.choose(index), {
|
||||
detail:
|
||||
mart.mode === "sell" ? `have ${entry.owned}` : `¥${entry.price}`,
|
||||
mart.mode === "sell"
|
||||
? `have ${entry.owned}`
|
||||
: mart.mode === "buy"
|
||||
? `¥${entry.price}`
|
||||
: `×${entry.owned}`,
|
||||
selected: mart.selected === index,
|
||||
}),
|
||||
);
|
||||
@@ -290,7 +305,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
||||
return element;
|
||||
};
|
||||
const output = document.createElement("output");
|
||||
output.textContent = `${mart.name} ×${mart.selected} ¥${total}`;
|
||||
output.textContent = shop
|
||||
? `${mart.name} ×${mart.selected} ¥${total}`
|
||||
: `${mart.name} ×${mart.selected}`;
|
||||
stepper.append(
|
||||
step("◀◀", "Minus 10", "left"),
|
||||
step("▼", "Minus 1", "down"),
|
||||
@@ -300,11 +317,9 @@ function showMart(world: GameWorld, mart: MartChoice | undefined) {
|
||||
);
|
||||
menu.replaceChildren(
|
||||
stepper,
|
||||
button(
|
||||
mart.mode === "sell" ? "SELL" : "BUY",
|
||||
() => runtime.choose(mart.selected),
|
||||
{ wide: true },
|
||||
),
|
||||
button(mart.mode.toUpperCase(), () => runtime.choose(mart.selected), {
|
||||
wide: true,
|
||||
}),
|
||||
button("CANCEL", () => runtime.choose(-1), { wide: true }),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -76,5 +76,5 @@ export function renderShell(root: HTMLElement) {
|
||||
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
|
||||
</div>
|
||||
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
||||
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. Party and bag menus, the PC and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
|
||||
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2>From New Bark Town to Mr. Pokémon</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play.</p><p>This milestone runs from your home and Professor Elm's lab, with the original scenes and your first Pokémon, along Route 29 to Cherrygrove City and up Route 30 to Mr. Pokémon's house: wild and trainer battles, items, the Poké Mart, the Pokémon Center and the first rival battle. Catch Pokémon with Poké Balls from the battle PACK. The Pokémon Center PC stores Pokémon and items. Party and bag menus and audio are still planned.</p><dl><dt>Choose</dt><dd>Up / Down, then X or A. Z or B answers No.</dd></dl><p>An optional walking companion may support egg progress after party, eggs and hatching are complete.</p></dialog>`;
|
||||
}
|
||||
@@ -163,23 +163,108 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
|
||||
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back the whole battle when a full party would need the PC", () => {
|
||||
/** Throw balls until the battle ends, answering prompts; returns the
|
||||
* battle messages and the pages shown afterwards. */
|
||||
const catchAll = (world: GameWorld, nickname?: string) => {
|
||||
const messages: string[] = [], pages: string[] = [];
|
||||
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
|
||||
const view = world.battle;
|
||||
if (!view) {
|
||||
if (world.dialogue) {
|
||||
const page = world.dialogue.pages[world.dialogue.index];
|
||||
if (pages.at(-1) !== page) pages.push(page);
|
||||
world.advanceDialogue();
|
||||
} else world.tick();
|
||||
continue;
|
||||
}
|
||||
const text = view.message?.text;
|
||||
if (text && messages.at(-1) !== text) messages.push(text);
|
||||
const prompt = view.prompt;
|
||||
if (prompt?.kind === "action")
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
||||
else if (prompt?.kind === "yesno") world.battleChoose(nickname ? 1 : 0);
|
||||
else if (prompt?.kind === "name") world.battleName(nickname!);
|
||||
else if (view.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
return {
|
||||
messages: messages.map((text) => text.replace(/\n/g, " ")),
|
||||
pages: pages.map((text) => text.replace(/\n/g, " ")),
|
||||
};
|
||||
};
|
||||
const fullParty = (boxed = 0) => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
const mon = world.state.party[0];
|
||||
world.state.party = Array.from({ length: 6 }, () => structuredClone(mon));
|
||||
world.state.bag.balls = [{ item: C.POKE_BALL, quantity: 10 }];
|
||||
const { status: _s, hp: _h, stats: _t, ...box } = mon;
|
||||
world.state.storage.boxes[0] = Array.from({ length: boxed }, () => structuredClone(box));
|
||||
return world;
|
||||
};
|
||||
|
||||
it("sends a catch with a full party to the current box", () => {
|
||||
const world = fullParty();
|
||||
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
||||
const { messages } = catchAll(world, "PIDGE");
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("PIDGE was sent to BILL's PC.");
|
||||
expect(world.state.party).toHaveLength(6);
|
||||
// SendMonIntoBox: first in the box, with full PP and base happiness.
|
||||
const [boxed] = world.state.storage.boxes[0];
|
||||
expect(boxed).toMatchObject({ species: PIDGEY, nickname: "PIDGE", level: 3, ot: world.name, happiness: 70, statExp: [0, 0, 0, 0, 0] });
|
||||
expect(boxed).not.toHaveProperty("hp");
|
||||
const data = world.assets.pokemon;
|
||||
expect(boxed.pp).toEqual(boxed.moves.map((move) => (move ? data.move(move).pp : 0)));
|
||||
expect(boxed.caughtData >> 8).toBe((2 << 6) | 3);
|
||||
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("keeps the ball when the party and the box are full", () => {
|
||||
const world = fullParty(20);
|
||||
const before = world.save();
|
||||
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
||||
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++)
|
||||
if (world.battle?.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
||||
for (let tick = 0; tick < 200 && world.battle; tick++) {
|
||||
if (world.battle.prompt?.kind === "action")
|
||||
world.battleAction({ action: "item", item: C.POKE_BALL, slot: 0 });
|
||||
const messages: string[] = [];
|
||||
for (let tick = 0; tick < 200 && world.battle?.prompt?.kind !== "action"; tick++) {
|
||||
const text = world.battle?.message?.text;
|
||||
if (text && messages.at(-1) !== text) messages.push(text.replace(/\n/g, " "));
|
||||
if (world.battle?.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
// Ball_BoxIsFullMessage: the item is not used and the turn is not taken.
|
||||
expect(messages).toEqual(["The POKéMON BOX is full. That can't be used now."]);
|
||||
expect(world.battle?.prompt?.kind).toBe("action");
|
||||
expect(world.battle?.engine.enemy.mon.hp).toBe(world.battle?.engine.enemy.mon.maxHp);
|
||||
for (let tick = 0; tick < 2000 && world.battle; tick++) {
|
||||
if (world.battle.prompt?.kind === "action") world.battleAction({ action: "run" });
|
||||
else if (world.battle.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
}
|
||||
expect(world.battle).toBeUndefined();
|
||||
expect(world.notice).toMatch(/not been ported/);
|
||||
expect(world.state.bag).toEqual({ ...before.bag });
|
||||
expect(world.state.party).toHaveLength(6);
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.state.bag).toEqual(before.bag);
|
||||
expect(world.state.storage).toEqual(before.storage);
|
||||
});
|
||||
|
||||
it("has Bill call after a catch fills the box", () => {
|
||||
const world = fullParty(19);
|
||||
for (const direction of ENCOUNTER_WALK) expect(world.move(direction)).toBe(true);
|
||||
const { messages } = catchAll(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(messages).toContain("PIDGEY was sent to BILL's PC.");
|
||||
expect(world.state.storage.boxes[0]).toHaveLength(20);
|
||||
// Script_SpecialBillCall runs at the next PlayerEvents.
|
||||
expect(world.busy).toBe(false);
|
||||
world.tick();
|
||||
const call = settle(world).join(" ").replace(/\n/g, " ");
|
||||
expect(call).toContain("It's me, BILL!");
|
||||
expect(call).toContain("Click!");
|
||||
expect(world.busy).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,10 @@ function pc(world: GameWorld) {
|
||||
});
|
||||
world.interact();
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
// _PlayersHousePC: "<PLAYER> turned on the PC.", then _PlayersPC's menu.
|
||||
acknowledge(world);
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
choose(world, "DECORATION");
|
||||
}
|
||||
function choose(world: GameWorld, name: string) {
|
||||
const choice = world.dialogue?.choice;
|
||||
|
||||
@@ -9,7 +9,7 @@ test.beforeEach(() =>
|
||||
test.skip(!existsSync(romPath), "Requires the local user ROM."),
|
||||
);
|
||||
|
||||
async function start(page: Page, room: boolean | "poster" = false) {
|
||||
async function start(page: Page, room: boolean | "poster" | "center" = false) {
|
||||
await page.route("**/src/game/runtime.ts", async (route) => {
|
||||
const response = await route.fetch(),
|
||||
source = await response.text();
|
||||
@@ -67,8 +67,15 @@ test("the bedroom PC puts away a decoration and saves the result", async ({
|
||||
await start(page, true);
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
await expect(page.locator("#dialogue-text")).toHaveText("DECORATION");
|
||||
await expect(page.locator("#dialogue-text")).toContainText(
|
||||
"turned on the PC.",
|
||||
);
|
||||
await page.keyboard.press("KeyX");
|
||||
const choices = page.locator("#dialogue-choice");
|
||||
await choices
|
||||
.getByRole("option", { name: "DECORATION", exact: true })
|
||||
.click();
|
||||
await expect(page.locator("#dialogue-text")).toHaveText("DECORATION");
|
||||
await choices.getByRole("option", { name: "BED", exact: true }).click();
|
||||
await choices
|
||||
.getByRole("option", { name: "PUT IT AWAY", exact: true })
|
||||
@@ -116,3 +123,62 @@ test("the Town Map poster opens a native map and closes with B", async ({
|
||||
await expect(page.locator("#town-map-view")).toBeHidden();
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
});
|
||||
|
||||
test("the Pokémon Center PC deposits a Pokémon and an item", async ({
|
||||
page,
|
||||
}) => {
|
||||
await start(page, "center");
|
||||
const text = page.locator("#dialogue-text"),
|
||||
choices = page.locator("#dialogue-choice");
|
||||
await page.keyboard.press("KeyX");
|
||||
await expect(text).toContainText("turned on the PC.");
|
||||
await page.keyboard.press("KeyX");
|
||||
await choices.getByRole("option", { name: "BILL's PC", exact: true }).click();
|
||||
await expect(text).toContainText("BILL's PC");
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.keyboard.press("KeyX");
|
||||
await choices
|
||||
.getByRole("option", { name: "DEPOSIT POKéMON", exact: true })
|
||||
.click();
|
||||
await choices.getByRole("option", { name: "PIDGEY :L4" }).click();
|
||||
await choices.getByRole("option", { name: "DEPOSIT", exact: true }).click();
|
||||
await expect(text).toHaveText("Stored PIDGEY!");
|
||||
await page.clock.runFor(2000);
|
||||
await expect(choices.getByRole("option", { name: "PIDGEY :L4" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
// B leaves the party list, then Bill's PC.
|
||||
await page.keyboard.press("KeyZ");
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(choices.getByRole("option", { name: "TURN OFF" })).toBeVisible();
|
||||
await choices
|
||||
.getByRole("option", { name: /'s PC$/ })
|
||||
.last()
|
||||
.click();
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.keyboard.press("KeyX");
|
||||
await choices
|
||||
.getByRole("option", { name: "DEPOSIT ITEM", exact: true })
|
||||
.click();
|
||||
const storage = page.locator("#mart");
|
||||
await expect(storage).toHaveAttribute("aria-label", "Item storage");
|
||||
await expect(page.locator("#mart-money")).toBeHidden();
|
||||
await storage.getByRole("button", { name: /POTION/ }).click();
|
||||
await storage.getByRole("button", { name: "Plus 1", exact: true }).click();
|
||||
await storage.getByRole("button", { name: "DEPOSIT", exact: true }).click();
|
||||
await expect(text).toContainText("Deposited 2 POTION(S).");
|
||||
await page.keyboard.press("KeyX");
|
||||
await storage.getByRole("button", { name: "BACK", exact: true }).click();
|
||||
await choices.getByRole("option", { name: "LOG OFF", exact: true }).click();
|
||||
await choices.getByRole("option", { name: "TURN OFF", exact: true }).click();
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(500);
|
||||
await expect(page.locator("#game-notice")).toHaveText("");
|
||||
const { state } = await backup(page);
|
||||
expect(state.party).toHaveLength(1);
|
||||
expect(
|
||||
state.storage.boxes[0].map((mon: { species: number }) => mon.species),
|
||||
).toEqual([16]);
|
||||
expect(state.pcItems).toEqual([{ item: 18, quantity: 2 }]);
|
||||
expect(state.bag.items).toEqual([{ item: 18, quantity: 3 }]);
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
|
||||
/** `room` places the player at the bedroom PC or poster, "center" at
|
||||
* Cherrygrove's Pokémon Center PC with two Pokémon; otherwise outdoors. */
|
||||
export function foundationFixture(
|
||||
assets: CrystalAssets,
|
||||
room: boolean | "poster",
|
||||
room: boolean | "poster" | "center",
|
||||
) {
|
||||
const world = new GameWorld(
|
||||
assets,
|
||||
@@ -13,12 +16,34 @@ export function foundationFixture(
|
||||
);
|
||||
world.state.scenes["24:4"] = 1;
|
||||
world.state.bag.keyItems.push(OW.BICYCLE);
|
||||
if (room === "center") {
|
||||
world.state.party = [155, 16].map((species, i) =>
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species,
|
||||
level: 8 - i * 4,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
);
|
||||
world.state.bag.items = [{ item: 18, quantity: 5 }]; // POTION
|
||||
}
|
||||
const save = world.save();
|
||||
const [map, x, y] =
|
||||
room === "center"
|
||||
? ["26:5", 9, 2]
|
||||
: room === "poster"
|
||||
? ["24:7", 6, 1]
|
||||
: room
|
||||
? ["24:7", 2, 2]
|
||||
: ["24:4", 9, 6];
|
||||
world.restore({
|
||||
...save,
|
||||
map: room ? "24:7" : "24:4",
|
||||
x: room === "poster" ? 6 : room ? 2 : 9,
|
||||
y: room === "poster" ? 1 : room ? 2 : 6,
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
direction: "up",
|
||||
objects: [],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import {
|
||||
calcStats,
|
||||
generatePartyMon,
|
||||
validPartyMon,
|
||||
} from "../src/game/party/pokemon";
|
||||
import {
|
||||
MAX_PC_ITEMS,
|
||||
emptyStorage,
|
||||
receivePCItem,
|
||||
toBoxMon,
|
||||
validStorage,
|
||||
} from "../src/game/party/storage";
|
||||
import { removeFromSlot } from "../src/game/party/bag";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const POKEMON_CENTER = "26:5",
|
||||
CYNDAQUIL = 155,
|
||||
PIDGEY = 16,
|
||||
POTION = 18,
|
||||
MYSTERY_EGG = 69;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
/** In front of Cherrygrove's PC (COLL_PC at 9,1). */
|
||||
function fixture() {
|
||||
let seed = 5;
|
||||
const world = new GameWorld(
|
||||
assets(),
|
||||
() => new Date("2026-09-25T12:00:00"),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
const data = world.assets.pokemon;
|
||||
const mon = (species: number, level: number) =>
|
||||
generatePartyMon(data, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
});
|
||||
world.state.party = [mon(CYNDAQUIL, 8), mon(PIDGEY, 4)];
|
||||
world.state.bag.items = [{ item: POTION, quantity: 5 }];
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: POKEMON_CENTER,
|
||||
x: 9,
|
||||
y: 2,
|
||||
direction: "up",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
}
|
||||
/** Acknowledge pages and timed messages up to the next choice; returns
|
||||
* the pages seen. */
|
||||
function until(world: GameWorld) {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 500; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
if (!world.busy) return pages;
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (pages.at(-1) !== page) pages.push(page);
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) return pages;
|
||||
if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
throw new Error("No choice appeared.");
|
||||
}
|
||||
/** Choose the menu item matching `label` (the whole label, or a regex). */
|
||||
function pick(world: GameWorld, label: string | RegExp) {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "menu")
|
||||
throw new Error(`Expected a menu, got ${choice?.kind}`);
|
||||
const index = choice.items.findIndex((item) =>
|
||||
typeof label === "string" ? item === label : label.test(item),
|
||||
);
|
||||
expect(
|
||||
index,
|
||||
`${label} in ${choice.items.join(", ")}`,
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
world.choose(index);
|
||||
return until(world);
|
||||
}
|
||||
function menuItems(world: GameWorld) {
|
||||
const choice = world.dialogue?.choice;
|
||||
return choice?.kind === "menu" ? choice.items : [];
|
||||
}
|
||||
function open(world: GameWorld) {
|
||||
world.interact();
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
return until(world);
|
||||
}
|
||||
/** Leave every menu with B until the script ends. */
|
||||
function close(world: GameWorld) {
|
||||
for (let i = 0; i < 20 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
until(world);
|
||||
}
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
}
|
||||
|
||||
it("lists Bill's PC and the player's PC, and Oak's PC after the Pokédex", () => {
|
||||
const world = fixture();
|
||||
const pages = open(world);
|
||||
expect(pages).toEqual([
|
||||
`${world.name} turned on the PC.`,
|
||||
"Access whose PC?",
|
||||
]);
|
||||
expect(menuItems(world)).toEqual([
|
||||
"BILL's PC",
|
||||
`${world.name}'s PC`,
|
||||
"TURN OFF",
|
||||
]);
|
||||
expect(pick(world, "TURN OFF")).toContain("… Link closed…");
|
||||
settle(world);
|
||||
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
||||
open(world);
|
||||
expect(menuItems(world)).toEqual([
|
||||
"BILL's PC",
|
||||
`${world.name}'s PC`,
|
||||
"PROF.OAK's PC",
|
||||
"TURN OFF",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses to start without a Pokémon", () => {
|
||||
const world = fixture();
|
||||
world.state.party = [];
|
||||
world.interact();
|
||||
expect(settle(world).join(" ").replace(/\n/g, " ")).toContain(
|
||||
"Bzzzzt! You must have a POKéMON to use this!",
|
||||
);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("deposits, keeps the last Pokémon and withdraws with recalculated stats", () => {
|
||||
const world = fixture();
|
||||
const data = world.assets.pokemon;
|
||||
const pidgey = world.state.party[1];
|
||||
pidgey.hp = 1;
|
||||
pidgey.status = 0x08;
|
||||
pidgey.pp = [0, 0, 0, 0];
|
||||
pidgey.statExp = [0xffff, 0xffff, 0xffff, 0xffff, 0xffff];
|
||||
open(world);
|
||||
expect(pick(world, "BILL's PC")).toEqual([
|
||||
"BILL's PC accessed.",
|
||||
"POKéMON Storage System opened.",
|
||||
"What?",
|
||||
]);
|
||||
expect(menuItems(world)).toEqual([
|
||||
"WITHDRAW POKéMON",
|
||||
"DEPOSIT POKéMON",
|
||||
"CHANGE BOX",
|
||||
"MOVE POKéMON W/O MAIL",
|
||||
"SEE YA!",
|
||||
]);
|
||||
pick(world, /^DEPOSIT/);
|
||||
expect(world.dialogue?.pages[0]).toBe("PARTY POKéMON\nChoose a POKéMON.");
|
||||
expect(menuItems(world)).toEqual(["CYNDAQUIL", "PIDGEY", "CANCEL"]);
|
||||
const choice = world.dialogue?.choice;
|
||||
expect(choice?.kind === "menu" && choice.details).toEqual([
|
||||
":L8",
|
||||
":L4",
|
||||
"",
|
||||
]);
|
||||
pick(world, "PIDGEY");
|
||||
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
|
||||
expect(pick(world, "DEPOSIT")).toContain("Stored PIDGEY!");
|
||||
// A mid-PC save holds the committed game.
|
||||
expect(world.save().party).toHaveLength(2);
|
||||
expect(world.liveState.party).toHaveLength(1);
|
||||
const [stored] = world.liveState.storage.boxes[0];
|
||||
expect(stored).not.toHaveProperty("hp");
|
||||
// RestorePPOfDepositedPokemon restores PP; status and HP are dropped.
|
||||
expect(stored.pp).toEqual(
|
||||
stored.moves.map((move) => (move ? data.move(move).pp : 0)),
|
||||
);
|
||||
// BillsPC_CheckMail_PreventBlackout keeps the last Pokémon.
|
||||
expect(menuItems(world)).toEqual(["CYNDAQUIL", "CANCEL"]);
|
||||
pick(world, "CYNDAQUIL");
|
||||
expect(pick(world, "DEPOSIT")).toContain("It's your last POKéMON!");
|
||||
expect(pick(world, "CANCEL")).toContain("What?");
|
||||
pick(world, /^WITHDRAW/);
|
||||
expect(world.dialogue?.pages[0]).toBe("BOX1\nChoose a POKéMON.");
|
||||
pick(world, "PIDGEY");
|
||||
expect(menuItems(world)).toEqual([
|
||||
"WITHDRAW",
|
||||
"STATS",
|
||||
"RELEASE",
|
||||
"CANCEL",
|
||||
]);
|
||||
expect(pick(world, "WITHDRAW")).toContain("Got PIDGEY!");
|
||||
pick(world, "CANCEL");
|
||||
close(world);
|
||||
const back = world.state.party[1];
|
||||
expect(world.state.storage.boxes[0]).toEqual([]);
|
||||
// SendGetMonIntoFromBox: CalcMonStats with stat experience, full HP.
|
||||
expect(back.status).toBe(0);
|
||||
expect(back.hp).toBe(back.stats[0]);
|
||||
expect(back.stats).toEqual(
|
||||
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
|
||||
);
|
||||
expect(back.stats[0]).toBeGreaterThan(pidgey.stats[0]);
|
||||
expect(validPartyMon(data, back)).toBe(true);
|
||||
const saved = world.save();
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("refuses to withdraw into a full party or deposit into a full box", () => {
|
||||
const world = fixture();
|
||||
const party = world.state.party;
|
||||
world.state.party = Array.from({ length: 6 }, (_, i) =>
|
||||
structuredClone(party[i % 2]),
|
||||
);
|
||||
world.state.storage.boxes[0] = [toBoxMon(party[1])];
|
||||
open(world);
|
||||
pick(world, "BILL's PC");
|
||||
pick(world, /^WITHDRAW/);
|
||||
pick(world, "PIDGEY");
|
||||
const full = pick(world, "WITHDRAW");
|
||||
expect(full).toContain("The party's full!");
|
||||
expect(full.at(-1)).toBe("What's up?");
|
||||
pick(world, "CANCEL");
|
||||
pick(world, "CANCEL");
|
||||
world.liveState.storage.boxes[0] = Array.from({ length: 20 }, () =>
|
||||
toBoxMon(party[1]),
|
||||
);
|
||||
pick(world, /^DEPOSIT/);
|
||||
pick(world, "PIDGEY");
|
||||
expect(pick(world, "DEPOSIT")).toContain("The BOX is full.");
|
||||
close(world);
|
||||
expect(world.state.party).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("releases after confirmation and says goodbye by species", () => {
|
||||
const world = fixture();
|
||||
const nicknamed = toBoxMon(world.state.party[1]);
|
||||
nicknamed.nickname = "BIRDY";
|
||||
world.state.storage.boxes[0] = [nicknamed];
|
||||
open(world);
|
||||
pick(world, "BILL's PC");
|
||||
pick(world, /^WITHDRAW/);
|
||||
pick(world, "BIRDY");
|
||||
expect(pick(world, "RELEASE")).toEqual(["Release POKéMON?"]);
|
||||
world.choose(0);
|
||||
expect(until(world)).toContain("What's up?");
|
||||
pick(world, "RELEASE");
|
||||
world.choose(1);
|
||||
expect(until(world)).toEqual([
|
||||
"Released POKéMON.",
|
||||
"Bye, PIDGEY!",
|
||||
"BOX1 Choose a POKéMON.",
|
||||
]);
|
||||
close(world);
|
||||
expect(world.state.storage.boxes[0]).toEqual([]);
|
||||
});
|
||||
|
||||
it("switches and names boxes", () => {
|
||||
const world = fixture();
|
||||
world.state.storage.boxes[1] = [toBoxMon(world.state.party[1])];
|
||||
open(world);
|
||||
pick(world, "BILL's PC");
|
||||
pick(world, "CHANGE BOX");
|
||||
expect(world.dialogue?.pages[0]).toBe("CURRENT\nBOX1\nChoose a BOX.");
|
||||
expect(menuItems(world)).toHaveLength(14);
|
||||
const choice = world.dialogue?.choice;
|
||||
expect(choice?.kind === "menu" && choice.details?.slice(0, 2)).toEqual([
|
||||
"0/20",
|
||||
"1/20",
|
||||
]);
|
||||
pick(world, "BOX2");
|
||||
expect(menuItems(world)).toEqual(["SWITCH", "NAME", "PRINT", "QUIT"]);
|
||||
expect(pick(world, "SWITCH")).toEqual([
|
||||
"When you change a POKéMON BOX, data will be saved. OK?",
|
||||
]);
|
||||
world.choose(1);
|
||||
until(world);
|
||||
pick(world, "BOX3");
|
||||
const naming = pick(world, "NAME");
|
||||
expect(naming).toEqual(["BOX NAME?"]);
|
||||
world.choose("FAVS");
|
||||
until(world);
|
||||
pick(world, "FAVS");
|
||||
pick(world, "NAME");
|
||||
// InitString: a blank name keeps the box's name.
|
||||
world.choose(" ");
|
||||
until(world);
|
||||
pick(world, "FAVS");
|
||||
expect(pick(world, "PRINT")).toContain("There's no POKéMON.");
|
||||
close(world);
|
||||
expect(world.state.storage.current).toBe(1);
|
||||
expect(world.state.storage.names[2]).toBe("FAVS");
|
||||
open(world);
|
||||
pick(world, "BILL's PC");
|
||||
pick(world, /^WITHDRAW/);
|
||||
expect(world.dialogue?.pages[0]).toBe("BOX2\nChoose a POKéMON.");
|
||||
expect(menuItems(world)).toEqual(["PIDGEY", "CANCEL"]);
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("stores, withdraws and tosses items in the player's PC", () => {
|
||||
const world = fixture();
|
||||
world.state.bag.keyItems = [MYSTERY_EGG];
|
||||
open(world);
|
||||
expect(pick(world, `${world.name}'s PC`)).toEqual([
|
||||
"Accessed own PC.",
|
||||
"Item Storage System opened.",
|
||||
"What do you want to do?",
|
||||
]);
|
||||
expect(menuItems(world)).toEqual([
|
||||
"WITHDRAW ITEM",
|
||||
"DEPOSIT ITEM",
|
||||
"TOSS ITEM",
|
||||
"MAIL BOX",
|
||||
"LOG OFF",
|
||||
]);
|
||||
world.choose(1);
|
||||
let list = world.dialogue?.choice;
|
||||
expect(
|
||||
list?.kind === "martlist" && list.entries.map((e) => e.name),
|
||||
).toEqual(["POTION", "MYSTERY EGG"]);
|
||||
world.choose(0);
|
||||
const quantity = world.dialogue?.choice;
|
||||
expect(quantity?.kind === "martquantity" && quantity.prompt).toBe(
|
||||
"How many do you\nwant to deposit?",
|
||||
);
|
||||
world.choose(3);
|
||||
expect(until(world)).toContain("Deposited 3 POTION(S).");
|
||||
// Key items cannot be tossed, so they move one at a time.
|
||||
world.choose(1);
|
||||
expect(until(world)).toContain("Deposited 1 MYSTERY EGG(S).");
|
||||
world.choose(-1);
|
||||
until(world);
|
||||
expect(world.liveState.pcItems).toEqual([
|
||||
{ item: POTION, quantity: 3 },
|
||||
{ item: MYSTERY_EGG, quantity: 1 },
|
||||
]);
|
||||
expect(world.liveState.bag).toMatchObject({
|
||||
items: [{ item: POTION, quantity: 2 }],
|
||||
keyItems: [],
|
||||
});
|
||||
pick(world, "TOSS ITEM");
|
||||
world.choose(1);
|
||||
expect(until(world)).toContain("That's too impor- tant to toss out!");
|
||||
world.choose(0);
|
||||
world.choose(2);
|
||||
expect(until(world)).toEqual(["Throw away 2 POTION(S)?"]);
|
||||
world.choose(1);
|
||||
expect(until(world)).toContain("Discarded POTION(S).");
|
||||
world.choose(-1);
|
||||
until(world);
|
||||
pick(world, "WITHDRAW ITEM");
|
||||
world.choose(1);
|
||||
expect(until(world)).toContain("Withdrew 1 MYSTERY EGG(S).");
|
||||
world.choose(0);
|
||||
world.choose(1);
|
||||
expect(until(world)).toContain("Withdrew 1 POTION(S).");
|
||||
world.choose(-1);
|
||||
until(world);
|
||||
expect(pick(world, "MAIL BOX")).toContain("There's no MAIL here.");
|
||||
close(world);
|
||||
expect(world.state.pcItems).toEqual([]);
|
||||
expect(world.state.bag).toMatchObject({
|
||||
items: [{ item: POTION, quantity: 3 }],
|
||||
keyItems: [MYSTERY_EGG],
|
||||
});
|
||||
});
|
||||
|
||||
it("rates the Pokédex on Oak's PC", () => {
|
||||
const world = fixture();
|
||||
world.state.flags.add(ENGINE_FLAGS.pokedex);
|
||||
for (const species of [1, 2, 3, 4, 5])
|
||||
world.state.pokedex.seen.add(species);
|
||||
world.state.pokedex.caught.add(1);
|
||||
open(world);
|
||||
expect(pick(world, "PROF.OAK's PC")).toEqual([
|
||||
"PROF.OAK's PC accessed.",
|
||||
"POKéDEX Rating System opened.",
|
||||
"Want to get your POKéDEX rated?",
|
||||
]);
|
||||
world.choose(1);
|
||||
const pages = until(world).join(" ");
|
||||
expect(pages).toContain("Current POKéDEX completion level:");
|
||||
expect(pages).toContain("5 POKéMON seen 1 POKéMON owned");
|
||||
expect(pages).toContain("Look for POKéMON in grassy areas!");
|
||||
expect(pages).toContain("The link to PROF. OAK's PC closed.");
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("validates and migrates stored Pokémon and items", () => {
|
||||
const world = fixture();
|
||||
const before = world.save();
|
||||
const box = toBoxMon(world.state.party[1]);
|
||||
for (const [storage, pcItems] of [
|
||||
[{ ...before.storage, current: 14 }, before.pcItems],
|
||||
[
|
||||
{
|
||||
...before.storage,
|
||||
boxes: before.storage.boxes.map((_, i) =>
|
||||
i ? [] : Array.from({ length: 21 }, () => box),
|
||||
),
|
||||
},
|
||||
before.pcItems,
|
||||
],
|
||||
[
|
||||
{
|
||||
...before.storage,
|
||||
boxes: before.storage.boxes.map((_, i) =>
|
||||
i ? [] : [{ ...box, hp: 10 }],
|
||||
),
|
||||
},
|
||||
before.pcItems,
|
||||
],
|
||||
[
|
||||
{ ...before.storage, names: before.storage.names.map(() => " ") },
|
||||
before.pcItems,
|
||||
],
|
||||
[before.storage, [{ item: C.POKE_BALL, quantity: 100 }]],
|
||||
[
|
||||
before.storage,
|
||||
Array.from({ length: 51 }, () => ({ item: POTION, quantity: 1 })),
|
||||
],
|
||||
] as const) {
|
||||
expect(() => world.restore({ ...before, storage, pcItems })).toThrow(
|
||||
/Invalid native save/,
|
||||
);
|
||||
expect(world.save()).toEqual(before);
|
||||
}
|
||||
const old = world.save() as Partial<ReturnType<GameWorld["save"]>>;
|
||||
delete old.storage;
|
||||
delete old.pcItems;
|
||||
world.restore(old);
|
||||
expect(world.state.storage.boxes.every((box) => !box.length)).toBe(true);
|
||||
expect(world.state.storage.names.every((name) => name === null)).toBe(true);
|
||||
expect(world.state.pcItems).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PC storage rules", () => {
|
||||
it("removes from the selected stack, or else the first match", () => {
|
||||
const list = [
|
||||
{ item: 1, quantity: 99 },
|
||||
{ item: 2, quantity: 5 },
|
||||
{ item: 1, quantity: 3 },
|
||||
];
|
||||
expect(removeFromSlot(list, 2, 1, 3)).toBe(true);
|
||||
expect(list).toEqual([
|
||||
{ item: 1, quantity: 99 },
|
||||
{ item: 2, quantity: 5 },
|
||||
]);
|
||||
expect(removeFromSlot(list, 1, 1, 9)).toBe(true);
|
||||
expect(list[0]).toEqual({ item: 1, quantity: 90 });
|
||||
expect(removeFromSlot(list, 1, 2, 6)).toBe(false);
|
||||
expect(removeFromSlot(list, 0, 3, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("holds fifty stacks, filling existing stacks first", () => {
|
||||
const items = Array.from({ length: MAX_PC_ITEMS }, (_, i) => ({
|
||||
item: i + 1,
|
||||
quantity: 98,
|
||||
}));
|
||||
expect(receivePCItem(items, 7, 1)).toBe(true);
|
||||
expect(items[6].quantity).toBe(99);
|
||||
expect(receivePCItem(items, 7, 1)).toBe(false);
|
||||
expect(receivePCItem(items, 99, 1)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only complete storage", () => {
|
||||
const storage = emptyStorage();
|
||||
expect(validStorage(storage)).toBe(true);
|
||||
expect(validStorage({ ...storage, boxes: storage.boxes.slice(1) })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(validStorage({ ...storage, names: [...storage.names, null] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
validStorage({
|
||||
...storage,
|
||||
names: ["LONGNAME9", ...storage.names.slice(1)],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validStorage({
|
||||
...storage,
|
||||
names: ["GOOD ONE", ...storage.names.slice(1)],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validStorage({ ...storage, current: -1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user