mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Merge branch 'dev' into spidercar2
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user