Merge branch 'dev' into spidercar2

This commit is contained in:
bryanthaboi
2026-08-24 08:48:24 -04:00
61 changed files with 4280 additions and 563 deletions
@@ -0,0 +1,145 @@
-- A sandboxed mod can contribute data-only field residual damage while the
-- engine retains HP, queue, and faint authority. The case also proves that
-- no-mod battles allocate no hook context and remain byte-equivalent.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
local FIXTURE = {
["mods/field_residual_probe/manifest.json"] = [[{
"id": "field_residual_probe",
"name": "Field Residual Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/field_residual_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("battle.field_residual", function(next, context)
mod.exports.calls = (mod.exports.calls or 0) + 1
mod.exports.context = context
local callback = context.field.tokens[1]
and context.field.tokens[1].onExpire
mod.exports.callbackType = type(callback)
if callback then callback() end
local rows = next(context)
rows[#rows + 1] = {
side = "player", amount = 7,
message = context.battlers.player.name .. " is buffeted!",
}
rows[#rows + 1] = {
side = "enemy", amount = 999,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
]],
}
local function newBattle(data)
TypeChart.load(data)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.field.weather = { id = "sand", turns = 4, source = "probe" }
return battle
end
local vanilla = T.sdk.loadNone({})
local plain = newBattle(vanilla.data)
local plainPlayerHp, plainEnemyHp = plain.player.mon.hp, plain.enemy.mon.hp
plain:endOfTurn()
T.eq(plain.player.mon.hp, plainPlayerHp,
"no-mod end of turn preserves player HP")
T.eq(plain.enemy.mon.hp, plainEnemyHp,
"no-mod end of turn preserves enemy HP")
T.eq(plain.field.weather.turns, 4,
"no-mod path does not reinterpret an unknown data-only field extension")
vanilla.release()
local run = T.sdk.loadMods({ "mods/field_residual_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the public field-residual probe loads cleanly")
local battle = newBattle(run.data)
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local callbackInvocations = 0
battle.field.tokens[1] = {
id = "callback-bearing", turns = 2,
state = { intensity = 4 },
onExpire = function() callbackInvocations = callbackInvocations + 1 end,
}
battle.player.invulnerable = true
battle:endOfTurn()
local out = run.loader.exports.field_residual_probe or {}
T.eq(out.calls, 1, "the public hook runs exactly once at round end")
T.check(out.context.field ~= battle.field,
"the hook receives a detached checkpoint-shaped field view")
T.same(out.context.field.weather,
{ id = "sand", turns = 4, source = "probe" },
"the detached field view carries checkpointed weather state")
T.eq(out.context.field.sides, nil,
"the detached field view exposes no live battler aliases")
T.eq(out.callbackType, "nil",
"the sandboxed wrapper cannot obtain a live field callback")
T.eq(callbackInvocations, 0,
"the sandboxed wrapper cannot invoke the engine-owned callback")
T.same(out.context.field.tokens[1], {
id = "callback-bearing", turns = 2, state = { intensity = 4 },
}, "the public field view retains data while omitting the callback")
T.eq(out.context.turn, battle.turnCount or 0,
"the hook receives the current turn counter")
T.eq(out.context.battle, nil,
"the hook does not expose the live engine battle object")
T.eq(out.context.battlers.player.side, "player",
"the detached player snapshot identifies its side")
T.eq(out.context.battlers.enemy.side, "enemy",
"the detached enemy snapshot identifies its side")
T.eq(out.context.battlers.player.vanished, true,
"the detached view reports Gen1 semi-invulnerability")
T.eq(out.context.battlers.player.hp, playerHp,
"the player snapshot carries pre-residual HP")
T.eq(out.context.battlers.enemy.hp, enemyHp,
"the enemy snapshot carries pre-residual HP")
T.check(out.context.battlers.player ~= battle.player,
"the public battler view is detached from the engine wrapper")
T.check(out.context.battlers.player.types ~= battle.player.curTypes,
"the public type list is detached")
T.eq(battle.player.mon.hp, playerHp - 7,
"the engine applies the validated player residual amount")
T.eq(battle.enemy.mon.hp, 0,
"the engine clamps residual damage to current HP")
T.eq(battle.enemy.faintQueued, true,
"the engine, not the mod, owns residual faint orchestration")
local sawPlayerMessage, sawEnemyMessage, drains = false, false, 0
for _, row in ipairs(battle.queue) do
local text = row.text and tostring(row.text) or ""
if text:find("buffeted", 1, true) then
if text:find(battle.player.name, 1, true) then sawPlayerMessage = true end
if text:find(battle.enemy.name, 1, true) then sawEnemyMessage = true end
end
if row.drain then drains = drains + 1 end
end
T.check(sawPlayerMessage and sawEnemyMessage,
"validated public messages enter the normal battle queue")
T.check(drains >= 2,
"residual HP changes use normal engine drain rows")
run.release()
T.finish("battle.field_residual public seam")
+103
View File
@@ -0,0 +1,103 @@
-- A sandboxed mod can read the outcome of a link battle -- who won, and the
-- lockstep party copies -- through the public event surface.
--
-- The copies are the point. Cable rules leave the real party untouched, so
-- a mode built on link battles (a tournament ladder, a battle royale) has no
-- other way to learn what the fight cost; by the time the state unwinds the
-- battle object is gone.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local LinkState = require("src.link.LinkState")
local FIXTURE = {
["mods/outcome_probe/manifest.json"] = [[{
"id": "outcome_probe",
"name": "Outcome Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/outcome_probe/main.lua"] = [[
local mod = ...
mod.exports.seen = 0
mod.events:on("link.battle_ended", function(ev)
mod.exports.seen = mod.exports.seen + 1
mod.exports.result = ev.result
mod.exports.role = ev.role
mod.exports.peerName = ev.peerName
mod.exports.myLead = ev.myParty and ev.myParty[1]
mod.exports.theirLead = ev.theirParty and ev.theirParty[1]
end)
]],
}
-- a link session parked at the end of a battle: no transport, so update()
-- goes straight to the stage that reports the outcome
local function finishedSession(result, isHost)
local ls
ls = setmetatable({
stage = "battleRunning",
isHost = isHost,
peerName = "BLUE",
net = nil,
battle = {
result = result,
playerParty = { { species = "RATTATA", hp = 3 } },
enemyParty = { { species = "PIDGEY", hp = 0 } },
},
game = { input = {}, stack = { top = function() return ls end } },
}, { __index = LinkState })
-- the real exit unwinds the whole link stack; the event is what is under
-- test, so record the exit rather than run it
ls.exitWith = function() ls.exited = true end
return ls
end
-- ------- no mod: the battle still ends, nothing observes it
local Runtime = require("src.mods.Runtime")
local vanilla = T.sdk.loadNone({})
T.check(not Runtime.wants("link.battle_ended"),
"with nothing subscribed the event is not wanted, so no payload is built")
local quiet = finishedSession("win", true)
quiet:update(0)
T.check(quiet.exited, "with no mod loaded the finished battle still unwinds")
T.eq(quiet.battle, nil, "and lets go of the battle")
vanilla.release()
-- ------- a mod reads the outcome and both party copies
local run = T.sdk.loadMods({ "mods/outcome_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the public outcome probe loads clean (" .. tostring(run.errors[1]) .. ")")
local session = finishedSession("win", true)
session:update(0)
local out = run.loader.exports.outcome_probe or {}
T.eq(out.seen, 1, "a finished link battle raises the event once")
T.eq(out.result, "win", "the outcome is reported")
T.eq(out.role, "host", "so is which side of the cable we were")
T.eq(out.peerName, "BLUE", "and who we played")
T.eq(out.myLead and out.myLead.species, "RATTATA",
"the lockstep copy of our party comes with it")
T.eq(out.myLead and out.myLead.hp, 3,
"carrying the damage the real party never took")
T.eq(out.theirLead and out.theirLead.species, "PIDGEY",
"and the copy of theirs")
T.check(session.exited, "the state unwinds afterwards, as it always did")
T.eq(session.battle, nil, "and the battle is released")
-- the guest side says so
local guest = finishedSession("lose", false)
guest:update(0)
T.eq(run.loader.exports.outcome_probe.seen, 2, "the guest reports too")
T.eq(run.loader.exports.outcome_probe.role, "guest", "as the guest")
T.eq(run.loader.exports.outcome_probe.result, "lose", "with its own outcome")
run.release()
T.finish("link_battle_ended")
+100
View File
@@ -0,0 +1,100 @@
-- A sandboxed mod can claim the A press on an object it owns, using only
-- public mod surfaces, and an unhooked build still talks to it as before.
--
-- The seam exists because a runtime object (WorldAPI:spawnNpc) carries no
-- TEXT_* id: the vanilla talk path has nothing to say for one, so a mod that
-- spawned it has to be able to answer instead.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local OverworldState = require("src.world.OverworldController")
local FIXTURE = {
["mods/talk_probe/manifest.json"] = [[{
"id": "talk_probe",
"name": "Talk Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/talk_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("world.talk", function(next, ow, target)
if target and target.claimedByMod then
mod.exports.claimed = target.id
return -- deliberately not calling next(): the mod answers instead
end
return next(ow, target)
end)
]],
}
-- enough of an overworld to reach the A press: a player facing one cell, an
-- object standing on it, and a map with no counter to talk across
local function fixtureOverworld(npc)
local ow
ow = setmetatable({
npcs = { npc },
player = {
facing = "up",
facingCell = function() return 4, 5 end,
},
map = {
id = "FIX_ROUTE",
isCounterCell = function() return false end,
},
talked = {},
}, { __index = OverworldState })
-- the vanilla destination, recorded rather than run: talkTo walks into the
-- map's text tables, which a fixture map does not have
ow.talkTo = function(_, target) ow.talked[#ow.talked + 1] = target.id end
return ow
end
local function objectAt(id, claimed)
return { id = id, cellX = 4, cellY = 5, targetX = 4, targetY = 5,
moving = false, claimedByMod = claimed or nil, def = {} }
end
-- ------- no mod: the A press lands in the vanilla talk path
local vanilla = T.sdk.loadNone({})
local plain = fixtureOverworld(objectAt("SIGNPOST_MAN"))
plain:interact()
T.eq(#plain.talked, 1, "with no mod loaded the A press reaches talkTo")
T.eq(plain.talked[1], "SIGNPOST_MAN", "and it is handed the object it faced")
vanilla.release()
-- ------- a mod that owns the object answers for it
local run = T.sdk.loadMods({ "mods/talk_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the public talk probe loads clean (" .. tostring(run.errors[1]) .. ")")
local owned = fixtureOverworld(objectAt("GHOST_PLAYER", true))
owned:interact()
local out = run.loader.exports.talk_probe or {}
T.eq(out.claimed, "GHOST_PLAYER", "a public hook sees the object it owns")
T.eq(#owned.talked, 0,
"and a hook that does not call next keeps the vanilla text path out of it")
-- ...while everything the mod does not own falls straight through
local other = fixtureOverworld(objectAt("NURSE_JOY"))
other:interact()
T.eq(#other.talked, 1, "an object the mod does not claim still reaches talkTo")
T.eq(other.talked[1], "NURSE_JOY", "unchanged")
-- an object mid-step is not talkable in either build (walk_npc.asm), so the
-- hook must not fire for one either
local walking = fixtureOverworld(objectAt("WALKER", true))
walking.npcs[1].moving = true
walking:interact()
T.eq(run.loader.exports.talk_probe.claimed, "GHOST_PLAYER",
"an object mid-step raises no talk hook")
T.eq(#walking.talked, 0, "and reaches no talk path at all")
run.release()
T.finish("world_talk")