Files
gen1recomp/tests/game3_mg_berry_crush_test.lua
2026-09-24 05:17:32 -04:00

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")