Port the remaining battle items, the stats screen and hBattleTurn at the menu

- Run every non-ball item effect usable in battle from the ROM's
  ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers
  and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll.
- Read happiness changes from HappinessChanges, including the larger
  level-up gain where a Pokémon was caught.
- Model hBattleTurn at the battle menu, including the trainer AI's writes,
  and drop the status-heal refusal.
- Port the stats screen for battles and Bill's PC.
- Compare the send-out and withdraw texts, RestoreHealth, RestorePP,
  ChangeHappiness and CalcPlayerStats with the original routines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:24:55 +02:00
1 parent cfd083c16d
commit b13e09ec20
22 files changed
+1547 -171

No files matched your search

+24 -8
View File
@@ -156,9 +156,17 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move: number;
index: number;
scores: number[];
/** Whether AI_Types, AI_Status or AIDamageCalc set hBattleTurn to the
* enemy, which the battle menu then still holds. */
enemyTurn: boolean;
} {
validateTrainerAI(input.weights);
const { player, enemy, data, battleData, weights, random } = input;
let enemyTurn = false;
const damage = (move: number) => {
enemyTurn = true;
return input.damage(move);
};
const moves = enemy.mon.moves.slice(0, 4);
const end = moves.indexOf(0);
if (end >= 0) moves.length = end;
@@ -213,7 +221,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
if (random() >= 128) add(i, -2);
} else if (random() >= 30) add(i, 2);
});
if (weights & 4)
if (weights & 4) {
// AI_Types sets hBattleTurn before each move's matchup.
enemyTurn = true;
attributes.forEach((move, i) => {
const value = matchup(i);
if (!value) add(i, 10);
@@ -224,6 +234,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
)
add(i, 1);
});
}
if (weights & 8)
attributes.forEach((move, i) => {
if (!move.power) add(i, 2);
@@ -250,9 +261,9 @@ export function chooseTrainerMove(input: TrainerAIInput): {
highest = 0;
attributes.forEach((move, i) => {
if (!move.power) return;
const damage = input.damage(moves[i]);
if (damage >= highest) {
highest = damage;
const value = damage(moves[i]);
if (value >= highest) {
highest = value;
strongest = i;
}
});
@@ -287,8 +298,13 @@ export function chooseTrainerMove(input: TrainerAIInput): {
move.effect === C.EFFECT_PARALYZE ||
move.power
) {
if ((poison && player.mon.types.includes(C.POISON)) || !matchup(i))
add(i, 10);
// Every path but a poison move against a Poison type sets
// hBattleTurn before BattleCheckTypeMatchup.
if (poison && player.mon.types.includes(C.POISON)) add(i, 10);
else {
enemyTurn = true;
if (!matchup(i)) add(i, 10);
}
}
});
if (weights & 512) {
@@ -302,7 +318,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
}
// The original subtracts damage from HP and requires a borrow, so exact
// damage equality is not treated as a knockout here.
if (input.damage(moves[i]) > player.mon.hp) add(i, -5);
if (damage(moves[i]) > player.mon.hp) add(i, -5);
});
}
// A zero score would wrap to 255 on the first decrement in the ROM.
@@ -311,7 +327,7 @@ export function chooseTrainerMove(input: TrainerAIInput): {
for (let attempt = 0; attempt < 4096; attempt++) {
const index = random() & 3;
if (index < moves.length && effective[index] === minimum)
return { move: moves[index], index, scores };
return { move: moves[index], index, scores, enemyTurn };
}
throw new UnsupportedBattle(
"Trainer move selection random source did not produce a candidate.",
+375 -107
View File
@@ -14,6 +14,7 @@ import {
PARTY_LENGTH,
calcStats,
levelForExp,
maxPP,
type PartyMon,
} from "../party/pokemon";
import { readString, readTextEnd, type TextContext } from "../scripts/text";
@@ -112,7 +113,11 @@ export type BattleEffect =
/** The battle party menu: answered with a party index, or -1 to go back
* when `cancel` allows it. With `menu`, the choice goes through the
* SWITCH/STATS/CANCEL submenu (BattleMonMenu) first. */
| { kind: "party"; text: string; menu: boolean; cancel: boolean };
| { kind: "party"; text: string; menu: boolean; cancel: boolean }
/** MoveSelectionScreen for an item: a move slot (0-3), or -1 to go back. */
| { kind: "move"; text: string; moves: number[]; pp: number[] };
/** An item from a pack slot (wCurItem and wCurItemQuantity). */
type ItemUse = { item: number; slot: number };
export type BattleAction =
| { action: "fight"; move: number }
| { action: "run" }
@@ -162,6 +167,8 @@ export interface BattleOptions {
caughtData?: (level: number) => number;
/** BATTLETYPE_TUTORIAL: the Dude's catching demonstration. */
tutorial?: boolean;
/** GetWorldMapLocation for the current map (LevelUpHappinessMod). */
landmark?: number;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
@@ -324,6 +331,63 @@ export function catchRate(input: {
return chance;
}
/** HP × 25 / (max HP / 4) as a byte, as SendOutMonText and WithdrawMonText
* compute it. Undefined when max HP / 4 is zero: the original divides by
* zero and freezes. */
function cheerPercent(hp: number, maxHp: number): number | undefined {
const divisor = (maxHp >> 2) & 0xff;
return divisor ? Math.floor((hp * 25) / divisor) & 0xff : undefined;
}
/** SendOutMonText's cheer from the enemy's remaining HP ("Go!" when it
* has fainted). */
export function sendOutText(hp: number, maxHp: number) {
if (!hp) return "GoMonText" as const;
const percent = cheerPercent(hp, maxHp);
if (percent === undefined) return undefined;
return percent >= 70
? ("GoMonText" as const)
: percent >= 40
? ("DoItMonText" as const)
: percent >= 10
? ("GoForItMonText" as const)
: ("YourFoesWeakGetmMonText" as const);
}
/** WithdrawMonText's choice from the HP lost since the send-out, in 16
* bits. The multiplicand's high byte is left over from earlier arithmetic
* in the original; it is taken as zero here. */
export function withdrawText(hpAtSwitch: number, hp: number, maxHp: number) {
const percent = cheerPercent((hpAtSwitch - hp) & 0xffff, maxHp);
if (percent === undefined) return undefined;
return !percent
? ("ThatsEnoughComeBackText" as const)
: percent < 30
? ("ComeBackText" as const)
: percent < 70
? ("OKComeBackText" as const)
: ("GoodComeBackText" as const);
}
/** RestoreHealth: a 16-bit sum that overflows or passes the maximum is
* the maximum. */
export function restoreHealth(hp: number, maxHp: number, amount: number) {
const sum = hp + amount;
return sum > 0xffff || sum > maxHp ? maxHp : sum;
}
/** RestorePP's new PP byte (PP Ups kept), or undefined when the move's PP
* is already at `max` (GetMaxPPOfMove). MAX items fill the move; others add
* 10, or 5 for the MysteryBerry. */
export function restoredPP(pp: number, max: number, item: number) {
const current = pp & C.PP_MASK;
if (current >= max) return undefined;
const full = item === C.MAX_ELIXER || item === C.MAX_ETHER;
return (pp & ~C.PP_MASK) | (full ? max : Math.min(max, current + (item === C.MYSTERYBERRY ? 5 : 10)));
}
/** ChangeHappiness' arithmetic with a HappinessChanges byte: bytes from
* $64 up (`cp $64`) are decreases that stop at zero. */
export function changedHappiness(happiness: number, delta: number) {
const sum = happiness + delta;
return delta >= 0x64 ? (sum > 0xff ? sum & 0xff : 0) : Math.min(0xff, sum);
}
export class Battle {
readonly data: BattleData;
readonly player = new Side();
@@ -338,7 +402,10 @@ export class Battle {
battlePlayerAction = 0;
enemyGoesFirst = 0;
numFleeAttempts = 0;
/** wBattleParticipantsNotFainted, which shares the experience. */
participants = 0;
/** wBattleParticipantsIncludingFainted, which revives consult. */
private participantsAll = 0;
/** wEnemyMonBaseStats: five base stats, catch rate and base exp. */
enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 };
weather: number = C.WEATHER_NONE;
@@ -357,6 +424,9 @@ export class Battle {
private battleHasJustStarted = 0;
/** wCurPartyMon: the party index a menu picked. */
private curPartyMon = 0;
/** wPartyMenuCursor: the 1-based cursor of the last party menu, which
* no ported field menu sets before a battle. */
private partyMenuCursor = 0;
/** wLastPlayerMon: the Pokémon that was out before the last switch. */
lastPlayerMon = 0;
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
@@ -434,11 +504,12 @@ export class Battle {
asm: (offset) => this.textAsm(offset),
};
}
/** StdBattleTextbox / BattleTextbox with a ROM text. */
private *say(address: number): Gen {
/** StdBattleTextbox / BattleTextbox with a ROM text. `button` waits for
* a button afterwards even without a prompt (WaitPressAorB_BlinkCursor). */
private *say(address: number, button = false): Gen {
const { text, prompt } = readTextEnd(this.reader, address, this.context());
// The tutorial's auto input presses A at every prompt.
const waits = prompt && this.battleType !== C.BATTLETYPE_TUTORIAL;
const waits = (prompt || button) && this.battleType !== C.BATTLETYPE_TUTORIAL;
for (const page of text.split("\n\n").filter(Boolean))
yield { kind: "text", text: page, prompt: waits };
}
@@ -483,18 +554,9 @@ export class Battle {
case at("YourFoesWeakGetmMonText"):
return BATTLE.BattleMonNicknameText;
case at("WithdrawMonText.WithdrawMonText"): {
// The enemy's HP lost since the send-out, as a percentage. The
// multiplicand's high byte is left over from earlier arithmetic;
// it is taken as zero here.
const lost = (this.enemyHPAtSwitch - this.enemy.mon.hp) & 0xffff;
const percent = this.hpPercent(lost);
return !percent
? BATTLE.ThatsEnoughComeBackText
: percent < 30
? BATTLE.ComeBackText
: percent < 70
? BATTLE.OKComeBackText
: BATTLE.GoodComeBackText;
const text = withdrawText(this.enemyHPAtSwitch, this.enemy.mon.hp, this.enemy.mon.maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
return BATTLE[text];
}
case at("Text_1_2_and_Poof"):
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
@@ -693,7 +755,7 @@ export class Battle {
yield* this.say(BATTLE.WantsToBattleText);
yield* this.say(BATTLE.BattleText_EnemySentOut);
} else yield* this.say(BATTLE.WildPokemonAppearedText);
this.participants = 1 << this.curBattleMon;
this.resetBattleParticipants();
this.initBattleMon();
const amuletCoin =
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
@@ -701,6 +763,7 @@ export class Battle {
this.newStatus(this.enemy);
yield* this.sendOutMonText();
yield this.hud();
this.turn = 0; // SetPlayerTurn before SpikesDamage
yield* this.battleTurn();
if (trainer && (this.battleResult & 0x3f) === C.WIN) {
yield* this.say(BATTLE.BattleText_EnemyWasDefeated);
@@ -749,6 +812,7 @@ export class Battle {
});
this.enemy.moveNum = choice.index;
this.enemy.curMove = choice.move;
if (choice.enemyTurn) this.turn = 1;
}
if (!this.checkPlayerLockedIn()) {
for (;;) {
@@ -892,14 +956,65 @@ export class Battle {
yield* this.say(BATTLE.OakThisIsntTheTimeText);
return false;
}
// ItemEffects: the routine this item runs.
// ItemEffects: the routine this item runs, chosen by its ROM address.
const effect = this.reader.near(BATTLE.ItemEffects + (item - 1) * 2, this.reader.bank(BATTLE.ItemEffects));
if (context === C.ITEMMENU_PARTY && effect === BATTLE.RestoreHPEffect)
return yield* this.restoreHPEffect(item, slot);
if (context === C.ITEMMENU_PARTY && effect === BATTLE.StatusHealingEffect)
return yield* this.statusHealingEffect(item, slot);
const use = { item, slot };
if (context === C.ITEMMENU_PARTY) {
// BattlePack's .BattleField: anything but success stays in the pack.
switch (effect) {
case BATTLE.RestoreHPEffect:
return yield* this.statusHealerResult(yield* this.itemRestoreHP(use));
case BATTLE.StatusHealingEffect:
return yield* this.statusHealingEffect(use);
case BATTLE.FullRestoreEffect:
return yield* this.fullRestoreEffect(use);
case BATTLE.ReviveEffect:
return yield* this.reviveEffect(use, 0);
case BATTLE.RevivalHerbEffect:
return yield* this.reviveEffect(use, C.HAPPINESS_REVIVALHERB);
case BATTLE.EnergypowderEffect:
case BATTLE.EnergyRootEffect:
return yield* this.bitterRestoreHP(use, effect === BATTLE.EnergyRootEffect ? C.HAPPINESS_ENERGYROOT : C.HAPPINESS_BITTERPOWDER);
case BATTLE.HealPowderEffect:
return yield* this.healPowderEffect(use);
case BATTLE.BitterBerryEffect:
return yield* this.bitterBerryEffect(use);
case BATTLE.RestorePPEffect:
return yield* this.restorePPEffect(use);
}
} else if (context !== C.ITEMMENU_CURRENT) {
// BattlePack's .BattleOnly: failure gives Oak's warning, and the
// "won't have any effect" value 2 returns to the pack.
let result: number | undefined;
switch (effect) {
case BATTLE.XItemEffect:
result = yield* this.xItemEffect(use);
break;
case BATTLE.XAccuracyEffect:
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_X_ACCURACY);
break;
case BATTLE.GuardSpecEffect:
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_MIST);
break;
case BATTLE.DireHitEffect:
result = yield* this.setPlayerSubstatusItem(use, C.SUBSTATUS_FOCUS_ENERGY);
break;
case BATTLE.PokeDollEffect:
result = yield* this.pokeDollEffect(use);
break;
}
if (result !== undefined) {
if (!result) yield* this.say(BATTLE.OakThisIsntTheTimeText);
return result === 1;
}
}
throw new UnsupportedBattle(`${data.itemName(item)} is not ported yet.`);
}
/** StatusHealer_Jumptable: 0 used, 1 "won't have any effect", 2 cancelled. */
private *statusHealerResult(code: number): Gen<boolean> {
if (code === 1) yield* this.say(BATTLE.ItemWontHaveEffectText);
return code === 0;
}
/** UseItem_SelectMon: the party menu for an item. Returns the party
* index, or -1 when cancelled or an Egg was chosen. */
private *useItemSelectMon(): Gen<number> {
@@ -911,77 +1026,231 @@ export class Battle {
}
return slot;
}
/** ItemRestoreHP (RestoreHPEffect): HealingHPAmounts, up to the maximum.
* The Pokémon out takes the party HP, which UpdateBattleMonInParty
* copied at the start of the turn. */
private *restoreHPEffect(item: number, slot: number): Gen<boolean> {
/** UseItemText: "<PLAYER> used <ITEM>!", a button, then UseDisposableItem. */
private *useItemText({ item, slot }: ItemUse): Gen {
yield* this.say(BATTLE.ItemUsedText, true);
this.tossItem("items", item, slot);
}
/** ItemRestoreHP: HealingHPAmounts, up to the maximum. The Pokémon out
* takes the party HP, which UpdateBattleMonInParty copied at the start of
* the turn. Returns the StatusHealer_Jumptable value. */
private *itemRestoreHP({ item, slot }: ItemUse): Gen<number> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
if (target < 0) return 2;
const mon = this.options.party[target];
if (!mon.hp || mon.hp >= mon.stats[0]) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
if (!mon.hp || mon.hp >= mon.stats[0]) return 1;
const r = this.reader;
let amount: number | undefined;
for (let p = BATTLE.HealingHPAmounts; r.byte(p) !== 0xff; p += 3)
if (r.byte(p) === item) amount = r.word(p + 1);
if (amount === undefined) throw new UnsupportedBattle("Missing healing amount.");
// RestoreHealth: a 16-bit sum that overflows or passes the maximum is full.
const before = mon.hp,
sum = mon.hp + amount;
mon.hp = sum > 0xffff || sum > mon.stats[0] ? mon.stats[0] : sum;
if (target === this.curBattleMon) this.player.mon.hp = mon.hp;
yield this.hud();
this.numbers[RAM.curHPAnimDeltaHP] = mon.hp - before;
yield* this.partyMenuActionText(target, C.PARTYMENUTEXT_HEAL_HP);
yield* this.restoreHP(target, restoreHealth(mon.hp, mon.stats[0], amount), C.PARTYMENUTEXT_HEAL_HP);
this.tossItem("items", item, slot);
return true;
return 0;
}
/** StatusHealingEffect and UseStatusHealer, with StatusHealingActions. */
private *statusHealingEffect(item: number, slot: number): Gen<boolean> {
/** The HP change, BattlemonRestoreHealth and the party menu action text
* with wCurHPAnimDeltaHP. */
private *restoreHP(target: number, hp: number, text: number): Gen {
const mon = this.options.party[target];
this.numbers[RAM.curHPAnimDeltaHP] = hp - mon.hp;
mon.hp = hp;
if (target === this.curBattleMon) this.player.mon.hp = hp;
yield this.hud();
yield* this.partyMenuActionText(target, text);
}
/** EnergypowderEffect and EnergyRootEffect: ItemRestoreHP, then the
* bitter taste costs happiness. */
private *bitterRestoreHP(use: ItemUse, happiness: number): Gen<boolean> {
const code = yield* this.itemRestoreHP(use);
if (!code) {
this.changeHappiness(this.curPartyMon, happiness);
yield* this.say(BATTLE.ItemLooksBitterText);
}
return yield* this.statusHealerResult(code);
}
/** StatusHealingEffect. */
private *statusHealingEffect(use: ItemUse): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target],
r = this.reader;
return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
}
/** HealPowderEffect: a status heal that tastes bitter. */
private *healPowderEffect(use: ItemUse): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const code = yield* this.useStatusHealer(use, target);
if (!code) {
this.changeHappiness(target, C.HAPPINESS_BITTERPOWDER);
yield* this.say(BATTLE.ItemLooksBitterText);
}
return yield* this.statusHealerResult(code);
}
/** GetItemHealingAction: StatusHealingActions' text and status mask. */
private healingAction(item: number) {
const r = this.reader;
let p = BATTLE.StatusHealingActions;
while (r.byte(p) !== item) {
if (r.byte(p) === 0xff) throw new UnsupportedBattle("Missing status healing action.");
p += 3;
}
let text = r.byte(p + 1);
const mask = r.byte(p + 2),
battleMon = target === this.curBattleMon;
if (!mon.hp) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
return { text: r.byte(p + 1), mask: r.byte(p + 2) };
}
/** UseStatusHealer: 0 if used, 1 if it would have no effect. */
private *useStatusHealer({ item, slot }: ItemUse, target: number): Gen<number> {
const mon = this.options.party[target];
if (!mon.hp) return 1;
let { text, mask } = this.healingAction(item);
if (!(mon.status & mask)) {
// IsItemUsedOnConfusedMon: a full heal still cures the confusion of
// the Pokémon out.
if (!(battleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED))) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return false;
}
if (!(target === this.curBattleMon && mask === 0xff && this.player.has(SUB3, C.SUBSTATUS_CONFUSED)))
return 1;
text = C.PARTYMENUTEXT_HEAL_CONFUSION;
}
mon.status = 0;
if (battleMon) {
// HealStatus
this.player.mon.status = 0;
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.calcPlayerStats();
}
this.healStatus(target, mask);
yield this.hud();
yield* this.partyMenuActionText(target, text);
this.tossItem("items", item, slot);
return 0;
}
/** HealStatus: the Pokémon out loses its status, Toxic and Nightmare
* (and confusion with a full heal), then CalcPlayerStats. */
private healStatus(target: number, mask: number) {
if (target !== this.curBattleMon) return;
this.player.mon.status = 0;
this.player.reset(SUB5, C.SUBSTATUS_TOXIC);
this.player.reset(SUB1, C.SUBSTATUS_NIGHTMARE);
if (mask === 0xff) this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.calcPlayerStats();
}
/** FullRestoreEffect: full HP and no status; at full HP only the status. */
private *fullRestoreEffect(use: ItemUse): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target];
if (!mon.hp) return yield* this.statusHealerResult(1);
if (mon.hp >= mon.stats[0]) return yield* this.statusHealerResult(yield* this.useStatusHealer(use, target));
mon.status = 0;
this.healStatus(target, this.healingAction(use.item).mask);
yield* this.restoreHP(target, mon.stats[0], C.PARTYMENUTEXT_HEAL_HP);
this.tossItem("items", use.item, use.slot);
return true;
}
/** ReviveEffect and RevivalHerbEffect (RevivePokemon). A revived
* Pokémon that took part in the battle shares the experience again. */
private *reviveEffect(use: ItemUse, happiness: number): Gen<boolean> {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target];
if (mon.hp) return yield* this.statusHealerResult(1);
if (this.participantsAll & (1 << target)) this.participants |= 1 << target;
// ReviveHalfHP halves the maximum HP; the others restore all of it.
yield* this.restoreHP(target, use.item === C.REVIVE ? mon.stats[0] >> 1 : mon.stats[0], C.PARTYMENUTEXT_REVIVE);
this.tossItem("items", use.item, use.slot);
if (happiness) {
this.changeHappiness(target, happiness);
yield* this.say(BATTLE.ItemLooksBitterText);
}
return true;
}
/** BitterBerryEffect: cures the confusion of the Pokémon out. */
private *bitterBerryEffect(use: ItemUse): Gen<boolean> {
if (!this.player.has(SUB3, C.SUBSTATUS_CONFUSED)) return yield* this.statusHealerResult(1);
this.player.reset(SUB3, C.SUBSTATUS_CONFUSED);
this.turn = 0;
yield* this.useItemText(use);
yield* this.say(BATTLE.ConfusedNoMoreText);
return true;
}
/** RestorePPEffect for Ethers, Elixers and the MysteryBerry. Choosing no
* move goes back to the party menu. */
private *restorePPEffect(use: ItemUse): Gen<boolean> {
const data = this.options.data,
item = use.item;
for (;;) {
const target = yield* this.useItemSelectMon();
if (target < 0) return false;
const mon = this.options.party[target];
let restored = false;
if (item === C.ELIXER || item === C.MAX_ELIXER) {
for (let slot = 0; slot < 4; slot++)
if (mon.moves[slot] && this.restorePP(mon, slot, item)) restored = true;
} else {
const choice = yield {
kind: "move",
text: readTextEnd(this.reader, BATTLE.RestoreThePPOfWhichMoveText, this.context()).text,
moves: [...mon.moves],
pp: [...mon.pp],
};
if (typeof choice !== "number" || choice < 0 || choice > 3 || !mon.moves[choice]) continue;
// GetMoveName and CopyName1: the move's name in wStringBuffer2.
this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = data.moveName(mon.moves[choice]);
restored = this.restorePP(mon, choice, item);
}
if (!restored) return yield* this.statusHealerResult(1);
// BattleRestorePP: the Pokémon out takes the PP of matching moves,
// unless it is transformed.
if (target === this.curBattleMon && !this.player.has(SUB5, C.SUBSTATUS_TRANSFORMED))
this.player.mon.moves.forEach((move, slot) => {
if (move && move === mon.moves[slot]) this.player.mon.pp[slot] = mon.pp[slot];
});
yield* this.say(BATTLE.PPRestoredText);
this.tossItem("items", item, use.slot);
return true;
}
}
/** RestorePP: MAX items fill the move; others add 10 (the MysteryBerry
* 5) up to GetMaxPPOfMove. Returns false if the PP was already full. */
private restorePP(mon: PartyMon, slot: number, item: number) {
const max = maxPP(this.options.data, mon.moves[slot], mon.pp[slot] >> 6) & C.PP_MASK;
const pp = restoredPP(mon.pp[slot], max, item);
if (pp === undefined) return false;
mon.pp[slot] = pp;
return true;
}
/** XItemEffect: the item is used, then RaiseStat on the player's side,
* even if the stat cannot rise, and a small happiness gain. */
private *xItemEffect(use: ItemUse): Gen<number> {
yield* this.useItemText(use);
const r = this.reader;
let p = BATTLE.XItemStats;
while (r.byte(p) !== use.item) p += 2;
this.turn = 0;
this.attackMissed = 0;
this.effectFailed = 0;
this.raiseStat(r.byte(p + 1));
yield this.hud();
yield* this.statUpMessage();
yield* this.statUpFailText();
this.changeHappiness(this.curBattleMon, C.HAPPINESS_USEDXITEM);
return 1;
}
/** XAccuracyEffect, GuardSpecEffect and DireHitEffect: a SubStatus4 flag
* that can be set once (WontHaveAnyEffect_NotUsedMessage returns 2). */
private *setPlayerSubstatusItem(use: ItemUse, bit: number): Gen<number> {
if (this.player.has(SUB4, bit)) {
yield* this.say(BATTLE.ItemWontHaveEffectText);
return 2;
}
this.player.sub[SUB4] |= 1 << bit;
yield* this.useItemText(use);
return 1;
}
/** PokeDollEffect: ends a wild battle as a draw (wForcedSwitch); in a
* trainer battle it fails. */
private *pokeDollEffect(use: ItemUse): Gen<number> {
if (this.battleMode !== C.WILD_BATTLE) return 0;
this.battleResult = (this.battleResult & 0xc0) | C.DRAW;
yield* this.useItemText(use);
this.battleEnded = true;
return 1;
}
/** CalcPlayerStats: stat stages, then the paralysis and burn penalties
* for the side that hBattleTurn names after BattleCommand_SwitchTurn,
* which is the enemy if hBattleTurn held the enemy's turn. */
* which is the enemy if hBattleTurn held the enemy's turn. At the battle
* menu that happens after a full turn (HandleHealingItems ends on the
* enemy) and after a trainer AI layer set it. */
private calcPlayerStats() {
const player = this.player;
for (let stat = 0; stat < 5; stat++) {
@@ -989,13 +1258,7 @@ export class Battle {
const value = Math.floor((player.stats[stat] * numerator) / denominator);
player.mon.stats[stat] = value === 0 ? 1 : Math.min(999, value);
}
const side = this.turn === 0 ? this.player : this.enemy;
// hBattleTurn at the battle menu is not modelled everywhere (AIDamageCalc
// leaves it on the enemy), so a penalty that would hit the enemy again
// is refused rather than guessed.
if (side === this.enemy && this.enemy.mon.status & ((1 << C.PAR) | (1 << C.BRN)))
throw new UnsupportedBattle("Healing status while the enemy is paralyzed or burned is not ported yet.");
this.applyStatusEffects(side);
this.applyStatusEffects(this.turn === 0 ? this.player : this.enemy);
}
/** PrintPartyMenuActionText with the Pokémon's nickname. */
private *partyMenuActionText(target: number, text: number): Gen {
@@ -2590,7 +2853,7 @@ export class Battle {
this.breakAttraction();
if (setMode) yield* this.enemySwitchSetMode();
else yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon; // ResetBattleParticipants
this.resetBattleParticipants();
this.turn = 1;
if (this.enemy.screens & (1 << C.SCREENS_SPIKES))
throw new UnsupportedBattle("Spikes are not ported yet.");
@@ -2605,7 +2868,7 @@ export class Battle {
yield* this.say(BATTLE.BattleText_EnemySentOut);
yield this.hud();
if (!switching) return;
this.participants = 0;
this.participants = this.participantsAll = 0;
this.battlePlayerAction = 0;
this.enemyIsSwitching = 1;
yield* this.playerSwitch();
@@ -2662,7 +2925,10 @@ export class Battle {
for (;;) {
const answer = yield { kind: "party", text: prompt, menu, cancel };
if (typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < this.options.party.length)
{
this.partyMenuCursor = answer + 1; // PartyMenuSelect
return (this.curPartyMon = answer);
}
if (cancel) return -1;
}
}
@@ -2733,7 +2999,7 @@ export class Battle {
/** The send-out shared by BattleMonEntrance, ForcePlayerMonChoice and
* PlayerPartyMonEntrance. */
private *sendOutPlayerMon(): Gen {
this.participants |= 1 << this.curBattleMon; // AddBattleParticipant
this.addBattleParticipant();
this.initBattleMon();
yield* this.sendOutMonText();
this.newStatus(this.player); // with ResetPlayerStatLevels
@@ -2745,28 +3011,11 @@ export class Battle {
}
/** SendOutMonText: the cheer depends on the enemy's remaining HP. */
private *sendOutMonText(): Gen {
const hp = this.enemy.mon.hp;
let text: number = BATTLE.GoMonText;
if (hp) {
this.enemyHPAtSwitch = hp;
const percent = this.hpPercent(hp);
if (percent < 70)
text =
percent >= 40
? BATTLE.DoItMonText
: percent >= 10
? BATTLE.GoForItMonText
: BATTLE.YourFoesWeakGetmMonText;
}
yield* this.say(text);
}
/** HP × 25 / (max HP / 4), low byte, as the send-out and withdraw texts
* compute it. A maximum HP below 4 divides by zero, which freezes the
* original. */
private hpPercent(hp: number) {
const divisor = (this.enemy.mon.maxHp >> 2) & 0xff;
if (!divisor) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
return Math.floor((hp * 25) / divisor) & 0xff;
const { hp, maxHp } = this.enemy.mon;
if (hp) this.enemyHPAtSwitch = hp;
const text = sendOutText(hp, maxHp);
if (!text) throw new UnsupportedBattle("A maximum HP below 4 freezes the original here.");
yield* this.say(BATTLE[text]);
}
/** AskUseNextPokemon: only wild battles ask. NO tries to run, measured
* with the first party Pokémon's Speed. Returns true if the player fled. */
@@ -2945,7 +3194,7 @@ export class Battle {
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
this.player.reset(SUB1, C.SUBSTATUS_IN_LOVE);
yield* this.enemySwitch();
this.participants = 1 << this.curBattleMon;
this.resetBattleParticipants();
this.battleHasJustStarted = 0;
}
@@ -3129,17 +3378,32 @@ export class Battle {
this.player.mon.status = 0;
this.updateBattleMonInParty();
// ChangeHappiness: fainting, or fainting to a much stronger foe.
const mon = this.partyMon;
const strong = this.enemy.mon.level >= this.player.mon.level + 30;
const change = this.happinessChange(strong ? "beatenByStrongFoe" : "fainted", mon.happiness);
mon.happiness = Math.max(0, Math.min(255, mon.happiness + change));
this.changeHappiness(this.curBattleMon, strong ? C.HAPPINESS_BEATENBYSTRONGFOE : C.HAPPINESS_FAINTED);
this.battleResult = (this.battleResult & 0xc0) + C.LOSE;
}
/** HappinessChanges: changes by current happiness (<100, <200, else). */
private happinessChange(kind: "fainted" | "beatenByStrongFoe" | "levelUp", happiness: number) {
const tier = happiness < 100 ? 0 : happiness < 200 ? 1 : 2;
const rows = { levelUp: [5, 3, 2], fainted: [-1, -1, -1], beatenByStrongFoe: [-5, -5, -10] };
return rows[kind][tier];
/** ChangeHappiness: HappinessChanges by tier (below 100, below 200,
* above). Eggs are skipped. In battle the battle Pokémon's copy follows
* only when wCurPartyMon equals wPartyMenuCursor, which is 1-based. */
private changeHappiness(index: number, action: number) {
const mon = this.options.party[index];
if (mon.species === C.EGG) return;
const tier = mon.happiness < 100 ? 0 : mon.happiness < 200 ? 1 : 2;
mon.happiness = changedHappiness(
mon.happiness,
this.reader.byte(BATTLE.HappinessChanges + (action - 1) * 3 + tier),
);
if (index === this.partyMenuCursor) this.player.mon.happiness = mon.happiness;
}
/** ResetBattleParticipants: only the Pokémon out, in both sets. */
private resetBattleParticipants() {
this.participants = this.participantsAll = 0;
this.addBattleParticipant();
}
/** AddBattleParticipant. */
private addBattleParticipant() {
this.participants |= 1 << this.curBattleMon;
this.participantsAll |= 1 << this.curBattleMon;
}
private *lostBattle(): Gen {
this.battleEnded = true;
@@ -3210,7 +3474,11 @@ export class Battle {
side.stats = mon.stats.slice(1);
this.calcStats(side);
}
mon.happiness = Math.min(255, mon.happiness + this.happinessChange("levelUp", mon.happiness));
// LevelUpHappinessMod: more where it was caught.
this.changeHappiness(
index,
(mon.caughtData & 0x7f) === this.options.landmark ? C.HAPPINESS_GAINLEVELATHOME : C.HAPPINESS_GAINLEVEL,
);
this.strings[RAM.stringBuffer1] = mon.nickname;
this.numbers[RAM.curPartyLevel] = newLevel;
yield* this.say(BATTLE.BattleText_StringBuffer1GrewToLevel);
@@ -3223,7 +3491,7 @@ export class Battle {
};
for (let level = oldLevel + 1; level <= newLevel; level++) yield* this.learnLevelMoves(mon, index, level);
}
this.participants = 1 << this.curBattleMon;
this.resetBattleParticipants();
}
/** CalcLevel: the highest level whose exp does not exceed the total. */
private levelForExp(species: number, exp: number) {
+14
View File
@@ -241,6 +241,20 @@ export const C = {
ITEMMENU_PARTY: 5,
PARTYMENUTEXT_HEAL_HP: 0xf5,
PARTYMENUTEXT_HEAL_CONFUSION: 0xf9,
PARTYMENUTEXT_REVIVE: 0xf7,
HAPPINESS_GAINLEVEL: 0x01,
HAPPINESS_USEDXITEM: 0x03,
HAPPINESS_FAINTED: 0x06,
HAPPINESS_BEATENBYSTRONGFOE: 0x08,
HAPPINESS_BITTERPOWDER: 0x0f,
HAPPINESS_ENERGYROOT: 0x10,
HAPPINESS_REVIVALHERB: 0x11,
HAPPINESS_GAINLEVELATHOME: 0x13,
REVIVE: 0x27,
ELIXER: 0x41,
MAX_ELIXER: 0x15,
MAX_ETHER: 0x40,
MYSTERYBERRY: 0x96,
EGG: 0xfd,
WILD_BATTLE: 1,
TRAINER_BATTLE: 2,
+39
View File
@@ -1,10 +1,12 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import {
BATTLE,
INTRO,
NUM_ITEMS,
NUM_POKEMON,
NUM_TRAINER_CLASSES,
STATS,
SYMBOLS,
} from "./profile";
import { defaultTextContext, readString } from "../scripts/text";
@@ -178,6 +180,43 @@ export class PokemonData {
);
}
/** PrintType: the name of a type from TypeNames. */
typeName(type: number): string {
if (!Number.isInteger(type) || type < 0 || type > 27)
throw new Error("Unknown type.");
const r = this.reader;
return readString(
r,
r.near(STATS.TypeNames + type * 2, r.bank(STATS.TypeNames)),
defaultTextContext(""),
);
}
/** GetGender: the Attack and Speed DVs against the species' ratio;
* undefined for genderless species. */
gender(species: number, dvs: number): "male" | "female" | undefined {
const ratio = this.baseData(species).genderRatio;
if (ratio === 0xff) return undefined; // GENDER_UNKNOWN
if (!ratio) return "male"; // GENDER_F0
if (ratio === 0xfe) return "female"; // GENDER_F100
const value = ((dvs >> 8) & 0xf0) | ((dvs >> 4) & 0x0f);
return ratio < value ? "male" : "female";
}
/** PrintTempMonStats' labels: Attack, Defense, Sp. Atk, Sp. Def, Speed. */
statNames(): string[] {
return readString(
this.reader,
BATTLE["PrintTempMonStats.StatNames"],
defaultTextContext(""),
64,
)
.split("\n")
.filter(Boolean);
}
/** A string of the Pokémon stats screen. */
statsString(label: keyof typeof STATS): string {
return readString(this.reader, STATS[label], defaultTextContext(""));
}
move(move: number): MoveData {
if (!Number.isInteger(move) || move < 1 || move > 251)
throw new Error("Unknown move.");
+35
View File
@@ -320,6 +320,23 @@ export const INTRO = {
"GetTimeOfDayString.day_string": 0x90b7b,
} as const;
/** The Pokémon stats screen's strings and the type names, keyed by their
* PRET labels so that \`npm run check:reference\` can verify each address. */
export const STATS = {
"LoadPinkPage.Status_Type": 0x4e119,
"LoadPinkPage.OK_str": 0x4e127,
"LoadPinkPage.ExpPointStr": 0x4e12b,
"LoadPinkPage.LevelUpStr": 0x4e136,
"LoadPinkPage.ToStr": 0x4e13f,
"LoadPinkPage.PkrsStr": 0x4e142,
"LoadGreenPage.Item": 0x4e1a0,
"LoadGreenPage.ThreeDashes": 0x4e1a5,
"LoadGreenPage.Move": 0x4e1a9,
IDNoString: 0x4e21e,
OTString: 0x4e222,
TypeNames: 0x5097b,
} 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. */
@@ -495,6 +512,24 @@ export const BATTLE = {
ItemCantUseOnEggText: 0xf810,
OakThisIsntTheTimeText: 0x10af3,
UseOnWhichPKMNString: 0x504f3,
FullRestoreEffect: 0xf128,
ReviveEffect: 0xf0c8,
RevivalHerbEffect: 0xf0a9,
EnergypowderEffect: 0xf18c,
EnergyRootEffect: 0xf190,
HealPowderEffect: 0xefad,
BitterBerryEffect: 0xf16a,
RestorePPEffect: 0xf5bf,
XItemEffect: 0xf4c5,
XAccuracyEffect: 0xf482,
GuardSpecEffect: 0xf4ab,
DireHitEffect: 0xf4b8,
PokeDollEffect: 0xf48f,
XItemStats: 0xf504,
HappinessChanges: 0x7221,
ItemLooksBitterText: 0xf80b,
RestoreThePPOfWhichMoveText: 0xf72a,
PPRestoredText: 0xf739,
HungOnText: 0x80e99,
HurtByBurnText: 0x807f8,
HurtByPoisonText: 0x807e2,
+2
View File
@@ -248,6 +248,8 @@ export type ScriptEffect =
/** A native message for presentation the port does not have yet; the
* script continues. */
| { kind: "notice"; text: string }
/** The Pokémon stats screen, closed with a button. */
| { kind: "stats"; mon: PartyMon }
/** 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
+3 -2
View File
@@ -494,7 +494,7 @@ export class PcSession {
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
yield { kind: "stats", mon: structuredClone(mon) };
continue;
}
if (action !== SUBMENU_RELEASE) break;
@@ -547,7 +547,8 @@ export class PcSession {
break;
}
if (action === SUBMENU_STATS) {
yield { kind: "notice", text: "The stats screen is not ported yet." };
// CalcTempmonStats: stats from stat experience, full HP, no status.
yield { kind: "stats", mon: withdrawnMon(this.host.data, mon) };
continue;
}
if (action !== SUBMENU_RELEASE) break;
+20 -4
View File
@@ -241,7 +241,7 @@ export interface BattleView {
/** A choice the battle is waiting for, if any. */
prompt?: Extract<
BattleEffect,
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" }
{ kind: "action" | "yesno" | "forget" | "stats" | "name" | "party" | "move" }
>;
/** Yes/No cursor, as on the original boxes. */
selected: 0 | 1;
@@ -1822,6 +1822,7 @@ export class GameWorld {
this.assets.map(this.mapId).landmark,
),
tutorial: effect.tutorial,
landmark: this.assets.map(this.mapId).landmark,
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -1853,6 +1854,10 @@ export class GameWorld {
this.townMap = true;
this.dialogue = { pages: ["Town Map"], index: 0 };
return true;
case "stats":
this.statsScreen = effect.mon;
this.dialogue = { pages: ["Stats"], index: 0 };
return true;
case "menu":
this.dialogue = {
pages: effect.pages.length ? effect.pages : [""],
@@ -2248,6 +2253,7 @@ export class GameWorld {
this.script = undefined;
this.memScript ??= script.memScript;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2275,6 +2281,7 @@ export class GameWorld {
this.spawned = script.rollback.spawned;
this.script = undefined;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;
@@ -2346,14 +2353,16 @@ export class GameWorld {
return;
this.pumpBattle(name);
}
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels)
* or a party menu (a party index, or -1 to go back). */
/** Answer a battle Yes/No (1/0), the move to forget (0-3, 4 cancels),
* a party menu (a party index, or -1 to go back) or an item's move
* choice (0-3, or -1 to go back). */
battleChoose(answer: number) {
const prompt = this.battle?.prompt;
if (
prompt?.kind === "yesno" ||
prompt?.kind === "forget" ||
prompt?.kind === "party"
prompt?.kind === "party" ||
prompt?.kind === "move"
)
this.pumpBattle(answer);
}
@@ -2372,6 +2381,7 @@ export class GameWorld {
if (prompt?.kind === "yesno") return this.pumpBattle(0);
if (prompt?.kind === "forget") return this.pumpBattle(4);
if (prompt?.kind === "party" && prompt.cancel) return this.pumpBattle(-1);
if (prompt?.kind === "move") return this.pumpBattle(-1);
if (prompt?.kind === "stats") return this.pumpBattle();
if (!prompt && view.message?.prompt) this.pumpBattle();
}
@@ -2695,6 +2705,8 @@ export class GameWorld {
/** One overworld tick. `held` is the direction the player is holding. */
/** Overworld ticks run so far, for presentation such as tile animation. */
townMap = false;
/** The Pokémon a script's stats screen shows (Bill's PC). */
statsScreen: PartyMon | undefined;
ticks = 0;
tick(held?: Direction) {
@@ -2723,6 +2735,7 @@ export class GameWorld {
) {
if (--dialogue.holdTicks <= 0) {
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -3144,6 +3157,7 @@ export class GameWorld {
return;
}
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump();
}
@@ -3214,6 +3228,7 @@ export class GameWorld {
)
return;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.pump(answer);
}
@@ -3475,6 +3490,7 @@ export class GameWorld {
this.tilesWalked = save.tilesWalked;
this.playerId = save.playerId;
this.townMap = false;
this.statsScreen = undefined;
this.dialogue = undefined;
this.emote = undefined;
this.picture = undefined;