mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
Merge pull request #1501 from bryanthaboi/dev
This commit is contained in:
@@ -70,12 +70,12 @@ end
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
guest:send({ type = "before", sequence = 1 })
|
||||
guest:send({ type = "hello", sequence = 2 })
|
||||
guest:send({ type = "greeting", sequence = 2 })
|
||||
guest:send({ type = "after", sequence = 3 })
|
||||
guest:send({ type = "hello", sequence = 4 })
|
||||
guest:send({ type = "greeting", sequence = 4 })
|
||||
host:update()
|
||||
|
||||
local hello = host:take("hello")
|
||||
local hello = host:take("greeting")
|
||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||
|
||||
@@ -122,14 +122,14 @@ end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
||||
self.inbox[#self.inbox + 1] = { type = "bye" }
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
||||
T.check(session:take("bye") ~= nil, "final close packet remains observable")
|
||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||
end
|
||||
@@ -182,21 +182,22 @@ do
|
||||
} })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
local reason = session:getFailure()
|
||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
||||
T.eq(session:getFailure(), nil, "a malformed packet is not a terminal failure")
|
||||
T.eq(session:getStatus(), "paired", "the session stays usable after a bad packet")
|
||||
local messages = session:poll()
|
||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
||||
T.eq(#messages, 2, "the malformed value is dropped, the rest is delivered")
|
||||
T.eq(messages[1].sequence, 1, "packets before the malformed one survive")
|
||||
T.eq(messages[2].sequence, 3, "packets after the malformed one survive")
|
||||
T.eq(session.dropped, 1, "the drop is counted")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"table without string type is a protocol error")
|
||||
T.eq(session:getFailure(), nil,
|
||||
"a table without a string type is dropped, not a terminal failure")
|
||||
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||
end
|
||||
|
||||
do
|
||||
@@ -268,8 +269,9 @@ do
|
||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||
senderNet:send(false)
|
||||
receiver:update()
|
||||
T.eq(receiver:getFailure(), "protocol_error",
|
||||
"loopback forwards decoded false to session validation")
|
||||
T.eq(receiver:getFailure(), nil,
|
||||
"a decoded scalar off the loopback is dropped, not fatal")
|
||||
T.eq(#receiver:poll(), 0, "...and never reaches the mode")
|
||||
end
|
||||
|
||||
do
|
||||
@@ -284,8 +286,9 @@ do
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"ENet forwards decoded false to session validation")
|
||||
T.eq(session:getFailure(), nil,
|
||||
"a decoded scalar off ENet is dropped, not fatal")
|
||||
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||
end
|
||||
|
||||
do
|
||||
@@ -294,8 +297,9 @@ do
|
||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||
"TCP control handoff does not index a decoded scalar")
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"decoded TCP scalar reaches session validation")
|
||||
T.eq(session:getFailure(), nil,
|
||||
"a decoded TCP scalar is dropped, not fatal")
|
||||
T.eq(#session:poll(), 0, "...and never reaches the mode")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -66,13 +66,39 @@ table.sort(MOVES)
|
||||
-- in the peer's inbox, so one side is mid-queue when the other's action
|
||||
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
|
||||
-- thing the real relay never does)
|
||||
local function laggyPair(delayA, delayB)
|
||||
-- mutation mode: a peer whose messages arrive with a random field (or the
|
||||
-- type itself) at the wrong Lua type. What is delivered is what Session
|
||||
-- would deliver -- Wire.sanitize's output, or nothing at all -- so a run
|
||||
-- exercises the real receive path rather than a hand-written stand-in.
|
||||
local Wire = require("src.link.Wire")
|
||||
local HOSTILE = { {}, { 1, 2, 3 }, 0, -1, 999, "s", "", true, false, math.huge }
|
||||
|
||||
local function mutate(rnd, msg)
|
||||
local keys = {}
|
||||
for k in pairs(msg) do
|
||||
if k ~= "type" then keys[#keys + 1] = k end
|
||||
end
|
||||
table.sort(keys)
|
||||
if #keys == 0 or rnd(1, 100) <= 20 then
|
||||
msg.type = HOSTILE[rnd(1, #HOSTILE)]
|
||||
else
|
||||
msg[keys[rnd(1, #keys)]] = HOSTILE[rnd(1, #HOSTILE)]
|
||||
end
|
||||
return msg
|
||||
end
|
||||
|
||||
local function laggyPair(delayA, delayB, rnd, mutateRate)
|
||||
local a, b = Net.loopbackPair()
|
||||
a.wire, b.wire = {}, {}
|
||||
a.delay, b.delay = delayA or 0, delayB or 0
|
||||
a.mutateRate, b.mutateRate = mutateRate or 0, mutateRate or 0
|
||||
local function send(self, msg)
|
||||
if self.closed then return end
|
||||
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
|
||||
if decoded and self.mutateRate > 0 then
|
||||
if rnd(1, 100) <= self.mutateRate then decoded = mutate(rnd, decoded) end
|
||||
decoded = Wire.sanitize(decoded)
|
||||
end
|
||||
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
|
||||
end
|
||||
local function update(self)
|
||||
@@ -143,7 +169,7 @@ local function firstMismatch(a, b)
|
||||
end
|
||||
|
||||
-- Returns nil when the run agreed, or a description of how it split.
|
||||
local function runOne(seed)
|
||||
local function runOne(seed, mutateRate)
|
||||
local rnd = makeRandom(seed)
|
||||
-- the two sides are deliberately different clients
|
||||
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
|
||||
@@ -155,7 +181,7 @@ local function runOne(seed)
|
||||
gameA.save.party = randomParty(rnd, rnd(1, 4))
|
||||
gameB.save.party = randomParty(rnd, rnd(1, 4))
|
||||
|
||||
local netA, netB = laggyPair(lagA, lagB)
|
||||
local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate)
|
||||
local battleSeed = rnd(1, 2 ^ 30)
|
||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||
myParty = Protocol.packParty(gameA.save.party),
|
||||
@@ -230,6 +256,12 @@ local function runOne(seed)
|
||||
end
|
||||
end
|
||||
local turn, part = firstMismatch(battleA, battleB)
|
||||
if turn and mutateRate then
|
||||
-- a corrupted action IS a divergence; what matters here is that the
|
||||
-- match ends by its own rules (desync draw, forfeit, disconnect)
|
||||
-- rather than throwing
|
||||
return nil, battleA.turnCount or 0
|
||||
end
|
||||
if turn then
|
||||
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
|
||||
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
|
||||
@@ -237,7 +269,7 @@ local function runOne(seed)
|
||||
end
|
||||
-- a battle still running at the guard is a stalemate (two mons that cannot
|
||||
-- KO each other), not a split; only a finished one can be checked mirrored
|
||||
if guard < 60000
|
||||
if not mutateRate and guard < 60000
|
||||
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
|
||||
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
|
||||
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
|
||||
@@ -250,6 +282,8 @@ end
|
||||
local RUNS = tonumber(arg and arg[1]) or 40
|
||||
local FIRST = tonumber(arg and arg[2]) or 1
|
||||
|
||||
local MUTATION_RUNS = tonumber(arg and arg[3]) or math.max(4, math.floor(RUNS / 4))
|
||||
|
||||
local failures, turns = 0, 0
|
||||
for seed = FIRST, FIRST + RUNS - 1 do
|
||||
local ok, why, t = pcall(runOne, seed)
|
||||
@@ -263,5 +297,21 @@ for seed = FIRST, FIRST + RUNS - 1 do
|
||||
end
|
||||
end
|
||||
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
|
||||
|
||||
local mutationFailures = 0
|
||||
for seed = FIRST, FIRST + MUTATION_RUNS - 1 do
|
||||
local ok, why = pcall(runOne, seed, 15)
|
||||
if not ok then
|
||||
mutationFailures = mutationFailures + 1
|
||||
print("FAIL link mutation fuzz seed " .. seed .. ": " .. tostring(why))
|
||||
elseif why then
|
||||
mutationFailures = mutationFailures + 1
|
||||
print("FAIL link mutation fuzz " .. why)
|
||||
end
|
||||
end
|
||||
print(("link mutation fuzz: %d runs, %d failures"):format(
|
||||
MUTATION_RUNS, mutationFailures))
|
||||
|
||||
assert(failures == 0, failures .. " lockstep run(s) diverged")
|
||||
assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw")
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
-- Hostile link traffic: every message type this build reads, with every
|
||||
-- field replaced by every wrong Lua type, driven through the real Session
|
||||
-- choke point and then into the real consumers (trade session, link battle,
|
||||
-- spectator battle, tournament screen -- including its draw).
|
||||
--
|
||||
-- The three payloads from the "How to Troll Pokemon Players" writeup are
|
||||
-- rows in the table below: action.slot as a table, hash.parts as a number,
|
||||
-- and pick.index out of range.
|
||||
--
|
||||
-- Self-contained; run directly or via run_link_tests.lua:
|
||||
-- luajit tests/link_hostile.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not Data.pokemon then Data:load() end
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
Input:init()
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local Net = require("src.link.Net")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Session = require("src.link.Session")
|
||||
local Tournament = require("src.link.Tournament")
|
||||
local Wire = require("src.link.Wire")
|
||||
|
||||
local failures = 0
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
print("ok " .. msg)
|
||||
else
|
||||
failures = failures + 1
|
||||
print("FAIL " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- corpus
|
||||
|
||||
local HOSTILE = { {}, { 1, 2, 3 }, { type = "x" }, 0, -1, 123, 1.5,
|
||||
math.huge, "s", "", true, false }
|
||||
|
||||
local function copy(v)
|
||||
if type(v) ~= "table" then return v end
|
||||
local out = {}
|
||||
for k, val in pairs(v) do out[k] = copy(val) end
|
||||
return out
|
||||
end
|
||||
|
||||
local packedMon = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
|
||||
|
||||
local TEMPLATES = {
|
||||
{ type = "hello", protocol = 2, name = "RED", mode = "battle",
|
||||
engineVersion = "1.0.0", apiVersion = "1", generation = 1,
|
||||
fingerprint = "abc123", linkModified = false,
|
||||
mods = { { id = "demo", version = "1.0", affectsLink = true } } },
|
||||
{ type = "records", pokemon = { PIKACHU = "a" }, moves = { TACKLE = "b" },
|
||||
heldItems = { BERRY = "c" } },
|
||||
{ type = "party", mons = { copy(packedMon) }, seed = 1234, forceLevel = 50 },
|
||||
{ type = "pick", index = 1 },
|
||||
{ type = "confirm", ok = true },
|
||||
{ type = "action", kind = "move", slot = 1, index = 1 },
|
||||
{ type = "hash", turn = 1, value = "v",
|
||||
parts = { actives = "a", volatile = "b", bench = "c" } },
|
||||
{ type = "replace", index = 1 },
|
||||
{ type = "bye" },
|
||||
{ type = "forfeit" },
|
||||
{ type = "spectate", side = "host",
|
||||
msg = { type = "action", kind = "move", slot = 1 } },
|
||||
{ type = "hosted", code = "ABCDEF" },
|
||||
{ type = "paired" },
|
||||
{ type = "peer_gone" },
|
||||
{ type = "join_error", reason = "not_found" },
|
||||
{ type = "tournament_hosted", code = "ABCDEF", turnLimit = 6,
|
||||
requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50,
|
||||
participating = true },
|
||||
{ type = "tournament_host_error", reason = "party_ineligible",
|
||||
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||
{ type = "tournament_join_error", reason = "party_ineligible",
|
||||
requiredPartySize = 3, minLevel = 5, maxLevel = 50 },
|
||||
{ type = "tournament_roster", players = { "RED", "BLUE" },
|
||||
spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3,
|
||||
minLevel = 5, maxLevel = 50, forceLevel = 50 },
|
||||
{ type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6,
|
||||
requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active",
|
||||
round = 1, champion = "RED",
|
||||
rounds = { { round = 1, matches = { { a = "RED", b = "BLUE",
|
||||
winner = "RED", bye = false, state = "live" } } } } } },
|
||||
{ type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6,
|
||||
role = "host" },
|
||||
{ type = "match_start_spectate", round = 1, playerHost = "RED",
|
||||
playerGuest = "BLUE" },
|
||||
{ type = "tournament_bye", round = 1 },
|
||||
{ type = "tournament_over", champion = "RED" },
|
||||
{ type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } },
|
||||
}
|
||||
|
||||
local NESTED = {
|
||||
{ "hash", { "parts", "actives" } },
|
||||
{ "party", { "mons", 1 } },
|
||||
{ "party", { "mons", 1, "dvs" } },
|
||||
{ "party", { "mons", 1, "moves" } },
|
||||
{ "party", { "mons", 1, "moves", 1 } },
|
||||
{ "party", { "mons", 1, "nickname" } },
|
||||
{ "party", { "mons", 1, "level" } },
|
||||
{ "party", { "mons", 1, "otId" } },
|
||||
{ "hello", { "mods", 1 } },
|
||||
{ "spectate", { "msg" } },
|
||||
{ "spectate", { "msg", "slot" } },
|
||||
{ "bracket_update", { "tournament", "rounds" } },
|
||||
{ "bracket_update", { "tournament", "rounds", 1, "matches" } },
|
||||
{ "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } },
|
||||
{ "tournament_roster", { "players", 1 } },
|
||||
}
|
||||
|
||||
local function templateFor(kind)
|
||||
for _, t in ipairs(TEMPLATES) do
|
||||
if t.type == kind then return t end
|
||||
end
|
||||
end
|
||||
|
||||
local function setPath(root, path, value)
|
||||
local node = root
|
||||
for i = 1, #path - 1 do
|
||||
node = node[path[i]]
|
||||
if type(node) ~= "table" then return false end
|
||||
end
|
||||
node[path[#path]] = value
|
||||
return true
|
||||
end
|
||||
|
||||
local corpus = {}
|
||||
local function add(msg) corpus[#corpus + 1] = msg end
|
||||
|
||||
add(false); add(true); add(123); add("string"); add({}); add({ 1, 2, 3 })
|
||||
add({ type = 5 }); add({ type = {} }); add({ type = true })
|
||||
add({ type = ("x"):rep(4096) })
|
||||
|
||||
for _, template in ipairs(TEMPLATES) do
|
||||
add(copy(template))
|
||||
for key in pairs(template) do
|
||||
if key ~= "type" then
|
||||
for _, bad in ipairs(HOSTILE) do
|
||||
local m = copy(template)
|
||||
m[key] = copy(bad)
|
||||
add(m)
|
||||
end
|
||||
local missing = copy(template)
|
||||
missing[key] = nil
|
||||
add(missing)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, row in ipairs(NESTED) do
|
||||
local template = templateFor(row[1])
|
||||
for _, bad in ipairs(HOSTILE) do
|
||||
local m = copy(template)
|
||||
if setPath(m, row[2], copy(bad)) then add(m) end
|
||||
end
|
||||
local m = copy(template)
|
||||
if setPath(m, row[2], nil) then add(m) end
|
||||
end
|
||||
|
||||
print(("hostile corpus: %d messages"):format(#corpus))
|
||||
|
||||
-- ---------------------------------------------------------------- session
|
||||
|
||||
local function fakeTransport(inbox)
|
||||
local transport = { paired = true, closed = false, error = nil,
|
||||
inbox = inbox, sent = {} }
|
||||
function transport:update() end
|
||||
function transport:poll()
|
||||
local messages = self.inbox
|
||||
self.inbox = {}
|
||||
return messages
|
||||
end
|
||||
function transport:send(m) table.insert(self.sent, m) end
|
||||
function transport:close() self.closed = true end
|
||||
return transport
|
||||
end
|
||||
|
||||
local transport = fakeTransport(copy(corpus))
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
local okUpdate, updateErr = pcall(session.update, session)
|
||||
check(okUpdate, "the whole hostile corpus goes through Session without throwing"
|
||||
.. (okUpdate and "" or (": " .. tostring(updateErr))))
|
||||
check(session:getFailure() == nil,
|
||||
"a hostile peer cannot latch a terminal failure on the session")
|
||||
check(session:getStatus() == "paired", "the session is still usable afterwards")
|
||||
local survivors = session:poll()
|
||||
check(#survivors > 0, "well-formed messages still get through")
|
||||
check(session.dropped > 0, "malformed messages are counted as dropped")
|
||||
|
||||
for _, msg in ipairs(survivors) do
|
||||
if type(msg.type) ~= "string" then
|
||||
check(false, "every delivered message has a string type")
|
||||
break
|
||||
end
|
||||
end
|
||||
check(true, "every delivered message has a string type")
|
||||
|
||||
for _, msg in ipairs(survivors) do
|
||||
local ok = pcall(Json.encode, msg)
|
||||
if not ok then
|
||||
check(false, "every delivered message is still encodable (" .. msg.type .. ")")
|
||||
break
|
||||
end
|
||||
end
|
||||
check(true, "every delivered message is still encodable")
|
||||
|
||||
-- ---------------------------------------------------------------- consumers
|
||||
|
||||
local function makeFakeGame(species, name)
|
||||
local save = require("src.core.SaveData").newGame()
|
||||
save.player.name = name or "RED"
|
||||
table.insert(save.party, Pokemon.new(Data, species, 20))
|
||||
local stack = { list = {} }
|
||||
function stack:push(s, ...)
|
||||
table.insert(self.list, s)
|
||||
if s.enter then s:enter(...) end
|
||||
end
|
||||
function stack:pop() return table.remove(self.list) end
|
||||
function stack:top() return self.list[#self.list] end
|
||||
function stack:update(dt)
|
||||
local t = self:top()
|
||||
if t and t.update then t:update(dt) end
|
||||
end
|
||||
return { data = Data, input = Input, stack = stack, save = save }
|
||||
end
|
||||
|
||||
do
|
||||
local crashed
|
||||
for _, msg in ipairs(survivors) do
|
||||
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||
local t = Protocol.TradeSession.new(Data, party)
|
||||
local ok, err = pcall(function()
|
||||
t:handle({ type = "party", mons = Protocol.packParty({
|
||||
Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||
t:handle(msg)
|
||||
t:pick(1)
|
||||
t:handle(msg)
|
||||
t:confirm(true)
|
||||
t:handle(msg)
|
||||
if t.stage == "done" then t:apply(nil) end
|
||||
end)
|
||||
if not ok then crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) end
|
||||
if crashed then break end
|
||||
end
|
||||
check(not crashed, "the trade session survives every hostile message"
|
||||
.. (crashed and (": " .. crashed) or ""))
|
||||
end
|
||||
|
||||
do
|
||||
local party = { Pokemon.new(Data, "KADABRA", 30) }
|
||||
local t = Protocol.TradeSession.new(Data, party)
|
||||
t:handle({ type = "party",
|
||||
mons = Protocol.packParty({ Pokemon.new(Data, "MACHOKE", 32) }) })
|
||||
t:pick(1)
|
||||
t:handle(Wire.sanitize({ type = "pick", index = 0 }))
|
||||
t:confirm(true)
|
||||
t:handle(Wire.sanitize({ type = "confirm", ok = true }))
|
||||
check(t.stage ~= "done", "an out-of-range pick never reaches a committed trade")
|
||||
check(t.stage == "cancelled", "...it cancels the trade instead")
|
||||
end
|
||||
|
||||
do
|
||||
local gameA = makeFakeGame("CHARIZARD", "RED")
|
||||
local gameB = makeFakeGame("BLASTOISE", "BLUE")
|
||||
local netA, netB = Net.loopbackPair()
|
||||
local battleA = LinkBattle.newHost(gameA, netA, {
|
||||
myParty = Protocol.packParty(gameA.save.party),
|
||||
theirParty = Protocol.packParty(gameB.save.party),
|
||||
theirName = "BLUE", seed = 4242 })
|
||||
gameA.stack:push(battleA)
|
||||
local crashed
|
||||
for _, msg in ipairs(survivors) do
|
||||
table.insert(netA.inbox, copy(msg))
|
||||
local ok, err = pcall(function()
|
||||
Input.pressed = {}
|
||||
gameA.stack:update(1 / 60)
|
||||
end)
|
||||
if not ok then
|
||||
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||
break
|
||||
end
|
||||
end
|
||||
check(not crashed, "a link battle survives every hostile message"
|
||||
.. (crashed and (": " .. crashed) or ""))
|
||||
end
|
||||
|
||||
do
|
||||
local gameSpec = makeFakeGame("RATTATA", "WATCHER")
|
||||
local specInbox = {}
|
||||
local specNet = {
|
||||
closed = false,
|
||||
update = function() end,
|
||||
poll = function()
|
||||
local msgs = specInbox
|
||||
specInbox = {}
|
||||
return msgs
|
||||
end,
|
||||
send = function() end,
|
||||
close = function() end,
|
||||
}
|
||||
local battle = LinkBattle.newSpectator(gameSpec, specNet, {
|
||||
hostParty = Protocol.packParty(makeFakeGame("CHARIZARD").save.party),
|
||||
guestParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
|
||||
hostName = "RED", guestName = "BLUE", seed = 99 })
|
||||
gameSpec.stack:push(battle)
|
||||
local crashed
|
||||
for _, msg in ipairs(corpus) do
|
||||
for _, side in ipairs({ "host", "guest", 5, {} }) do
|
||||
local wrapped = Wire.sanitize({ type = "spectate", side = side,
|
||||
msg = copy(msg) })
|
||||
if wrapped then table.insert(specInbox, wrapped) end
|
||||
end
|
||||
local ok, err = pcall(function()
|
||||
Input.pressed = {}
|
||||
gameSpec.stack:update(1 / 60)
|
||||
end)
|
||||
if not ok then
|
||||
crashed = tostring(err)
|
||||
break
|
||||
end
|
||||
end
|
||||
check(not crashed, "a spectator battle survives every hostile envelope"
|
||||
.. (crashed and (": " .. crashed) or ""))
|
||||
end
|
||||
|
||||
do
|
||||
local game = makeFakeGame("PIKACHU", "RED")
|
||||
local exits = 0
|
||||
local t = setmetatable({
|
||||
game = game,
|
||||
stage = "bracket",
|
||||
index = 1,
|
||||
settingsIndex = 1,
|
||||
settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY",
|
||||
maxLevel = "ANY", forceLevel = "ANY", participating = true },
|
||||
roster = {},
|
||||
spectatorRoster = {},
|
||||
isCreator = false,
|
||||
net = { send = function() end, close = function() end,
|
||||
take = function() return nil end,
|
||||
poll = function() return {} end,
|
||||
hasPending = function() return false end },
|
||||
}, Tournament)
|
||||
t.exitWith = function(self) exits = exits + 1 end
|
||||
local crashed
|
||||
for _, msg in ipairs(survivors) do
|
||||
local ok, err = pcall(function()
|
||||
t:handleMessage(msg)
|
||||
t:draw()
|
||||
end)
|
||||
if not ok then
|
||||
crashed = ("%s: %s"):format(tostring(msg.type), tostring(err))
|
||||
break
|
||||
end
|
||||
if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then
|
||||
crashed = ("%s left a non-table roster"):format(tostring(msg.type))
|
||||
break
|
||||
end
|
||||
end
|
||||
check(not crashed, "the tournament screen survives every hostile message"
|
||||
.. (crashed and (": " .. crashed) or ""))
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- json
|
||||
do
|
||||
local deep = ("["):rep(4096) .. ("]"):rep(4096)
|
||||
local value, err = Json.decode(deep)
|
||||
check(value == nil and err ~= nil, "a deeply nested document is refused")
|
||||
local long = '{"type":"hello","name":"' .. ("x"):rep(1024) .. '"}'
|
||||
check(Json.decode(long, 256) == nil, "a document past the caller's cap is refused")
|
||||
check(Json.decode(long) ~= nil, "...and the cap is opt-in for other callers")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- net caps
|
||||
do
|
||||
local n = Net.new()
|
||||
n.rxBuf = ("x"):rep(Net.MAX_LINE + 1)
|
||||
n:drainLines()
|
||||
check(n.closed and n.error ~= nil,
|
||||
"a peer that never sends a newline closes the connection")
|
||||
check(#n.rxBuf == 0, "...and the buffer is released")
|
||||
end
|
||||
|
||||
print(("\nlink hostile: %d messages, %d failures"):format(#corpus, failures))
|
||||
assert(failures == 0, failures .. " hostile-input failure(s)")
|
||||
return true
|
||||
@@ -645,6 +645,14 @@ check(fxAdvances("messages", "linkNext"),
|
||||
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
||||
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
||||
|
||||
-- ---------------------------------------------------------------- hostile wire
|
||||
-- Every message type crossed with every wrong Lua type, through the real
|
||||
-- Session choke point and into the real consumers. The regression net for
|
||||
-- the remote-crash payloads; self-contained like the fuzz above.
|
||||
local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua")
|
||||
check(hostileOk, "hostile wire suite"
|
||||
.. (hostileOk and "" or (": " .. tostring(hostileErr))))
|
||||
|
||||
-- ---------------------------------------------------------------- mod link compat
|
||||
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
||||
-- assert-based checks, so it lands here as a single pass/fail line.
|
||||
|
||||
Reference in New Issue
Block a user