diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 40147fae..3030138f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"` when you mean everywhere. `docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold -and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1, +and Silver today (40 of the 46 registries, 40 event and 44 hook names shared with Gen 1, and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their writes with a report, and which hooks and events are still to come. `docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 0e7e7ffd..896b9187 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -24,7 +24,7 @@ The short version, for an author deciding what to write: merged.** The write is taken, dropped, and named once per mod in the same error feed the mod manager shows -- in both directions, so a Red boot writing to `decorations` is told exactly as a Gold boot writing to `map_scripts` is. -- **40 event names and 43 hook names have a call site in both generations**, so +- **40 event names and 44 hook names have a call site in both generations**, so one subscription serves both games. `tests/engine/gate_gen2_mod_api.lua` reads those names back out of the source and fails if a site is renamed or deleted on either side, and fails again if a new shared site appears without @@ -539,7 +539,8 @@ gains a field instead of the name gaining a prefix. `battle.damage_dealt`, `battle.fainted`, `battle.status_inflicted`, `battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`, `pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`, - `battle.crit`, `battle.accuracy`, `battle.turn_order`, + `battle.crit`, `battle.accuracy`, `battle.charge_required`, + `battle.turn_order`, `battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`, `catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`, `battle.catch_exp`, `battle.bottom_ui_visible`, diff --git a/docs/modding.md b/docs/modding.md index 4016e1f1..c8807b26 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -633,6 +633,17 @@ the selected indices. Mods remain responsible for selection policy and should use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact contract and compatibility guarantees. +Both battle engines expose the guarded `battle.charge_required` hook when a +charge-capable move is selected for its initial turn and the active ruleset +would otherwise charge it. The wrapper receives `(next, ctx)`, where `ctx` is +`{ battle, user, target, move, charge = true, isCalled }`. Return `false` to +skip only that initial charge and continue through the ordinary move pipeline; +call `next(ctx)` to keep it. The hook does not run for the release turn or when +the active ruleset already skips charging (for example, Gold Solarbeam in +sun). PP use, accuracy, damage, animation, and secondary effects remain owned +by the engine. With no subscriber, the vanilla decision runs without building +the hook context. + ## Developer console Boot with developer mode on to unlock the in-game console and hot-reload diff --git a/docs/rfcs/0011-battle-charge-required.md b/docs/rfcs/0011-battle-charge-required.md new file mode 100644 index 00000000..310bec00 --- /dev/null +++ b/docs/rfcs/0011-battle-charge-required.md @@ -0,0 +1,92 @@ +# RFC 0011: Charge-required battle hook + +## Status + +Proposed. + +## Motivation + +A battle-mechanics mod can change damage through `battle.damage` and register +move effects, but it cannot conditionally skip the first turn of an existing +charge move. In Gen 1, the engine decides and stores the charge continuation +before any public effect callback can run. Reaching into `user.charging`, +`user.chargeReady`, or generation-specific volatile state is private, +checkpoint-fragile, and would require a mod to duplicate move-pipeline policy. + +Weather is the immediate example: a portable sun rule needs Solarbeam to +resolve on selection while leaving Fly, Dig, PP use, hit resolution, animation, +and secondary effects to the engine. The capability is generic and useful to +other ruleset and move-mechanics mods. + +## Decision and plan extended + +This implements **D-AT-002: charge-stage policy remains mod authority through a +generic guarded engine decision seam**. The consuming design is tracked in the +Adaptive Trainers implementation plan, +[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md), +Task 8. The delta follows the additive, guarded hook convention documented by +Route B in `CONTRIBUTING-mods.md`; it contains no weather, move-id, trainer, or +Adaptive Trainers policy. + +## Exact API delta + +Both the Gen 1 and Gen 2 battle engines add this guarded hook: + +```lua +mod.hooks:wrap("battle.charge_required", function(next, ctx) + -- ctx = { + -- battle = live battle controller, + -- user = attacking battler, + -- target = defending battler, + -- move = merged move record, + -- charge = true, + -- isCalled = false, + -- } + if should_resolve_now(ctx) then return false end + return next(ctx) +end) +``` + +The call site is the initial-use charge decision, after announcement and PP +handling but before charge state, invulnerability, charge animation, or charge +text is created. It runs only when the active engine rules would otherwise +require a charge. It does not run on the release turn. Returning exactly +`false` skips that initial charge and continues through the engine-owned move +pipeline. Any other downstream return preserves the charge. `isCalled` is true +when Metronome or Mirror Move selected the move. + +Gold keeps its native sun decision first, so Solarbeam in native sun already +requires no charge and does not invoke the hook. Gen 1 link battles use the +shared Gen 1 move pipeline and therefore receive the same seam; normal link +mod-compatibility rules continue to govern deterministic peers. + +The hot path first calls `Runtime.wantsHook("battle.charge_required")`. With no +subscriber, no hook payload table is allocated and the existing branch runs +unchanged. + +## Migration and compatibility + +Existing mods change nothing. The hook name and payload are additive. With no +wrapper installed, Red, Blue, Yellow, Gold, and Silver retain their previous +charge state, PP use, text, animation, accuracy, damage, and native weather +behavior. Existing charge-move data and effect records require no migration. + +A mod adopting the seam should call `next(ctx)` unless it deliberately wants to +skip this charge. It should not mutate private charge fields or re-run the move. + +## Verification + +- `tests/engine/battle_charge_required.lua` exercises the real Gen 1 and Gen 2 + engines through a sandboxed public mod, including false-to-skip, next-to-keep, + release-turn behavior, called-move PP semantics, shared payload shape, and + native Gold sun behavior. +- The same test proves no-mod charge/release parity and replaces + `Runtime.call` with a sentinel behind a false `Runtime.wantsHook` guard. +- `tests/engine/gate_hooks.lua` discovers the new catalog name and proves empty + chains preserve vanilla values and allocation behavior. +- `tests/engine/gate_gen2_mod_api.lua` requires a guarded site in both + generations and keeps the compatibility reference list complete. + +## Deprecation etiquette + +Nothing is removed, renamed, superseded, or deprecated. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4a61ef0a..3a005361 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- record (chargeText) and the invulnerability from semiInvulnerable, -- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- ChargeEffect sets INVULNERABLE for both) - if record and record.charge and not releasing then + local chargeRequired = record and record.charge ~= nil and not releasing + if chargeRequired and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = user, target = target, move = move, + charge = true, isCalled = isCalled or false, + }) + chargeRequired = required ~= false + end + if chargeRequired then self:cancelMoveAnim() user.charging = moveInst user.chargeReady = true diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 06d57035..9346f9dd 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId) if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then charge = nil end + if charge and not charging and Runtime.wantsHook("battle.charge_required") then + local required = Runtime.call("battle.charge_required", function(c) + return c.charge + end, { + battle = self, user = attacker, target = defender, move = def, + charge = true, isCalled = (self.copyDepth or 0) > 0, + }) + if required == false then charge = nil end + end if charge and not charging then state.chargeMove = moveId state.vanished = charge.vanish or nil diff --git a/tests/engine/battle_charge_required.lua b/tests/engine/battle_charge_required.lua new file mode 100644 index 00000000..3fb66cc9 --- /dev/null +++ b/tests/engine/battle_charge_required.lua @@ -0,0 +1,311 @@ +-- Public, shared battle.charge_required hook. +-- +-- A mod that adds weather to Gen 1 needs to let SolarBeam resolve on the +-- turn it is selected without replacing the move, mutating private battle +-- state, or reimplementing the damage pipeline. This case loads a real +-- sandboxed mod through the public SDK and drives the real Gen 1 and Gold +-- battle engines. It also pins each generation's empty-chain decision. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") +local Font = require("src.render.Font") +local Gen2Battle = require("src.battle.gen2.Battle") +local Gen2Mon = require("src.battle.gen2.Mon") +local Pokemon = require("src.pokemon.Pokemon") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") + +local function lowRoll(a) + return a or 0 +end + +local function queueHasText(battle, fragment) + for _, row in ipairs(battle.queue or {}) do + if row.text and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function eventHasText(battle, fragment) + for _, row in ipairs(battle.events or {}) do + if row.kind == "message" and row.text + and row.text:find(fragment, 1, true) then return true end + end + return false +end + +local function gen1Data() + local data = T.fixtures.fresh() + data.moves.SOLARBEAM = { + id = "SOLARBEAM", index = 80, name = "SOLARBEAM", type = "GRASS", + power = 120, accuracy = 100, pp = 10, effect = "CHARGE_EFFECT", + } + data.moves.FLY = { + id = "FLY", index = 81, name = "FLY", type = "FLYING", + power = 70, accuracy = 95, pp = 15, effect = "FLY_EFFECT", + } + data.moves.DIG = { + id = "DIG", index = 82, name = "DIG", type = "GROUND", + power = 100, accuracy = 100, pp = 10, effect = "FLY_EFFECT", + } + Font.load(data) + TypeChart.load(data) + return data +end + +local function gen1Battle(data, moveId) + local save = SaveData.newGame() + save.party = { Pokemon.new(data, "FIXMON_A", 30) } + local move = { id = moveId, pp = 10, maxPp = 10 } + save.party[1].moves = { move } + local stack = { states = {} } + function stack:push(state) self.states[#self.states + 1] = state end + function stack:pop() return table.remove(self.states) end + function stack:top() return self.states[#self.states] end + local game = { data = data, save = save, stack = stack, + input = { wasPressed = function() return false end, + isDown = function() return false end } } + local battle = BattleState.newWild(game, "FIXMON_B", 20) + battle.rng = lowRoll + return battle, battle.player, battle.enemy, move +end + +local G2_TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GRASS = { id = "GRASS", index = 22, category = "special" }, + FLYING = { id = "FLYING", index = 2, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, +} + +local G2_MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, + effect = "EFFECT_NORMAL_HIT" }, + SOLARBEAM = { id = "SOLARBEAM", name = "SOLARBEAM", power = 120, + type = "GRASS", accuracy = 100, pp = 10, + effect = "EFFECT_SOLARBEAM" }, + FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING", + accuracy = 95, pp = 15, effect = "EFFECT_FLY" }, + DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND", + accuracy = 100, pp = 10, effect = "EFFECT_FLY" }, +} + +local G2_DATA = { + pokemon = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + MACHOP = { + id = "MACHOP", index = 66, name = "MACHOP", + baseStats = { hp = 70, attack = 80, defense = 50, speed = 35, + specialAttack = 35, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63, + levelMoves = {}, evolutions = {}, + }, + }, + moves = G2_MOVES, + type_chart = { types = G2_TYPES, matchups = {} }, + items = {}, +} + +local G2_DVS = { attack = 15, defense = 15, speed = 15, special = 15 } +G2_DVS.hp = Gen2Mon.hpDV(G2_DVS) + +local function gen2Battle(moveId, weather) + local player = Gen2Mon.new(G2_DATA, "MACHOP", 30, { dvs = G2_DVS }) + local move = { id = moveId, pp = 10, maxPp = 10 } + player.moves = { move } + local wild = Gen2Mon.new(G2_DATA, "MACHOP", 20, { dvs = G2_DVS }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Gen2Battle.new({ data = G2_DATA, party = { player }, + wild = wild, random = function(n) return math.max(0, (n or 1) - 1) end }) + battle.weather = weather + return battle, player, wild, move +end + +-- No-mod parity: Red always charges a charge-capable move on initial use, +-- spends PP only on that initial use, and resolves on the continuation. +do + local run = T.sdk.loadNone() + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.eq(enemy.mon.hp, hp, "Gen 1 no-mod initial SolarBeam only charges") + T.eq(player.charging, move, "Gen 1 no-mod stores the selected move") + T.eq(move.pp, 9, "Gen 1 no-mod charge spends one PP") + T.check(queueHasText(battle, "took in sunlight"), + "Gen 1 no-mod keeps the charge text") + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, "Gen 1 no-mod continuation resolves damage") + T.eq(move.pp, 9, "Gen 1 no-mod continuation spends no second PP") + run.release() +end + +-- No-mod parity: Gold's native answer remains weather-sensitive. Solarbeam +-- charges without sun and skips charge under sun. +do + local run = T.sdk.loadNone({ generation = 2 }) + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.eq(wild.hp, hp, "Gold no-mod initial Solarbeam charges without sun") + T.eq(player.volatile.chargeMove, "SOLARBEAM", + "Gold no-mod stores Solarbeam without sun") + T.eq(move.pp, 9, "Gold no-mod charge spends one PP") + + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, "Gold no-mod sun skips Solarbeam charge") + T.eq(player.volatile.chargeMove, nil, + "Gold no-mod sun stores no charge continuation") + T.eq(move.pp, 9, "Gold no-mod sun still spends exactly one PP") + run.release() +end + +local MOD = { + ["mods/charge_probe/manifest.json"] = [[{ + "id": "charge_probe", + "name": "Charge Required Probe", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "games": ["all"] + }]], + ["mods/charge_probe/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("battle.charge_required", function(nextFn, ctx) + mod.exports.calls = (mod.exports.calls or 0) + 1 + mod.exports.last = { + battle = ctx.battle ~= nil, + user = ctx.user ~= nil, + target = ctx.target ~= nil, + move = ctx.move and ctx.move.id, + charge = ctx.charge, + isCalled = ctx.isCalled, + } + if ctx.move.id == "SOLARBEAM" then return false end + return nextFn(ctx) + end) + ]], +} + +-- Public mod API, Gen 1: one conditional false resolves through the ordinary +-- damage pipeline on the first turn. Fly and Dig keep their vanilla charge +-- state, invulnerability, PP, and text; the release does not call the hook. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), + }) + T.eq(#run.errors, 0, + "the public charge hook mod loads clean (" .. tostring(run.errors[1]) .. ")") + local data = gen1Data() + local battle, player, enemy, move = gen1Battle(data, "SOLARBEAM") + local hp = enemy.mon.hp + battle:performMove(player, enemy, move) + T.check(enemy.mon.hp < hp, + "a public Gen 1 hook can resolve SolarBeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gen 1 resolution spends one PP") + T.eq(player.charging, nil, "the bypass creates no Gen 1 continuation") + T.check(not queueHasText(battle, "took in sunlight"), + "the bypass emits no Gen 1 charge text") + + local out = run.loader.exports.charge_probe or {} + T.eq(out.calls, 1, "the public Gen 1 hook fires once on initial use") + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "the public Gen 1 hook receives the generation-neutral context") + + for _, id in ipairs({ "FLY", "DIG" }) do + battle, player, enemy, move = gen1Battle(data, id) + local beforeCalls = out.calls or 0 + battle:performMove(player, enemy, move) + T.eq(player.charging, move, id .. " still charges through next(ctx)") + T.eq(player.invulnerable, true, id .. " still becomes invulnerable") + T.eq(move.pp, 9, id .. " still spends one PP on its charge turn") + T.check(queueHasText(battle, id == "FLY" and "flew up" or "dug a hole"), + id .. " still emits its charge text") + T.eq(out.calls, beforeCalls + 1, id .. " calls the hook on initial use") + battle:performMove(player, enemy, move) + T.eq(out.calls, beforeCalls + 1, + id .. " release does not call the initial-use hook again") + T.eq(move.pp, 9, id .. " release spends no second PP") + end + + -- Called charge-capable moves get the same initial-use seam and say so. + battle, player, enemy, move = gen1Battle(data, "FLY") + battle:performMove(player, enemy, move, true) + out = run.loader.exports.charge_probe or {} + T.eq(out.last and out.last.isCalled, true, + "the Gen 1 context marks a called charge move") + T.eq(move.pp, 10, "a called Gen 1 charge move keeps called-move PP semantics") + run.release() +end + +-- Public mod API, Gold: the same wrapper bypasses the ordinary no-sun charge +-- branch, preserves one PP spend, and emits no charge text. +do + local run = T.sdk.loadMods({ "mods/charge_probe" }, { + fs = T.sdk.memfs(MOD), generation = 2, + }) + T.eq(#run.errors, 0, + "the shared charge hook mod loads clean on Gold") + local battle, player, wild, move = gen2Battle("SOLARBEAM") + local hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "the public Gold hook resolves Solarbeam on its initial use") + T.eq(move.pp, 9, "the one-turn Gold resolution spends one PP") + T.eq(player.volatile.chargeMove, nil, + "the bypass creates no Gold charge continuation") + T.check(not eventHasText(battle, "took in sunlight"), + "the bypass emits no Gold charge text") + local out = run.loader.exports.charge_probe or {} + T.same(out.last, { + battle = true, user = true, target = true, move = "SOLARBEAM", + charge = true, isCalled = false, + }, "Gold receives the same generation-neutral hook context") + + local calls = out.calls + battle, player, wild, move = gen2Battle("SOLARBEAM", "sun") + hp = wild.hp + battle:useMove(player, wild, "SOLARBEAM") + T.check(wild.hp < hp, + "Gold native sun still resolves before the public charge decision") + T.eq(out.calls, calls, + "the hook does not run when the active rules already skip charge") + run.release() +end + +-- Guard parity: with no subscriber, neither generation reaches Runtime.call; +-- both take their vanilla decision without constructing/dispatching a ctx. +do + local battle, player, enemy, move = gen1Battle(gen1Data(), "SOLARBEAM") + local battle2, player2, enemy2 = gen2Battle("SOLARBEAM") + local oldWants, oldCall = Runtime.wantsHook, Runtime.call + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gen 1 guard checks the hook name") + return false + end + Runtime.call = function() + error("unsubscribed charge hot path dispatched", 0) + end + local ok, err = pcall(battle.performMove, battle, player, enemy, move) + T.check(ok, "the guarded Gen 1 hot path does not dispatch: " .. tostring(err)) + + Runtime.wantsHook = function(name) + T.eq(name, "battle.charge_required", "the Gold guard checks the hook name") + return false + end + ok, err = pcall(battle2.useMove, battle2, player2, enemy2, "SOLARBEAM") + T.check(ok, "the guarded Gold hot path does not dispatch: " .. tostring(err)) + Runtime.wantsHook, Runtime.call = oldWants, oldCall +end + +T.finish("battle charge required") diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index beabb51c..01558e97 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -402,7 +402,8 @@ local GEN2_HOOKS = { "ui.pc.items", "ui.list_menu", "transition.style", -- battle - "battle.damage", "battle.crit", "battle.accuracy", "battle.turn_order", + "battle.damage", "battle.crit", "battle.accuracy", + "battle.charge_required", "battle.turn_order", "battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain", "catch.rate", "trainer.party", -- one wrap cancels or forces an evolution in either game: Gold passes `data`