mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
e3ee1f7028
RFC 0021 gave a mode back the bag on the link cable: opts.items makes an
item the turn, and `{ type = "action", kind = "item", item, index, move }`
rides the wire so the peer -- and a spectator -- can apply the same effect
to their copies before the moves. The peer never got the item.
Wire.sanitize rebuilds every inbound message field by field from
SCHEMAS[type] and drops anything the schema does not name. SCHEMAS.action
names kind, slot and index; `item` and `move` are not among them, so they
were stripped on the way in. Added beside the others, clamped the same
way (a string id, a move slot in 1..MAX_MOVES). SCHEMAS.spectate calls
the same sanitize, so a spectator is fixed by the same line.
Why the tests did not see it. tests/engine/link_items.lua -- and every
in-process link test -- pairs the two sides with Net.loopbackPair, which
hands the table straight over; sanitize only runs on the way through
Session, which is every REAL transport. So the failure needed two
machines to show up, and when it did it was the quietest kind: the peer
receives an item action with no item in it, LinkItems.apply returns
nothing, the turn is still spent, nothing is printed, and the two
simulations part by one heal with nothing on either screen to say so --
until a hash several turns later blames the wrong turn. Found by a
downstream mod's two-client harness on its first real duel: the guest
healed 12 -> 18 and the host watched its copy of that mon go 12 -> 6.
link_items.lua now pins the schema directly (an item action through
sanitize, through a spectate wrapper, and a move action carrying no
item), which is a check a loopback pair cannot make. docs/rfcs/0021
gains the section saying a new field on an existing message type is
invisible until SCHEMAS knows about it.
tests/run_link_tests.lua green; the engine tier matches stock's reds
(572/584, the same twelve).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
9.2 KiB
Lua
215 lines
9.2 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
|
|
|
|
-- ------- ...and the item survives the wire, not just a loopback Net
|
|
--
|
|
-- The blocks above hand the message straight to the peer. A real
|
|
-- transport does not: Session pushes every inbound message through
|
|
-- Wire.sanitize first, which rebuilds it field by field from a schema
|
|
-- and drops anything the schema does not name. An `action` that keeps
|
|
-- only kind/slot/index arrives as an item with no item in it -- the
|
|
-- turn is still spent, nothing is applied, nothing is printed, and the
|
|
-- two sides are a heal apart with no way to tell. So the schema is
|
|
-- pinned here, where a link test can see it.
|
|
|
|
do
|
|
local Wire = require("src.link.Wire")
|
|
local out = Wire.sanitize({ type = "action", kind = "item", item = "POTION",
|
|
index = 2, move = 3 })
|
|
T.check(out ~= nil, "wire: an item action survives sanitize")
|
|
T.eq(out and out.kind, "item", "wire: ...as an item")
|
|
T.eq(out and out.item, "POTION", "wire: ...carrying WHICH item")
|
|
T.eq(out and out.index, 2, "wire: ...the party slot it was used on")
|
|
T.eq(out and out.move, 3, "wire: ...and the move it picked")
|
|
local spec = Wire.sanitize({ type = "spectate", side = "host",
|
|
msg = { type = "action", kind = "item",
|
|
item = "FULL_HEAL" } })
|
|
T.eq(spec and spec.msg and spec.msg.item, "FULL_HEAL",
|
|
"wire: ...through a spectate wrapper too")
|
|
local moveOnly = Wire.sanitize({ type = "action", kind = "move", slot = 2 })
|
|
T.eq(moveOnly and moveOnly.item, nil, "wire: a move action carries no item")
|
|
end
|
|
|
|
T.finish("items ride the link cable when a mode asks (RFC 0021)")
|