mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
Merge pull request #1645 from MaxTomahawk/adaptive-trainers/charge-required-hook
feat(mod-api): expose charge decision hook
This commit is contained in:
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
|||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`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
|
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.
|
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
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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`
|
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
|
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
|
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.damage_dealt`, `battle.fainted`, `battle.status_inflicted`,
|
||||||
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
`battle.battler_switched`, `battle.ball_thrown`, `battle.exp_gained`,
|
||||||
`pokemon.level_up`, `pokemon.move_learned`; hooks `battle.damage`,
|
`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`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||||
|
|||||||
@@ -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
|
use only public `mod.ui`, hook, and save APIs. See RFC 0010 for the exact
|
||||||
contract and compatibility guarantees.
|
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
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -3980,7 +3980,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
-- record (chargeText) and the invulnerability from semiInvulnerable,
|
||||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||||
-- ChargeEffect sets INVULNERABLE for both)
|
-- 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()
|
self:cancelMoveAnim()
|
||||||
user.charging = moveInst
|
user.charging = moveInst
|
||||||
user.chargeReady = true
|
user.chargeReady = true
|
||||||
|
|||||||
@@ -1494,6 +1494,15 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
|
||||||
charge = nil
|
charge = nil
|
||||||
end
|
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
|
if charge and not charging then
|
||||||
state.chargeMove = moveId
|
state.chargeMove = moveId
|
||||||
state.vanished = charge.vanish or nil
|
state.vanished = charge.vanish or nil
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
-- 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:useMove(player, wild, "SOLARBEAM")
|
||||||
|
T.check(wild.hp < hp, "Gold no-mod continuation resolves damage")
|
||||||
|
T.eq(player.volatile.chargeMove, nil,
|
||||||
|
"Gold no-mod continuation clears stored charge state")
|
||||||
|
T.eq(move.pp, 9, "Gold no-mod continuation spends no second 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("DIG")
|
||||||
|
hp = wild.hp
|
||||||
|
battle:useMove(player, wild, "DIG")
|
||||||
|
T.eq(wild.hp, hp, "Gold next(ctx) keeps the initial charge turn")
|
||||||
|
T.eq(player.volatile.chargeMove, "DIG",
|
||||||
|
"Gold next(ctx) stores the selected charge move")
|
||||||
|
T.eq(player.volatile.vanished, true,
|
||||||
|
"Gold next(ctx) preserves semi-invulnerability")
|
||||||
|
T.eq(move.pp, 9, "Gold next(ctx) spends one PP on the charge turn")
|
||||||
|
T.eq(out.calls, calls + 1,
|
||||||
|
"Gold next(ctx) invokes the hook once on initial use")
|
||||||
|
battle:useMove(player, wild, "DIG")
|
||||||
|
T.check(wild.hp < hp, "Gold next(ctx) release resolves damage")
|
||||||
|
T.eq(out.calls, calls + 1,
|
||||||
|
"Gold release does not invoke the charge hook again")
|
||||||
|
T.eq(move.pp, 9, "Gold release spends no second PP")
|
||||||
|
|
||||||
|
calls = out.calls
|
||||||
|
battle, player, wild, move = gen2Battle("TACKLE")
|
||||||
|
battle.copyDepth = 1
|
||||||
|
battle:useMove(player, wild, "FLY")
|
||||||
|
T.eq(out.calls, calls + 1, "a called Gold charge move invokes the hook")
|
||||||
|
T.eq(out.last and out.last.isCalled, true,
|
||||||
|
"the Gold context marks a called charge move")
|
||||||
|
T.eq(move.pp, 10, "a called Gold move spends no known-move PP")
|
||||||
|
T.eq(player.volatile.chargeMove, "FLY",
|
||||||
|
"a called Gold charge move can keep its charge through next(ctx)")
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -402,7 +402,8 @@ local GEN2_HOOKS = {
|
|||||||
"ui.pc.items", "ui.list_menu",
|
"ui.pc.items", "ui.list_menu",
|
||||||
"transition.style",
|
"transition.style",
|
||||||
-- battle
|
-- 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",
|
"battle.enemy_action", "battle.run", "battle.exp_award", "exp.gain",
|
||||||
"catch.rate", "trainer.party",
|
"catch.rate", "trainer.party",
|
||||||
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
|
-- one wrap cancels or forces an evolution in either game: Gold passes `data`
|
||||||
|
|||||||
Reference in New Issue
Block a user