mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
665 lines
28 KiB
Lua
665 lines
28 KiB
Lua
#!/usr/bin/env luajit
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local failed = 0
|
|
local function check(cond, msg)
|
|
if cond then
|
|
print("[ok] " .. msg)
|
|
else
|
|
failed = failed + 1
|
|
print("[FAIL] " .. msg)
|
|
end
|
|
end
|
|
|
|
local function eq(a, b, msg)
|
|
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
|
|
end
|
|
|
|
local Json = require("src.link.Json")
|
|
local Wire = require("src.link.Wire")
|
|
local MG = require("src.core.game3.minigames.common")
|
|
local R = require("src.core.game3.minigames.berry_crush.rules")
|
|
local Phys = require("src.core.game3.minigames.berry_crush.physics")
|
|
|
|
local ROOM = "r0123456789abcdef"
|
|
|
|
print("[test] 1. fixed-point helpers (berry_crush.c Q_24_8 / Q_N_S)")
|
|
do
|
|
eq(R.trunc(-7, 2), -3, "C division truncates toward zero")
|
|
eq(R.trunc(7, 0), 0, "division by zero reads 0 (agbcc __div0)")
|
|
eq(R.s16(0x8000), -32768, "s16 wraps")
|
|
eq(R.shr(-5, 1), -3, "arithmetic shift floors")
|
|
eq(R.q24div(600 * 256, 60 * 256), 2560, "Q_24_8_div 600/60")
|
|
eq(R.q24mul(1280, 25600), 128000, "Q_24_8_mul")
|
|
eq(R.qnsDiv(7, R.qns(7, 127), 272), 7649, "berry drop var1")
|
|
eq(R.berryIndex(133), 0, "CHERI is berry 0")
|
|
eq(R.berryIndex(175), 42, "ENIGMA is berry 42")
|
|
eq(R.berryIndex(176), nil, "176 is not a berry")
|
|
end
|
|
|
|
print("[test] 2. pressing speed, rankings, records (no tables)")
|
|
do
|
|
eq(R.pressingSpeed(600, 100), 2560, "100 presses in 10 s = 10.00/s (Q24.8)")
|
|
eq(R.pressingSpeed(900, 100), 1706, "100 presses in 15 s")
|
|
eq(R.pressingSpeed(0, 100), 0, "zero time reads 0")
|
|
local s0, r0, s1, r1 = R.rank({ 5, 7, 7 }, { 3, 1, 2 })
|
|
eq(table.concat(s0, ","), "7,7,5", "presses sorted descending")
|
|
eq(table.concat(r0, ","), "2,3,1", "bubble sort keeps the cart's tie order")
|
|
eq(table.concat(s1, ","), "3,2,1", "random page sorted")
|
|
eq(table.concat(r1, ","), "1,3,2", "random page player order")
|
|
local st = R.newState(3)
|
|
st.nb, st.nc, st.powder, st.timer = 3, 10, 90, 600
|
|
st.pl[1].np, st.pl[1].mx, st.pl[1].ns = 10, 5, 4
|
|
st.pl[2].np, st.pl[2].mx, st.pl[2].ns = 8, 8, 8
|
|
st.pl[3].np = 0
|
|
st.held = { 300, 700, 0 }
|
|
local t = R.tabulate(st, R.RANDOM_NEATNESS)
|
|
eq(t.silk, 65, "silkiness = 3 big / 10 checks -> 65")
|
|
eq(t.powder, 175, "powder = 90 * 3 players * 65% (Q24.8)")
|
|
eq(t.total, 18, "total presses")
|
|
eq(t.random[1], 800, "neatness 5/10 = 50.00 (Q.4)")
|
|
eq(t.random[2], 1600, "neatness 8/8 = 100.00")
|
|
eq(t.random[3], 0, "no presses -> 0")
|
|
local p = R.tabulate(st, R.RANDOM_POWER)
|
|
eq(p.random[1], 800, "power: held 300 of 600 frames = 50%")
|
|
eq(p.random[2], 1600, "power: held past the timer = 100%")
|
|
local c = R.tabulate(st, R.RANDOM_COOPERATIVE)
|
|
eq(c.random[1], 640, "cooperative 4/10 = 40%")
|
|
local zero = R.newState(2)
|
|
zero.nc = 0
|
|
eq(R.tabulate(zero, 0).silk, 50, "no sparkle checks: division reads 0 -> 50")
|
|
local Records = require("src.ui.game3.minigame_records")
|
|
local session = {}
|
|
check(Records.updateBerryCrush(session, 3, 2560), "first 3-player record")
|
|
check(not Records.updateBerryCrush(session, 3, 2000), "a slower speed is not a record")
|
|
eq(session.berryCrushPressingSpeeds[2], 2560, "3 players -> pressingSpeeds[1]")
|
|
end
|
|
|
|
print("[test] 3. link-state records round-trip")
|
|
do
|
|
local q = {}
|
|
R.encodeRecord({ t = 35999, d = 32, v = -2, fl = 0x7FFF, sa = 4, big = true, eg = true }, q)
|
|
R.encodeRecord({ t = 1, d = 0, v = 3, fl = 6, sa = 0, big = false, eg = false }, q)
|
|
local back = R.decodeRecords(Json.decode(Json.encode(q)))
|
|
eq(#back, 2, "two records")
|
|
eq(back[1].t, 35999, "timer")
|
|
eq(back[1].v, -2, "negative vibration")
|
|
check(back[1].big and back[1].eg and back[1].sa == 4, "big sparkle / end / sparkle amount")
|
|
eq(R.recordFlags(back[2], 1), 6, "player 1 flags")
|
|
eq(R.recordFlags({ fl = 6 + 48 }, 2), 6, "player 2 flags (3 bits each)")
|
|
end
|
|
|
|
print("[test] 4. sprite animation timing (sprite.c)")
|
|
do
|
|
local sp = Phys.newImpact({ impactXOffset = 0, impactYOffset = -16 })
|
|
Phys.startImpact(sp, 1, { impact_coords = { { 0, 0 }, { -1, 0 }, { 1, 1 } } })
|
|
local frames = 0
|
|
while not sp.invisible and frames < 100 do
|
|
Phys.stepImpact(sp)
|
|
frames = frames + 1
|
|
end
|
|
eq(frames, 14, "small impact: 3 frames x 4 then END, hidden on the next callback")
|
|
local big = Phys.newImpact({ impactXOffset = 0, impactYOffset = -16 })
|
|
Phys.startImpact(big, 5, { impact_coords = { { 0, 0 }, { -1, 0 }, { 1, 1 } } })
|
|
eq(big.anim.num, 1, "sync flag -> big impact")
|
|
eq(big.x2, 0, "impact offset from sImpactCoords[(5 % 4) - 1]")
|
|
local n = Phys.dropFrames({ berryXOffset = -24, berryXDest = 16 })
|
|
check(n > 20 and n < 60, "a berry falls from -16 to 112 in " .. n .. " frames")
|
|
eq(Phys.dropFrames({ berryXOffset = 32, berryXDest = -8 }), n, "every seat's berry takes the same time")
|
|
end
|
|
|
|
local Cache = require("tests.game3_cache")
|
|
local bundle = Cache.bundle("berry_crush/tables.lua")
|
|
if not bundle then
|
|
print("[skip] cache-backed checks: " .. tostring(Cache.reason))
|
|
if failed > 0 then
|
|
print(("[FAIL] %d check(s) failed"):format(failed))
|
|
os.exit(1)
|
|
end
|
|
print("[PASS] game3_mg_berry_crush_test")
|
|
os.exit(0)
|
|
end
|
|
|
|
local Art = require("src.ui.game3.minigames.common_art")
|
|
local G = require("src.core.game3.minigames.berry_crush")
|
|
local Sim = G.Sim
|
|
local Records = require("src.ui.game3.minigame_records")
|
|
local Bag = require("src.core.game3.bag")
|
|
local Countdown = require("src.ui.game3.minigames.common_countdown")
|
|
|
|
local art, artErr = G.loadArt(Art.cache())
|
|
check(art ~= nil, "berry_crush art + tables load from the cache (" .. tostring(artErr) .. ")")
|
|
if not art then
|
|
print(("[FAIL] %d check(s) failed"):format(failed + 1))
|
|
os.exit(1)
|
|
end
|
|
local T = art.tables
|
|
|
|
print("[test] 5. ROM tables match berry_crush.c")
|
|
do
|
|
eq(table.concat(T.sync_press_bonus, ","), "0,1,2,3,5", "sSyncPressBonus")
|
|
eq(table.concat(T.big_sparkle_thresholds, ","), "5,7,9,12", "sBigSparkleThresholds")
|
|
eq(table.concat(T.sparkle_thresholds[1], ","), "2,4,6,7", "sSparkleThresholds 2 players")
|
|
eq(table.concat(T.vibration[5], ","), "3,5,3,0", "sVibrationData 5 players")
|
|
eq(table.concat(T.intro_outro_vibration[5], ","), "6,4,1,-2,-4,-2,0", "sIntroOutroVibrationData[4]")
|
|
eq(#T.berry_data, 43, "43 berries")
|
|
eq(T.berry_data[1].difficulty, 50, "CHERI difficulty")
|
|
eq(T.player_coords[4].berryXOffset, 32, "sPlayerCoords[3]")
|
|
eq(table.concat(T.player_id_to_pos_id[4], ","), "0,1,3,2,4", "sPlayerIdToPosId 5 players")
|
|
eq(T.pressing_speed_table[8], 390625, "sPressingSpeedConversionTable")
|
|
eq(T.digit_templates[1].x, 156, "timer minutes at x 156")
|
|
check(art.namePal[1] ~= nil and art.namePal[4] ~= nil, "name colours from crusher.pal bank 8")
|
|
check(art.bg and art.crusher_top and art.impact and art.sparkle and art.timer_digits, "sheets present")
|
|
eq(art.impact.frames, 7, "impact frames")
|
|
eq(art.sparkle.frames, 14, "sparkle frames")
|
|
end
|
|
|
|
print("[test] 6. leader frame: HandlePartnerInput / BuildLocalState / HandlePlayerInput")
|
|
do
|
|
local st = R.newState(2)
|
|
R.setBerries(st, T, { 0, 0 })
|
|
eq(st.ta, 100, "two CHERIs: 100 presses target")
|
|
eq(st.targetDepth, 800, "targetDepth = Q24.8(100 / 32)")
|
|
eq(st.powder, 40, "berry powder base")
|
|
local rec = R.leaderFrame(st, { true, true }, T)
|
|
eq(st.tp, 3, "two synced presses + bonus 1")
|
|
eq(st.pl[1].ns, 1, "synced press counted")
|
|
eq(rec.fl, 6 + 6 * 8, "both players: hit flag 2 | sync 4")
|
|
eq(rec.v, 3, "vibration from sVibrationData[1][1]")
|
|
eq(st.nc, 0, "first big-sparkle check at timer 0")
|
|
eq(rec.sa, 1, "3 presses in the window -> sparkle amount 1")
|
|
check(not rec.eg, "not finished")
|
|
local ended = false
|
|
for _ = 1, 200 do
|
|
st.timer = st.lastT
|
|
rec = R.leaderFrame(st, { true, true }, T)
|
|
if rec.eg then ended = true break end
|
|
end
|
|
check(ended, "reaching the target ends the game")
|
|
eq(rec.d, 32, "crusher fully down")
|
|
local amounts = {}
|
|
local st2 = R.newState(5)
|
|
R.setBerries(st2, T, { 42, 42, 42, 42, 42 })
|
|
for f = 1, 900 do
|
|
st2.timer = st2.lastT < 0 and 0 or st2.lastT
|
|
local pressed = {}
|
|
for p = 1, 5 do pressed[p] = (f + p) % (1 + (f % 4)) == 0 end
|
|
local r = R.leaderFrame(st2, pressed, T)
|
|
amounts[r.sa] = true
|
|
if r.eg then break end
|
|
end
|
|
check(not amounts[2] and not amounts[3], "sparkle amount 2/3 unreachable (cart bug kept)")
|
|
end
|
|
|
|
local NOCD = function() return { step = function() return false end, draw = function() end } end
|
|
|
|
local function moduleFor(session, opts)
|
|
opts = opts or {}
|
|
local M = setmetatable({}, { __index = G })
|
|
M.hooks = setmetatable({
|
|
session = function() return session end,
|
|
playSe = function(id) if opts.se then opts.se[#opts.se + 1] = id end end,
|
|
playSong = function() end,
|
|
pauseMusic = function() end,
|
|
resumeMusic = function() end,
|
|
textSpeed = function() return 2 end,
|
|
tick = function() end,
|
|
newCountdown = function() return Countdown.new(120, 80, { playSe = function() end }) end,
|
|
pickBerry = function(sim, done)
|
|
local list = Bag.listPocket(session.bag, "BERRY_POUCH")
|
|
done(list[opts.pick or 1] and tonumber(list[opts.pick or 1].id) or nil)
|
|
end,
|
|
givePowder = function(s, amount)
|
|
if opts.powder then opts.powder[#opts.powder + 1] = amount end
|
|
return G.hooks.givePowder(s, amount)
|
|
end,
|
|
save = function() if opts.saves then opts.saves[1] = (opts.saves[1] or 0) + 1 end end,
|
|
}, { __index = G.hooks })
|
|
M.new = function(ctx) return Sim.new(M, ctx) end
|
|
return M
|
|
end
|
|
|
|
local Hub = {}
|
|
Hub.__index = Hub
|
|
|
|
function Hub.new(n)
|
|
local self = setmetatable({ order = {}, inbox = {}, present = {}, leader = 0, epoch = 1,
|
|
dropped = {}, names = {}, maxState = 0 }, Hub)
|
|
for s = 0, n - 1 do
|
|
self.order[#self.order + 1] = s
|
|
self.inbox[s] = {}
|
|
self.present[s] = true
|
|
self.names[s] = ({ "RED", "BLUE", "LEAF", "GOLD", "KRIS" })[s + 1]
|
|
end
|
|
return self
|
|
end
|
|
|
|
function Hub:playersList()
|
|
local out = {}
|
|
for _, s in ipairs(self.order) do
|
|
if self.present[s] then
|
|
out[#out + 1] = { id = ("%08x"):format(0xa0 + s), name = self.names[s], seat = s, online = true }
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function wireCopy(msg)
|
|
return Wire.sanitize(Json.decode(Json.encode(msg)))
|
|
end
|
|
|
|
function Hub:deliver(to, msg)
|
|
if not self.present[to] then return end
|
|
local q = self.inbox[to]
|
|
q[#q + 1] = wireCopy(msg)
|
|
q[#q].seat = msg.seat
|
|
q[#q].relay = msg.relay
|
|
end
|
|
|
|
function Hub:route(from, msg)
|
|
if not self.present[from] then return end
|
|
local bytes = #Json.encode(msg)
|
|
if msg.type == MG.MSG.STATE and bytes > self.maxState then self.maxState = bytes end
|
|
if bytes > ((msg.type == MG.MSG.RESULT) and 1024 or 512) then
|
|
self.dropped[#self.dropped + 1] = msg.type .. " cap " .. bytes
|
|
return
|
|
end
|
|
local inner = wireCopy(msg)
|
|
if not inner then
|
|
self.dropped[#self.dropped + 1] = msg.type .. " schema"
|
|
return
|
|
end
|
|
if inner.type == MG.MSG.STATE and from ~= self.leader then return end
|
|
inner.seat = from
|
|
for _, s in ipairs(self.order) do
|
|
if s ~= from then self:deliver(s, inner) end
|
|
end
|
|
end
|
|
|
|
function Hub:drop(seat)
|
|
self.present[seat] = false
|
|
self.inbox[seat] = {}
|
|
if seat == self.leader then
|
|
local nextSeat
|
|
for _, s in ipairs(self.order) do
|
|
if self.present[s] then nextSeat = s break end
|
|
end
|
|
self.leader = nextSeat
|
|
self.epoch = self.epoch + 1
|
|
local msg = { type = MG.MSG.LEADER, seat = nextSeat, prev = seat, epoch = self.epoch, relay = true }
|
|
for _, s in ipairs(self.order) do self:deliver(s, msg) end
|
|
end
|
|
end
|
|
|
|
function Hub:session(seat)
|
|
local hub = self
|
|
local rs = { paired = true, closed = false, left = false, target = ROOM }
|
|
function rs:send(msg) hub:route(seat, msg) end
|
|
function rs:poll()
|
|
local q = hub.inbox[seat]
|
|
hub.inbox[seat] = {}
|
|
return q
|
|
end
|
|
function rs:players() return hub:playersList() end
|
|
function rs:close()
|
|
self.left = true
|
|
self.closed = true
|
|
hub:drop(seat)
|
|
end
|
|
return rs
|
|
end
|
|
|
|
function Hub:client(seat)
|
|
local hub = self
|
|
return {
|
|
state = function() return hub.present[seat] and "online" or "offline" end,
|
|
room = function()
|
|
if not hub.present[seat] then return nil end
|
|
return { room = ROOM, leader = hub.leader, players = hub:playersList() }
|
|
end,
|
|
}
|
|
end
|
|
|
|
local function makeInput(every, st)
|
|
local input = { frame = 0 }
|
|
function input:wasPressed(b)
|
|
local sim = st.match and st.match.sim
|
|
if sim and sim.stage == "yesno" and st.wantNo then
|
|
if b == "down" then return sim.yesNo == 0 end
|
|
return b == "a" and sim.yesNo == 1 and self.frame % every == 0
|
|
end
|
|
return b == "a" and self.frame % every == 0
|
|
end
|
|
function input:isDown(b) return b == "a" and self.frame % every < 2 end
|
|
return input
|
|
end
|
|
|
|
local function makeGame(n, berriesBySeat, counts)
|
|
local hub = Hub.new(n)
|
|
local players = {}
|
|
for s = 0, n - 1 do
|
|
players[#players + 1] = { id = ("%08x"):format(0xa0 + s), name = hub.names[s], seat = s,
|
|
trainerId = 0x1000 + s, gender = s % 2 }
|
|
end
|
|
local seats = {}
|
|
for s = 0, n - 1 do
|
|
local session = { berryPowder = 10, bag = Bag.new(), name = hub.names[s] }
|
|
local count = counts and counts[s + 1] or 3
|
|
Bag.add(session.bag, berriesBySeat[s + 1], count)
|
|
local st = { session = session, se = {}, powder = {}, saves = {}, item = berriesBySeat[s + 1], count = count }
|
|
local M = moduleFor(session, { se = st.se, powder = st.powder, saves = st.saves })
|
|
local spec = { game = "crush", session = hub:session(s), seat = s, seats = n, players = players, seed = 777 }
|
|
st.match = MG.Match.new(spec, {
|
|
module = M, client = hub:client(s), art = art, countdown = NOCD,
|
|
onResult = function(msg, match) MG.applyResults(session, msg, match.seat, M) end,
|
|
})
|
|
st.input = makeInput(4 + s, st)
|
|
seats[s] = st
|
|
end
|
|
return hub, seats
|
|
end
|
|
|
|
local function step(seats, frames, hook)
|
|
for _ = 1, frames do
|
|
for s = 0, 4 do
|
|
local st = seats[s]
|
|
if st and not st.gone and not st.match:finished() then
|
|
st.input.frame = st.input.frame + 1
|
|
st.match:step(st.input)
|
|
end
|
|
end
|
|
if hook and hook() then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function allFinished(seats)
|
|
for s = 0, 4 do
|
|
local st = seats[s]
|
|
if st and not st.gone and not st.match:finished() then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
local function sum(list)
|
|
local n = 0
|
|
for _, v in ipairs(list) do n = n + v end
|
|
return n
|
|
end
|
|
|
|
print("[test] 7. 3-seat game over the fake relay: two rounds, play again, no berries left")
|
|
do
|
|
local hub, seats = makeGame(3, { 133, 140, 150 }, { 3, 3, 2 })
|
|
local sawStages = {}
|
|
local predictedImpact = false
|
|
local firstTarget, firstBerries
|
|
step(seats, 20000, function()
|
|
for s = 0, 2 do
|
|
local sim = seats[s].match.sim
|
|
if sim then
|
|
sawStages[sim.stage] = true
|
|
if s == 1 and sim.stage == "play" and sim.predicted > 0 and not sim.impacts[sim.me].invisible then
|
|
predictedImpact = true
|
|
end
|
|
if s == 0 and not firstTarget and sim.A.ph == Sim.PH.PLAY then
|
|
firstTarget = sim.A.ta
|
|
end
|
|
if s == 1 and not firstBerries and sim.A.ph == Sim.PH.PLAY then
|
|
firstBerries = table.concat(sim.A.berries, ",")
|
|
end
|
|
end
|
|
end
|
|
return allFinished(seats)
|
|
end)
|
|
check(allFinished(seats), "every seat finished (" .. tostring(seats[0].match.phase) .. "/"
|
|
.. tostring(seats[1].match.phase) .. "/" .. tostring(seats[2].match.phase) .. ")")
|
|
for _, name in ipairs({ "ask", "pouch", "waitmsg", "drop", "lid", "countdown", "play", "finish", "results",
|
|
"powder", "standby", "saving", "yesno", "again", "stop" }) do
|
|
check(sawStages[name], "stage " .. name)
|
|
end
|
|
eq(#hub.dropped, 0, "no message dropped by the byte caps (" .. table.concat(hub.dropped, ";") .. ")")
|
|
check(hub.maxState <= 512, "largest game3_mg_state " .. hub.maxState .. " bytes")
|
|
check(predictedImpact, "a member's own impact shows before the leader confirms (prediction)")
|
|
eq(firstTarget, T.berry_data[1].difficulty + T.berry_data[8].difficulty + T.berry_data[18].difficulty,
|
|
"target presses from the three berries")
|
|
eq(firstBerries, "0,7,17", "every seat knows every berry")
|
|
local r0 = seats[0].match.result
|
|
check(r0 ~= nil, "leader produced game3_mg_result")
|
|
for s = 1, 2 do
|
|
local rs = seats[s].match.result
|
|
check(rs ~= nil and MG.same(rs.results, r0.results), "seat " .. s .. " agrees")
|
|
end
|
|
eq(#(r0 and r0.powder or {}), 0, "powder was given in-game, not by the MG result")
|
|
for s = 0, 2 do
|
|
local st = seats[s]
|
|
local sim = st.match.sim
|
|
eq(sim.round, 1, "seat " .. s .. " played two rounds")
|
|
eq(Bag.get(st.session.bag, st.item), st.count - 2, "seat " .. s .. " used one berry per round")
|
|
eq(#st.powder, 2, "seat " .. s .. " got powder twice")
|
|
check(st.powder[1] > 0 and st.powder[2] > 0, "powder " .. table.concat(st.powder, "+"))
|
|
eq(st.session.berryPowder, 10 + sum(st.powder), "seat " .. s .. " Berry Powder total")
|
|
check((st.session.berryCrushPressingSpeeds and st.session.berryCrushPressingSpeeds[2] or 0) > 0,
|
|
"seat " .. s .. " 3-player pressing speed record")
|
|
eq(st.session.gameStats and st.session.gameStats[Sim.GAME_STAT_BERRY_CRUSH_POINTS], 2, "GAME_STAT_BERRY_CRUSH_POINTS")
|
|
eq(st.saves[1], 2, "seat " .. s .. " saved after each round")
|
|
check(sim.over, "seat " .. s .. " closed the game")
|
|
end
|
|
eq(seats[0].powder[1], seats[1].powder[1], "everyone gets the same powder")
|
|
eq(seats[2].match.sim.answer, Sim.ANSWER.NO_BERRIES, "LEAF ran out of berries")
|
|
eq(seats[0].match.sim.answer, Sim.ANSWER.YES, "RED wanted another round")
|
|
local function has(list, id)
|
|
for _, v in ipairs(list) do if v == id then return true end end
|
|
return false
|
|
end
|
|
check(has(seats[1].se, Sim.SE.BALL_THROW) and has(seats[1].se, Sim.SE.FALL) and has(seats[1].se, Sim.SE.M_STRENGTH),
|
|
"drop / lid sounds")
|
|
check(has(seats[1].se, Sim.SE.MUD_BALL) or has(seats[1].se, Sim.SE.BREAKABLE_DOOR), "crush sounds")
|
|
local sim1 = seats[1].match.sim
|
|
local lines, win = require("src.ui.game3.minigames.berry_crush.view").buildPage(sim1, 2)
|
|
check(#lines > 6 and win.width == 24, "crushing results page builds (" .. #lines .. " lines)")
|
|
local found = false
|
|
for _, l in ipairs(lines) do if l.text:find("BERRY", 1, true) then found = true end end
|
|
check(found, "berry names on the crushing page")
|
|
end
|
|
|
|
print("[test] 8. leader drops mid-game: seat 1 takes over and the game still finishes")
|
|
do
|
|
local hub, seats = makeGame(3, { 133, 133, 133 })
|
|
for s = 0, 2 do seats[s].wantNo = true end
|
|
local dropped = false
|
|
step(seats, 12000, function()
|
|
local sim0 = seats[0].match.sim
|
|
if not dropped and sim0 and sim0.A.ph == Sim.PH.PLAY and sim0.A.tp >= 30 then
|
|
dropped = true
|
|
seats[0].gone = true
|
|
hub:drop(0)
|
|
end
|
|
return dropped and seats[1].match:finished() and seats[2].match:finished()
|
|
end)
|
|
check(dropped, "seat 0 dropped during play")
|
|
check(seats[1].match:isLeader(), "seat 1 became leader")
|
|
eq(seats[2].match.leader, 1, "seat 2 follows seat 1")
|
|
check(seats[1].match.sim.A.gone[1], "the old leader is marked gone")
|
|
eq(seats[1].match.phase, "done", "seat 1 finished as leader")
|
|
eq(seats[2].match.phase, "done", "seat 2 finished")
|
|
local a, b = seats[1].match.result, seats[2].match.result
|
|
check(a ~= nil and b ~= nil and MG.same(a.results, b.results), "seats 1 and 2 agree")
|
|
local tp = seats[1].match.sim.res and seats[1].match.sim.res.total or 0
|
|
check(tp >= 30, "presses before the drop were kept (" .. tp .. ")")
|
|
eq(#seats[1].powder, 1, "one round of powder for seat 1")
|
|
eq(seats[1].powder[1], seats[2].powder[1], "seats 1 and 2 got the same powder")
|
|
eq(seats[2].match.sim.answer, Sim.ANSWER.NO, "declined another round")
|
|
end
|
|
|
|
print("[test] 9. time up at 10 minutes: no powder, no record")
|
|
do
|
|
local ctx = { seat = 0, leader = true, players = { { seat = 0, name = "RED" }, { seat = 1, name = "BLUE" } },
|
|
art = art, rng = MG.rng(1) }
|
|
local session = { bag = Bag.new() }
|
|
local powder = {}
|
|
local M = moduleFor(session, { powder = powder })
|
|
local sim = Sim.new(M, ctx)
|
|
local inputs = { { r = 0, b = 0, a = 3, n = 0, h = 0 }, { r = 0, b = 0, a = 3, n = 0, h = 0 } }
|
|
local present = { true, true }
|
|
sim.A.ph = Sim.PH.COUNTDOWN
|
|
R.setBerries(sim.A, T, { 0, 0 })
|
|
local frames = 0
|
|
while sim.A.ph ~= Sim.PH.TIMEUP and frames < R.MAX_TIME + 10 do
|
|
sim:leaderStep(inputs, present)
|
|
frames = frames + 1
|
|
end
|
|
eq(sim.A.ph, Sim.PH.TIMEUP, "leader times out")
|
|
eq(sim.A.timer, R.MAX_TIME, "timer clamped to MAX_TIME")
|
|
inputs[1].a, inputs[2].a = 4, 4
|
|
sim:leaderStep(inputs, present)
|
|
eq(sim.A.ph, Sim.PH.RESULTS, "results phase")
|
|
check(sim.A.res and sim.A.res.timeUp, "results say time up")
|
|
sim:takeResults(sim.A.res)
|
|
eq(#powder, 0, "no powder")
|
|
eq(session.berryCrushPressingSpeeds, nil, "records untouched")
|
|
check(not G.applyResults(session, sim:results(), 0), "the MG result writes nothing")
|
|
end
|
|
|
|
print("[test] 10. snapshot stays under the relay cap with 5 players")
|
|
do
|
|
local players = {}
|
|
for s = 0, 4 do players[#players + 1] = { seat = s, name = "PLAYER" .. s } end
|
|
local sim = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 0, leader = true, players = players, art = art })
|
|
R.setBerries(sim.A, T, { 42, 42, 42, 42, 42 })
|
|
sim.A.ph = Sim.PH.PLAY
|
|
local A = sim.A
|
|
A.lt, A.timer, A.tp, A.nc, A.nb, A.lastT = 35999, 35998, 32000, 1199, 1199, 35999
|
|
for p = 1, 5 do
|
|
local pl = A.pl[p]
|
|
pl.np, pl.ns, pl.st, pl.mx, pl.td, pl.it, pl.fl, pl.sn = 9999, 9999, 9999, 9999, 35999, 35999, 2, 9999
|
|
A.held[p] = 35999
|
|
end
|
|
for t = 1, 3 do
|
|
sim.outq[#sim.outq + 1] = { t = 35990 + t, d = 32, v = -4, fl = 0x7FFF, sa = 4, big = true, eg = false }
|
|
end
|
|
local msg = { type = MG.MSG.STATE, f = 99999, e = 12, s = sim:snapshot() }
|
|
local bytes = #Json.encode(msg)
|
|
check(bytes <= 512, "worst-case game3_mg_state is " .. bytes .. " bytes")
|
|
local saved = sim.A.ph
|
|
sim.A.ph = Sim.PH.RESULTS
|
|
sim.A.res = { time = 35999, silk = 100, powder = 99999, page = 2, total = 49995,
|
|
presses = { 9999, 9999, 9999, 9999, 9999 }, random = { 1600, 1600, 1600, 1600, 1600 } }
|
|
local rbytes = #Json.encode({ type = MG.MSG.STATE, f = 99999, e = 12, s = sim:snapshot() })
|
|
check(rbytes <= 512, "results-phase game3_mg_state is " .. rbytes .. " bytes")
|
|
sim.A.ph, sim.A.res = saved, nil
|
|
local back = Wire.sanitize(Json.decode(Json.encode(msg)))
|
|
local other = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 1, leader = false, players = players, art = art })
|
|
other:applySnapshot(back.s)
|
|
eq(other.A.pl[5].np, 9999, "member restores player counters")
|
|
eq(other.A.lt, 35999, "member restores leader timer")
|
|
eq(#other.vq, 3, "records queued for playback")
|
|
other:becomeLeader(back.s)
|
|
check(other.leader and #other.vq == 3, "becomeLeader does not queue records twice")
|
|
end
|
|
|
|
print("[test] 11. results pages and cart text")
|
|
do
|
|
local players = { { seat = 0, name = "RED" }, { seat = 1, name = "BLUE" } }
|
|
local sim = Sim.new(moduleFor({ bag = Bag.new(), berryPowder = 500 }),
|
|
{ seat = 1, leader = false, players = players, art = art })
|
|
R.setBerries(sim.A, T, { 0, 8 })
|
|
sim:takeResults({ time = 1234, silk = 70, powder = 88, page = 1, total = 75,
|
|
presses = { 40, 35 }, random = { 800, 900 } })
|
|
eq(sim.res.rank0[1], 1, "RED first on presses")
|
|
eq(sim.res.rank1[1], 2, "BLUE first on the cooperative page")
|
|
local lines = require("src.ui.game3.minigames.berry_crush.view").buildPage(sim, 1)
|
|
local text = {}
|
|
for _, l in ipairs(lines) do text[#text + 1] = l.text end
|
|
local joined = table.concat(text, "|")
|
|
check(joined:find("Cooperative Rankings", 1, true) ~= nil, "random page header (" .. joined .. ")")
|
|
check(joined:find("56.25", 1, true) ~= nil, "Q.4 percentage 900/16 = 56.25")
|
|
local crush = require("src.ui.game3.minigames.berry_crush.view").buildPage(sim, 2)
|
|
local all = {}
|
|
for _, l in ipairs(crush) do all[#all + 1] = l.text end
|
|
joined = table.concat(all, "|")
|
|
check(joined:find("CHERI BERRY", 1, true) ~= nil and joined:find("LUM BERRY", 1, true) ~= nil,
|
|
"berry names (" .. joined .. ")")
|
|
check(joined:find("Silkiness", 1, true) ~= nil and joined:find(" 70%", 1, true) ~= nil, "silkiness")
|
|
check(joined:find("20.53", 1, true) ~= nil, "time 1234 frames = 0 min 20.53 sec")
|
|
local Printer = require("src.ui.game3.minigames.berry_crush.printer")
|
|
local music = {}
|
|
local p
|
|
p = Printer.new(G.hooks.text(Sim.MSG.POWDER, { "88", "588" }), 1,
|
|
{ music = function(cmd, arg) music[#music + 1] = cmd .. ":" .. arg .. "@" .. (p and p.revealed or 0) end })
|
|
eq(table.concat(music, ","), "23:0@0,11:257@0", "PAUSE_MUSIC then PLAY_BGM MUS_LEVEL_UP before the first glyph")
|
|
local guard = 0
|
|
while not p.prompt and guard < 500 do p:tick(nil) guard = guard + 1 end
|
|
check(p.prompt, "the powder message stops for A at its paragraph break")
|
|
eq(music[3], "24:0@" .. p.total, "RESUME_MUSIC after the last glyph of page 1")
|
|
local A = { wasPressed = function(_, b) return b == "a" end }
|
|
p:tick(A)
|
|
while not p.prompt and guard < 1000 do p:tick(nil) guard = guard + 1 end
|
|
check(p.prompt and p:printed() and not p:done(), "the trailing \\p holds the second page for A")
|
|
check(p:text():find("588", 1, true) ~= nil, "total Berry Powder in the text")
|
|
for _ = 1, 30 do p:tick(nil) end
|
|
check(not p:done(), "no auto-close without A")
|
|
p:tick(A)
|
|
p:tick(nil)
|
|
p:tick(nil)
|
|
check(p:done() and p:text() == "", "A clears the window and ends the message")
|
|
for _, key in ipairs({ Sim.MSG.PICK_BERRY, Sim.MSG.TIMES_UP }) do
|
|
local q = Printer.new(G.hooks.text(key), 0)
|
|
for _ = 1, 400 do if q.prompt and q.page == #q.pages then break end q:tick(q.prompt and A or nil) end
|
|
check(q.prompt and q.page == #q.pages and not q:done(), key .. " waits for A after its last page")
|
|
end
|
|
local w = Printer.new(G.hooks.text(Sim.MSG.COMM_STANDBY), 0)
|
|
for _ = 1, 100 do w:tick(nil) end
|
|
check(w:done(), "a message without a trailing \\p closes on its own")
|
|
end
|
|
|
|
print("[test] 12. a member missing at the play-again barrier stops the game (berry_crush.c:2286)")
|
|
do
|
|
local players = { { seat = 0, name = "RED" }, { seat = 1, name = "BLUE" }, { seat = 2, name = "LEAF" } }
|
|
local function yes(round) return { r = round, b = 0, a = Sim.ACK.ANSWER, n = 0, h = 0, y = Sim.ANSWER.YES } end
|
|
local sim = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 0, leader = true, players = players, art = art })
|
|
sim.A.ph = Sim.PH.SAVE
|
|
sim:leaderStep({ yes(0), yes(0), nil }, { true, true, nil })
|
|
eq(sim.A.ph, Sim.PH.STOP, "LEAF left before answering: STOP, not a new round")
|
|
eq(sim.A.round, 0, "no new round")
|
|
local full = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 0, leader = true, players = players, art = art })
|
|
full.A.ph = Sim.PH.SAVE
|
|
full:leaderStep({ yes(0), yes(0), yes(0) }, { true, true, true })
|
|
eq(full.A.round, 1, "everyone present and YES starts round 2")
|
|
local mig = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 1, leader = false, players = players, art = art })
|
|
mig.A.ph = Sim.PH.SAVE
|
|
mig.A.ld = 1
|
|
mig:becomeLeader(nil)
|
|
mig.A.ph = Sim.PH.SAVE
|
|
mig:leaderStep({ nil, yes(0), yes(0) }, { nil, true, true })
|
|
eq(mig.A.ph, Sim.PH.STOP, "the old leader is gone after migration: STOP")
|
|
local results = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 0, leader = true, players = players, art = art })
|
|
results.A.ph = Sim.PH.RESULTS
|
|
local st = { r = 0, b = 0, a = Sim.ACK.STANDBY, n = 0, h = 0, y = 0 }
|
|
results:leaderStep({ st, st, nil }, { true, true, nil })
|
|
eq(results.A.ph, Sim.PH.SAVE, "earlier barriers still release without the missing seat")
|
|
end
|
|
|
|
print("[test] 13. a member who leaves mid-game is marked gone for every seat")
|
|
do
|
|
local players = { { seat = 0, name = "RED" }, { seat = 1, name = "BLUE" }, { seat = 2, name = "LEAF" } }
|
|
local lead = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 0, leader = true, players = players, art = art })
|
|
local member = Sim.new(moduleFor({ bag = Bag.new() }), { seat = 2, leader = false, players = players, art = art })
|
|
lead.A.ph = Sim.PH.PLAY
|
|
lead:leaderStep({ nil, nil, nil }, { true, true, true })
|
|
check(not lead.A.gone[2], "everyone present: nobody gone")
|
|
lead:leaderStep({ nil, nil, nil }, { true, nil, true })
|
|
check(lead.A.gone[2] == true, "BLUE left the room: gone on the leader")
|
|
check(not lead.A.gone[1] and not lead.A.gone[3], "the others stay")
|
|
local wired = wireCopy({ type = MG.MSG.STATE, f = 1, e = 1, s = lead:snapshot() })
|
|
member:applySnapshot(wired and wired.s)
|
|
check(member.A.gone[2] == true, "the snapshot carries BLUE's drop to LEAF")
|
|
lead:leaderStep({ nil, nil, nil }, { true, true, true })
|
|
check(lead.A.gone[2] == true, "a dropped seat does not come back")
|
|
end
|
|
|
|
if failed > 0 then
|
|
print(("[FAIL] %d check(s) failed"):format(failed))
|
|
os.exit(1)
|
|
end
|
|
print("[PASS] game3_mg_berry_crush_test")
|