mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 06:55:29 +02:00
tests, docs: cover world.talk and link.battle_ended
gate_meta_coverage asks every extension point for a unit test through the public mod API, a no-mod parity test, and docs. The seams commit shipped the call sites and owed the rest. Both cases drive the real engine path, and both assert the unhooked build first, so they fail if the seam is deleted and if it changes vanilla behaviour. world_talk stands an object on the faced cell: with no mod the A press reaches talkTo, with a mod that owns the object it does not, and an object the mod ignores still falls through. link_battle_ended parks a session at the end of a battle and checks the event carries the result, the role, and both party copies. The parity side needed nothing. gate_hooks and gate_events walk the live catalog, so both seams were covered structurally as soon as the call sites existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -265,6 +265,80 @@ Red exposes FLY separately because it requires a destination picker:
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Self-driven world actors
|
||||
|
||||
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||
knows a cutscene is running, so it gates player input for as long as the
|
||||
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||
Five handle methods drive one without that lockout:
|
||||
|
||||
```lua
|
||||
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||
sprite = "SPRITE_RED" })
|
||||
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||
ghost:setPassable(true) -- walk-through
|
||||
```
|
||||
|
||||
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||
queue. It deliberately does **not** check collision: a caller replaying a
|
||||
move that was already decided elsewhere (validated on a peer's machine, or
|
||||
authored) would let the two copies disagree about where the actor is if this
|
||||
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||
flight, for a warp arrival or a resync too far gone to walk off.
|
||||
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||
draws and can still be talked to.
|
||||
|
||||
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||
object, raised before the map's text tables get it:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||
if mine(target) then
|
||||
say(target) -- the mod answers for an object it owns
|
||||
return -- ...by not calling next()
|
||||
end
|
||||
return next(ow, target) -- everything else falls through unchanged
|
||||
end)
|
||||
```
|
||||
|
||||
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||
mid-step raises no hook, matching the vanilla gate.
|
||||
|
||||
## Adopting an already-paired link session
|
||||
|
||||
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||
session on a transport that is *already* paired, skipping the address/code
|
||||
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||
tunnel a battle through its own connection rather than opening a second one.
|
||||
|
||||
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||
|
||||
```lua
|
||||
mod.events:on("link.battle_ended", function(ev)
|
||||
-- ev = { result, myParty, theirParty, peerName, role }
|
||||
end)
|
||||
```
|
||||
|
||||
The party 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, and by the time the state
|
||||
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||
|
||||
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||
false` to land straight in the world -- a mode that hands out its own starting
|
||||
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||
can also carry a room code or an address.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- 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 vanilla = T.sdk.loadNone({})
|
||||
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