feat(mod-api): expose charge decision hook

This commit is contained in:
MaxTomahawk
2026-08-21 14:40:51 +02:00
parent c11c762f15
commit 4b0496bad1
8 changed files with 440 additions and 5 deletions
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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`,
+11
View File
@@ -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
+92
View File
@@ -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.
+11 -1
View File
@@ -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
+9
View File
@@ -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
+311
View File
@@ -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")
+2 -1
View File
@@ -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`