Files
gen1recomp/tests/engine/link_items.lua
T
DESKTOP-8SRFDDM\cam95 dd2a1f89ff engine: opts.items -- items on the link cable, for a mode that asks (RFC 0021)
A link battle runs under cable rules and LinkBattle keeps them: openItems
prints "Items can't be used in a link battle!" and the wire knows move,
struggle, locked, switch and RUN.  Right for the Cable Club, wrong for a
mode that is not the Cable Club -- a battle royale played over
LinkState.newFromSession fights with real, damaged parties, and the
potions and X items on the ground are its whole economy; against a bot
the bag works, against a person it says no.  The mode cannot fix it:
submit, resolveLockstep and the decoder are closures, and there is no
action that means "nothing" to spend the turn on.

opts.items = true on newHost/newGuest (off by default, set on both
machines like turnLimit): the bag is BattleState.openItems -- the vanilla
BagMenu against this battle, whose picker already offers the clamped
copies -- and the effect is ItemEffects.use as in any fight.  What
changes is what spending the turn means: itemUsed puts { kind = "item",
item, index, move } on the wire as the turn's action instead of running
the AI's reply.  Both machines and a spectator resolve it before switches
and moves: the user's side is already applied (the bag did it), the other
side applies the same effect to its own copies of that side through
src/link/LinkItems.lua -- ItemEffects.use behind a battle whose player is
the user's battler and a save whose party is the user's copies, nothing
consumed -- and prints "<name> used <ITEM>!" plus the effect's lines.
Every effect reachable in a battle is deterministic, so the per-turn
hash still agrees.  BagMenu hands itemUsed the item, target and move it
used; a local battle ignores them.  A ball on the cable is refused
(ItemEffects), the way the doll is in any trainer battle.

tests/engine/link_items.lua: cable rules by default; opted in, the host's
POTION heals the guest's copy before the moves, both print the lines,
one turn, hashes agree; a spectator fed the same two messages heals its
copy too.  docs/modding.md and docs/rfcs/0021-link-battle-items.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:19:06 -05:00

186 lines
7.6 KiB
Lua

-- Items on the link cable (RFC 0021): a mode that opts in gets the bag,
-- the item rides the wire as the turn's action, and the peer -- and a
-- spectator -- apply the same effect to their copies before the moves,
-- so the per-turn hash still agrees. Cable rules stay the default.
-- luajit tests/run_engine.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Link = require("tests.modkit.link")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local LinkBattle = require("src.link.LinkBattle")
local ItemEffects = require("src.inventory.ItemEffects")
math.randomseed(4242)
local Input = Link.prepare(Data)
-- the host's lead starts hurt on BOTH machines (packed HP rides the
-- wire), so a POTION has something to restore
local function pair(opts)
local gameA = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "RED" })
local gameB = Link.fakeGame(Data, { "FIXMON_A", "FIXMON_B" }, { name = "BLUE" })
local lead = gameA.save.party[1]
lead.hp = math.max(1, math.floor(lead.stats.hp / 3))
local netA, netB = Net.loopbackPair()
local packedA = Protocol.packParty(gameA.save.party)
local packedB = Protocol.packParty(gameB.save.party)
local base = { seed = 987654321 }
for k, v in pairs(opts or {}) do base[k] = v end
local function with(extra)
local o = {}
for k, v in pairs(base) do o[k] = v end
for k, v in pairs(extra) do o[k] = v end
return o
end
local battleA = LinkBattle.newHost(gameA, netA,
with({ myParty = packedA, theirParty = packedB, theirName = "BLUE" }))
local battleB = LinkBattle.newGuest(gameB, netB,
with({ myParty = packedB, theirParty = packedA, theirName = "RED" }))
return gameA, gameB, battleA, battleB, packedA, packedB
end
local function watch(battle)
local seen = {}
local sayNext, say = battle.sayNext, battle.say
battle.sayNext = function(s, text) seen[#seen + 1] = text; return sayNext(s, text) end
battle.say = function(s, text, ...) seen[#seen + 1] = text; return say(s, text, ...) end
return seen
end
local function has(list, needle)
for _, text in ipairs(list) do
if type(text) == "string" and text:find(needle, 1, true) then return true end
end
return false
end
-- ------- cable rules by default: the bag is refused, nothing new on the wire
do
local gameA, gameB, battleA, battleB = pair()
local seenA = watch(battleA)
gameA.stack:push(battleA)
gameB.stack:push(battleB)
for _ = 1, 3000 do
if battleA.phase == "menu" then break end
Input.pressed = { a = true }
gameA.stack:update(1 / 60)
gameB.stack:update(1 / 60)
end
T.eq(battleA.phase, "menu", "default: the host reaches its menu")
battleA:openItems()
T.check(has(seenA, "Items can't be"), "default: the bag is refused with the cable line")
T.check(battleA.itemUsed == require("src.battle.BattleState").itemUsed,
"default: itemUsed is the inherited one, nothing rides the wire")
end
-- ------- opted in: the host's POTION heals on both machines before the moves
do
local gameA, gameB, battleA, battleB = pair({ items = true })
local seenA, seenB = watch(battleA), watch(battleB)
local resA, resB
battleA.onFinish = function(r) resA = r end
battleB.onFinish = function(r) resB = r end
gameA.stack:push(battleA)
gameB.stack:push(battleB)
local used, guestMoved = false, false
local hpBeforeOnB
-- A advances text on a side that is not at its menu; a side AT its menu
-- is left there, so the battle stops after the one turn under test
local function pressFor(battle)
Input.pressed = (battle.phase ~= "menu") and { a = true } or {}
end
for _ = 1, 20000 do
if resA or resB then break end
if not used and battleA.phase == "menu" and gameA.stack:top() == battleA then
-- what the bag does, in order: the effect on the lockstep copy, then
-- the turn spent with the item's context (BagMenu's `spent`)
hpBeforeOnB = battleB.enemy.mon.hp
local target = battleA.player.mon
local result, payload = ItemEffects.use(Data, gameA.save, "POTION", target, battleA)
T.eq(result, "consumed", "the host's POTION lands on its copy")
battleA:itemUsed({}, { item = "POTION", target = target })
used = true
end
if not guestMoved and battleB.phase == "menu" and gameB.stack:top() == battleB then
battleB:resolveTurn(battleB.player.curMoves[1])
guestMoved = true
end
pressFor(battleA)
gameA.stack:update(1 / 60)
pressFor(battleB)
gameB.stack:update(1 / 60)
if used and guestMoved and battleA.phase == "menu" and battleB.phase == "menu" then
break
end
end
T.check(used, "items: the host got a menu to use the POTION from")
T.eq(battleA.phase, "menu", "items: the turn resolved on the host")
T.eq(battleB.phase, "menu", "items: ...and on the guest")
T.eq(battleA.turnCount, 1, "items: one turn on the host")
T.eq(battleB.turnCount, 1, "items: one turn on the guest")
T.check(resA == nil and resB == nil, "items: nobody desynced or ran")
T.check(hpBeforeOnB ~= nil and battleB.enemy.mon.hp ~= hpBeforeOnB or
battleB.enemy.mon.hp == battleA.player.mon.hp,
"items: the guest's copy of the host's lead moved")
T.eq(battleB.enemy.mon.hp, battleA.player.mon.hp,
"items: after the turn both machines hold the same HP for the host's lead")
T.check(has(seenB, "RED used"), "items: the guest prints the host's used line")
T.check(has(seenB, "restored"), "items: ...and the restore line")
local mismatch
for turn, hash in pairs(battleA.localHashes) do
local other = battleB.localHashes[turn]
if other and other ~= hash then mismatch = mismatch or turn end
end
T.check(mismatch == nil, "items: the per-turn state hash still agrees")
T.check(battleA.remoteHashes[1] ~= nil and battleB.remoteHashes[1] ~= nil,
"items: both sides exchanged the turn's hash")
end
-- ------- a spectator applies both players' items to its own copies
do
local gameA, gameB, _, _, packedA, packedB = pair({ items = true })
local gameS = Link.fakeGame(Data, { "FIXMON_A" }, { name = "WATCHER" })
local inbox = {}
local net = { closed = false }
function net:update() end
function net:poll() local m = inbox; inbox = {}; return m end
function net:send() end
function net:close() end
local spec = LinkBattle.newSpectator(gameS, net, {
hostParty = packedA, guestParty = packedB,
hostName = "RED", guestName = "BLUE", seed = 987654321,
})
T.check(spec ~= nil, "spectator: opens off the packed parties")
local seenS = watch(spec)
gameS.stack:push(spec)
local function frozen() return spec.phase == "waitBoth" or spec.phase == "menu" end
for _ = 1, 3000 do
if frozen() then break end
Input.pressed = { a = true }
gameS.stack:update(1 / 60)
end
T.check(frozen(), "spectator: waits for the first turn (" .. spec.phase .. ")")
local before = spec.player.mon.hp
inbox[#inbox + 1] = { type = "spectate", side = "host",
msg = { type = "action", kind = "item", item = "POTION", index = 1 } }
inbox[#inbox + 1] = { type = "spectate", side = "guest",
msg = { type = "action", kind = "move", slot = 1 } }
for _ = 1, 6000 do
if frozen() and spec.turnCount == 1 then break end
Input.pressed = { a = true }
gameS.stack:update(1 / 60)
end
T.eq(spec.turnCount, 1, "spectator: the turn played")
T.check(spec.player.mon.hp > before or spec.player.mon.hp == spec.player.mon.stats.hp,
"spectator: the host's lead was healed on the replica (" .. before .. " -> " .. spec.player.mon.hp .. ")")
T.check(has(seenS, "RED used"), "spectator: prints the host's used line")
end
T.finish("items ride the link cable when a mode asks (RFC 0021)")