Merge pull request #1601 from bryanthaboi/dev

bug fixes and uncles neighbor
This commit is contained in:
bryanthaboi
2026-08-20 10:48:57 -04:00
committed by GitHub
103 changed files with 21426 additions and 984 deletions
@@ -41,12 +41,21 @@ end
-- Mock isReady
RomImporter.isReady = function(v)
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
or v == "silver"
end
local ok = RomImporter.syncAndroidShortcuts("gold")
check(ok == true, "syncAndroidShortcuts returns true on Android")
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
and capturedShortcuts[4] == "yellow",
"the rest follow GameVersion.ORDER until the cap")
capturedShortcuts = nil
RomImporter.syncAndroidShortcuts("silver")
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
-- Test with subset of ready games (e.g. only Red and Gold)
RomImporter.isReady = function(v)
@@ -78,4 +87,4 @@ end
love.system.getLaunchGame = savedGetLaunchGame
love.system.updateShortcuts = nil
print("8/8 checks passed (android_shortcuts_payload_test)")
T.finish("android_shortcuts_payload_test")
+1
View File
@@ -23,6 +23,7 @@ T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
-- ------- 2. manifest: gen2compat is opt-in and defaults off
+45 -34
View File
@@ -39,45 +39,54 @@ end
local edited = 0
local hooks = { editTouchControls = function() edited = edited + 1 end }
local gold = LauncherSettings.open(hooks, "gold")
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
check(has(gold, "VIBRATION"), "and VIBRATION")
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
check(has(gold, "VOID FILL"), "and VOID FILL")
for _, version in ipairs({ "gold", "silver" }) do
local model = LauncherSettings.open(hooks, version)
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
check(has(model, "VIBRATION"), version .. " and VIBRATION")
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
check(has(model, "VOID FILL"), version .. " and VOID FILL")
local voidFill = findRow(gold, "VOID FILL")
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
voidFill.step(1)
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
eq(voidFill.value(), "WATER", "and the row reads WATER")
voidFill.step(-1)
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
local voidFill = findRow(model, "VOID FILL")
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
voidFill.step(1)
eq(model.opts.gold.voidFill, "water",
version .. " right stores water in the gold block")
eq(voidFill.value(), "WATER", version .. " and the row reads WATER")
voidFill.step(-1)
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
-- Every write has to land in the gold block: the flat keys beside it are
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
-- rows leave them exactly as they found them.
local flatPad = gold.opts.touchControls
local flatBuzz = gold.opts.haptics
-- Every write has to land in the gen2 block: the flat keys beside it are
-- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
-- rows leave them exactly as they found them.
local flatPad = model.opts.touchControls
local flatBuzz = model.opts.haptics
local pad = findRow(gold, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
check(type(gold.opts.gold) == "table", "into the gold block")
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
local pad = findRow(model, "TOUCH PAD")
local before = pad.value()
pad.step(1)
check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
check(type(model.opts.gold) == "table", version .. " into the gold block")
eq(model.opts.gold.touchControls.enabled, false,
version .. " which now carries enabled")
eq(model.opts.touchControls, flatPad,
version .. " leaving the flat Gen 1 key alone")
eq(model.opts.silver, nil,
version .. " and inventing no second Gen 2 block beside it")
local buzz = findRow(gold, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
"VIBRATION stores a level the shared module knows")
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
local buzz = findRow(model, "VIBRATION")
local buzzBefore = buzz.value()
buzz.step(1)
check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
eq(model.opts.gold.haptics,
TouchControls.normalizeHaptics(model.opts.gold.haptics),
version .. " VIBRATION stores a level the shared module knows")
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
findRow(gold, "TOUCH CONTROLS").action()
eq(edited, 1, "the editor row reaches the host hook")
edited = 0
findRow(model, "TOUCH CONTROLS").action()
eq(edited, 1, version .. " the editor row reaches the host hook")
end
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
-- the flat table.
@@ -94,6 +103,8 @@ eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
-- rather than dead, on both sides.
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
"no hook, no editor row on Gold")
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
"nor on Silver")
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
"nor on Red")
-- The Edit row hands the screen to the host, and the host has to know WHICH
+41 -38
View File
@@ -155,28 +155,34 @@ modImp.mods = mods
modImp._ensureMods = function() return mods end
LauncherView.draw(modImp)
LauncherView.draw(modImp)
check((modImp._modScrollMax or 0) > 0,
"60 mods overflow the list viewport inside the panel")
local list = modImp._modListRect
check(list.x + list.w
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
"the rows stop short of the region's scrollbar gutter")
pointer(list.x + 10, list.y + 10)
local reg = modImp._tabRegionRect
check((modImp._tabScrollMax.mods or 0) > reg.h,
"60 installed mods are ONE continuous list: the region's travel spans "
.. "the whole list, not one page of it")
eq(modImp._pages.mods, nil, "and no page state is ever minted for it")
pointer(reg.x + 10, reg.y + 40)
modImp._wheelY = -1
LauncherView.draw(modImp)
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
eq(modImp._pageScroll, 0, "and not the page behind that")
check((modImp._tabScroll.mods or 0) > 0,
"a notch over the region scrolls the list like any other tab")
eq(modImp._pageScroll, 0, "without reaching the page behind it")
modImp._tabScroll.mods = modImp._tabScrollMax.mods
LauncherView.draw(modImp)
pointer(reg.x + 10, reg.y + reg.h * 0.5)
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp._pages.mods, nil,
"bottoming the list out never auto-advances any pager")
eq(modImp._tabScroll.mods, modImp._tabScrollMax.mods,
"the list just rests at its end")
modImp._modActions = "mod1"
local shielded = modImp.modScroll
local shieldedPage = modImp._pageScroll
pointer(list.x + 10, list.y + 10)
local shieldedAt = modImp._tabScroll.mods
modImp._wheelY = -1
LauncherView.draw(modImp)
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
eq(modImp._tabScroll.mods, shieldedAt,
"a shielded mod list ignores the notch under the scrim")
modImp._modActions = nil
modImp._wheelY = 0
LauncherView.draw(modImp)
@@ -225,32 +231,27 @@ dragMods.mods = mods
dragMods._ensureMods = function() return mods end
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
local dlist = dragMods._modListRect
local dListMax = dragMods._modScrollMax
local dreg = dragMods._tabRegionRect
local dRegionMax = dragMods._tabScrollMax.mods
check(dListMax > 0 and dRegionMax > 0,
"the mods tab has both an inner list and a region to scroll")
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
eq(dragMods.modScroll, math.min(60, dListMax),
"the first pixels of the drag move the list")
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
eq(dragMods._tabScroll.mods, dRegionMax,
"then the same gesture walks the region to its bottom")
check(dRegionMax > 0, "the mods region scrolls its overscan like any tab")
LauncherView.touchpressed(dragMods, 7, dreg.x + 20, dreg.y + 30)
LauncherView.touchmoved(dragMods, 7, dreg.x + 20, dreg.y + 30 - 60)
eq(dragMods._tabScroll.mods, math.min(60, dRegionMax),
"a drag moves the region by the finger's travel")
LauncherView.touchmoved(dragMods, 7, dreg.x + 20,
dreg.y + 30 - 60 - dRegionMax * 2)
eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
for i = 2, 12 do
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
end
dragMods._ensureSkins = function() return dragMods._skins end
dragMods.modScroll = 0
local heldModScroll = dragMods.modScroll
local overList = dlist.y + 30
local heldModsPage = dragMods._pages.mods or 1
local heldModsAt = dragMods._tabScroll.mods
local overList = dreg.y + 60
dragMods:_switchTab("skins")
LauncherView.draw(dragMods)
LauncherView.draw(dragMods)
@@ -260,8 +261,10 @@ LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
check((dragMods._tabScroll.skins or 0) > 0,
"a drag on the skins tab scrolls the skins tab")
eq(dragMods.modScroll, heldModScroll,
"and leaves the mod list where the player parked it")
eq(dragMods._pages.mods or 1, heldModsPage,
"and leaves the mod list on the page the player parked it")
eq(dragMods._tabScroll.mods, heldModsAt,
"with its region offset held for the return trip")
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
love.graphics.polygon = love.graphics.polygon or function() end
@@ -308,8 +311,8 @@ local view = read("src/import/LauncherView.lua")
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
"the panel dispatch opens a scroll region")
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
check(view:find("modListWantsWheel", 1, true) ~= nil,
"the nested mod list is asked before the region takes a notch")
check(view:find("modListWantsWheel", 1, true) == nil,
"no nested list steals the wheel from the region any more")
check(view:find("start.region", 1, true) ~= nil,
"a touch drag that began in the region scrolls the region")
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
+12 -8
View File
@@ -32,11 +32,13 @@ do
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
"and each of them names itself")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
@@ -56,7 +58,7 @@ end
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
@@ -67,14 +69,14 @@ end
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold", "games can name Gen 2 alone")
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold", "or both generations")
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
"an old gen2compat beside a new games list still adds its game")
end
@@ -224,10 +226,12 @@ do
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, red = "nope" } },
gold = { a = true }, silver = { a = true }, crystal = { a = true },
red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
local function clearSavesInbox()
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local dir = "imports/saves/" .. ver
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
love.filesystem.remove(dir .. "/" .. name)
@@ -137,6 +137,8 @@ check(createdDirs["imports/saves/yellow"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
check(createdDirs["imports/saves/gold"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
check(createdDirs["imports/saves/silver"] == true,
"RES-01: ensureSavesInboxDir creates imports/saves/silver/")
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
ri = freshImporter()
+113
View File
@@ -0,0 +1,113 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local ScreenPosition = require("src.core.ScreenPosition")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = require("src.render.Renderer")
local Chrome = require("src.ui.gen2.Chrome")
local Zoom = require("src.render.Zoom")
eq(ScreenPosition.normalize(nil), "center", "no setting is CENTER")
eq(ScreenPosition.normalize("junk"), "center", "garbage degrades to CENTER")
eq(ScreenPosition.normalize("top"), "top", "top passes through")
eq(ScreenPosition.label("upper"), "UPPER", "upper reads UPPER")
local seen, v = {}, "center"
for _ = 1, 3 do
seen[#seen + 1] = ScreenPosition.label(v)
v = ScreenPosition.cycle(v, 1)
end
eq(table.concat(seen, ","), "CENTER,UPPER,TOP", "the row cycles CENTER,UPPER,TOP")
eq(ScreenPosition.cycle("top", 1), "center", "and wraps back to CENTER")
eq(ScreenPosition.cycle("center", -1), "top", "stepping back lands on TOP")
ScreenPosition.setMode("center")
eq(ScreenPosition.lift(640, 288), 0, "CENTER never lifts")
ScreenPosition.setMode("top")
eq(ScreenPosition.lift(640, 288), 176, "TOP lifts the centered origin to 0")
eq(ScreenPosition.lift(288, 288), 0, "no slack, no lift")
eq(ScreenPosition.lift(144, 288), 0, "negative slack, no lift")
eq(ScreenPosition.lift(640, 288, 40), 136, "TOP stops at the safe-area inset")
eq(ScreenPosition.lift(640, 288, 999), 0,
"a safe inset past center degrades to centered")
ScreenPosition.setMode("upper")
eq(ScreenPosition.lift(640, 288), 88, "UPPER lands halfway between")
eq(ScreenPosition.lift(640, 288, 40), 88, "a small inset leaves UPPER alone")
eq(ScreenPosition.lift(640, 288, 120), 56, "a large inset pushes UPPER down")
ScreenPosition.applyOptions({ screenPos = "top" })
eq(ScreenPosition.mode, "top", "applyOptions takes the stored key")
ScreenPosition.applyOptions(nil)
eq(ScreenPosition.mode, "center", "applyOptions without options is CENTER")
local function setWindow(w, h)
love.graphics.getDimensions = function() return w, h end
love.graphics.getPixelDimensions = function() return w, h end
end
setWindow(360, 640)
TouchSkin.setActive(nil)
Renderer:init()
Zoom.offset = 0
ScreenPosition.setMode("center")
local r = Renderer:frameRects()
eq(r.Sp, 2, "360x640 fits two whole GB pixels")
eq(r.lift, 0, "CENTER: no lift")
eq(r.oy, 176, "CENTER: the letterbox is centered")
local _, vhCenter = Renderer:worldViewSize()
ScreenPosition.setMode("top")
r = Renderer:frameRects()
eq(r.lift, 176, "TOP: the full centered slack lifts away")
eq(r.oy, 0, "TOP: the letterbox sits at the top edge")
eq(r.uoy, 0, "TOP: the UI letterbox follows")
eq(r.ox, math.floor((360 - 320) / 2), "TOP: horizontal centering is untouched")
local _, vhTop = Renderer:worldViewSize()
eq(vhTop, vhCenter + 2 * math.ceil(176 / 2),
"TOP: the world canvas grows to keep the bottom covered")
ScreenPosition.setMode("upper")
r = Renderer:frameRects()
eq(r.oy, 88, "UPPER: the letterbox centers in the upper half")
ScreenPosition.setMode("top")
local ox, oy = Chrome.fitOrigin(360, 640)
eq(oy, 0, "TOP: Gold's letterbox sits at the top edge")
eq(ox, math.floor((360 - 320) / 2), "TOP: Gold's horizontal centering is untouched")
ScreenPosition.setMode("center")
local _, cy = Chrome.fitOrigin(360, 640)
eq(cy, 176, "CENTER: Gold's letterbox is centered")
ScreenPosition.setMode("top")
local skin = assert(TouchSkin.parse([[
overlays = 1
overlay0_name = "bezel"
overlay0_full_screen = true
overlay0_normalized = true
overlay0_viewport = "0.1,0.1,0.8,0.4"
overlay0_descs = 1
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
]]))
TouchSkin.setActive(skin)
TouchSkin.setOverlayLive(false)
r = Renderer:frameRects()
eq(r.cut, true, "the skin viewport cuts the playfield")
eq(r.lift, 0, "a skin viewport disables the lift")
eq(select(1, ScreenPosition.skinActive(360, 640)), true,
"skinActive sees the viewport")
local _, sy = Chrome.fitOrigin(360, 640)
local _, cy2 = (function()
ScreenPosition.setMode("center")
return Chrome.fitOrigin(360, 640)
end)()
eq(sy, cy2, "with a skin the Gold origin ignores the mode")
TouchSkin.setActive(nil)
ScreenPosition.setMode("center")
T.finish("screen_position")
+4 -2
View File
@@ -81,7 +81,8 @@ local function importer(ready)
}, RomImporter)
end
local allReady = importer({ red = true, blue = true, yellow = true, gold = true })
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
allReady:_queueBaseRomScan()
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
eq(listings, 0, "ready launcher does not enumerate baseroms")
@@ -123,7 +124,8 @@ eq(picks, 0, "missing detected ROM does not open the picker unexpectedly")
missing:choose("red")
eq(picks, 1, "the next import attempt falls back to the native picker")
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true })
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
silver = true })
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
rescanned:reimport("red")
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
+112
View File
@@ -1233,6 +1233,118 @@ check("a full wallet fills it", Chrome.money(999999), "\xc2\xa5999999")
check("the coin field keeps its leading zeroes", Chrome.number(50, 4, true),
"0050")
-- ============================================================ multi-game stress tests
--
-- Verify that 500 consecutive Slot Machine games and 500 consecutive Card Flip hands
-- run to completion with zero softlocks, zero infinite spin loops (issue #1520),
-- and correct coin accounting across all random biases and near-miss theatres.
local mockSave = {
player = { name = "GOLD", coins = 5000 },
}
local mockInput = {
pressed = {},
wasPressed = function(self, key)
local v = self.pressed[key]
self.pressed[key] = false
return v or false
end,
press = function(self, key)
self.pressed[key] = true
end,
}
local mockSlotGame = {
save = mockSave,
input = mockInput,
data = {},
}
-- Test Slot Machine 500-spin continuous loop
local sm = SlotMachine.new(mockSlotGame, { lucky = true })
local completedSpins = 0
for spin = 1, 500 do
mockSave.player.coins = 5000 -- ensure test player always has coins
if sm.phase == "quit" or sm.phase == "ranOut" then
sm = SlotMachine.new(mockSlotGame, { lucky = true })
end
-- Enter bet phase
check("slot machine in bet phase at spin start", sm.phase, "bet")
mockInput:press("a") -- bet 3 coins
sm:update(1/60)
check("slot machine entered spinning phase", sm.phase, "spinning")
local frameCount = 0
local maxFrames = 5000 -- safety bound per spin
while sm.phase == "spinning" and frameCount < maxFrames do
frameCount = frameCount + 1
if frameCount % 30 == 0 then
mockInput:press("a") -- press stop button
end
sm:update(1/60)
end
check(("spin %d must not hang in spinning"):format(spin), frameCount < maxFrames, true)
-- Resolve flash and payout phases
while (sm.phase == "flash" or sm.phase == "payoutText") and frameCount < maxFrames do
frameCount = frameCount + 1
if sm.phase == "payoutText" and sm.matched == SlotMachine.NO_MATCH then
mockInput:press("a")
end
sm:update(1/60)
end
check(("spin %d reached again phase"):format(spin), sm.phase == "again" or sm.phase == "bet", true)
if sm.phase == "again" then
mockInput:press("a") -- choose YES to play again
sm:update(1/60)
completedSpins = completedSpins + 1
elseif sm.phase == "bet" then
completedSpins = completedSpins + 1
end
end
check("all 500 slot machine spins completed without softlock", completedSpins >= 490, true)
-- Test Card Flip 500-hand continuous loop
local cf = CardFlip.new(mockSlotGame)
local completedHands = 0
for hand = 1, 500 do
mockSave.player.coins = 5000
if cf.phase == "quit" then
cf = CardFlip.new(mockSlotGame)
end
local frameCount = 0
local maxFrames = 500
while cf.phase ~= "again" and cf.phase ~= "quit" and frameCount < maxFrames do
frameCount = frameCount + 1
if cf.phase == "ask" or cf.phase == "message" or cf.phase == "result"
or cf.phase == "choose" or cf.phase == "bet" then
mockInput:press("a")
end
cf:update(1/60)
end
check(("hand %d completed in bounds"):format(hand), frameCount < maxFrames, true)
if cf.phase == "again" then
mockInput:press("a") -- play again
cf:update(1/60)
completedHands = completedHands + 1
end
end
check("all 500 card flip hands completed cleanly", completedHands >= 490, true)
print(("gen2 game corner: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an exit
-- here would take the whole tier down and silently skip every suite after it.
+6
View File
@@ -31,10 +31,14 @@ require("src.core.Logger").warn = function() end
local Core = require("src.core.gen2.HallOfFame")
local Credits = require("src.ui.gen2.Credits")
local GameVersion = require("src.core.GameVersion")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local Save = require("src.core.gen2.Save")
local Screens = require("src.ui.Screens")
local priorVersion = GameVersion.get()
GameVersion.set("gold")
-- ---- fixtures -------------------------------------------------------------
local POKEMON = {
@@ -711,4 +715,6 @@ else
eq(creditsData.palettes[1][1][1], 255, "and set 1 colour 0 is white")
end
GameVersion.set(priorVersion)
S.finish()
+2
View File
@@ -76,6 +76,8 @@ local function makeWorld()
local game = {
data = { audio = {}, screens = registry },
save = {
version = "gold",
generation = 2,
player = { name = "GOLD" },
party = { { species = "TYPHLOSION", level = 50, hp = 120,
otId = 33333 } },
+55 -1
View File
@@ -28,6 +28,9 @@ love.filesystem = {
local GameVersion = require("src.core.GameVersion")
local Save = require("src.core.gen2.Save")
local priorVersion = GameVersion.get()
GameVersion.set("gold")
local failures, checks = 0, 0
local function check(name, got, want)
checks = checks + 1
@@ -48,6 +51,11 @@ check("main file", main, "save_gold.lua")
check("backup file", backup, "save_gold.lua.bak")
check("staged file", tmp, "save_gold.lua.tmp")
check("gold suffix", GameVersion.saveSuffix("gold"), "_gold")
local sMain, sBackup, sTmp = Save.filenames("silver")
check("silver main file", sMain, "save_silver.lua")
check("silver backup file", sBackup, "save_silver.lua.bak")
check("silver staged file", sTmp, "save_silver.lua.tmp")
check("silver suffix", GameVersion.saveSuffix("silver"), "_silver")
-- Red keeps its historical un-suffixed name, so the two can never collide.
check("red is unsuffixed", GameVersion.saveSuffix("red"), "")
@@ -104,7 +112,13 @@ check("defaults are copied", Save.defaultOptions().textSpeed, "MID")
-- A sparse or hand-edited save must come back indexable rather than crashing
-- the first screen that reads it.
local sparse = Save.normalize({ player = {} })
check("normalized version", sparse.version, "gold")
check("normalized version", sparse.version, GameVersion.get())
check("a Gen 2 version already on the table is kept",
Save.normalize({ version = "silver", player = {} }).version, "silver")
check("and a Gen 1 one is replaced by the running edition",
Save.normalize({ version = "red", player = {} }).version, "gold")
check("as is a version this engine does not have",
Save.normalize({ version = "crystal", player = {} }).version, "gold")
check("party exists", type(sparse.party), "table")
check("inventory exists", type(sparse.inventory), "table")
check("pokedex seen exists", type(sparse.pokedex.seen), "table")
@@ -478,6 +492,46 @@ do
files = {}
end
-- ------- the same save, run as Silver
--
-- The blank name is the edition's own first PlayerNameArray row
-- (data/player_names.asm:12-23).
do
GameVersion.set("silver")
files = {}
local born = Save.newGame({})
check("a silver boot stamps silver", born.version, "silver")
check("still generation 2", born.generation, 2)
check("with the edition's own preset name", born.player.name, "SILVER")
check("gold's preset is unchanged", Save.defaultPlayerName("gold"), "GOLD")
check("and silver's is its own", Save.defaultPlayerName("silver"), "SILVER")
check("the running edition answers with no argument",
Save.defaultPlayerName(), "SILVER")
check("a version-less save takes the running edition",
Save.normalize({ player = {} }).version, "silver")
check("and a gold save keeps gold on a silver boot",
Save.normalize({ version = "gold", player = {} }).version, "gold")
check("no silver save yet", Save.exists("silver"), false)
check("silver save wrote", Save.save(born), true)
check("silver save exists now", Save.exists("silver"), true)
check("gold is untouched by it", Save.exists("gold"), false)
local main = Save.filenames("silver")
check("the silver file is silver's own", main, "saves/silver/slot1.lua")
check("and it is the file on disk", files[main] ~= nil, true)
local back = Save.load("silver")
check("silver round-trips", back and back.player.name, "SILVER")
check("keeping its stamp", back.version, "silver")
files = {}
GameVersion.set("gold")
end
GameVersion.set(priorVersion)
print(("gen2 save: %d checks, %d failures"):format(checks, failures))
-- Raise rather than os.exit: tests/run_tests.lua dofiles this file, so an
-- exit here takes the whole tier down with it and silently skips every
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""ROM-free regression tests for the manifest generator's version pin and
its documented, deliberate overrides. No pokered checkout or ROM needed."""
from pathlib import Path
from unittest import TestCase, main, mock
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import make_rom_manifest # noqa: E402
class CheckPokeredRevisionTest(TestCase):
def test_matching_revision_is_silent(self):
with mock.patch.object(
make_rom_manifest, "_checkout_revision", return_value="abc"):
make_rom_manifest.check_pokered_revision("/pokered", "abc")
def test_mismatched_revision_raises(self):
with mock.patch.object(
make_rom_manifest, "_checkout_revision", return_value="abc"):
with self.assertRaises(SystemExit):
make_rom_manifest.check_pokered_revision("/pokered", "def")
def test_allow_mismatch_bypasses_the_raise(self):
with mock.patch.object(
make_rom_manifest, "_checkout_revision", return_value="abc"):
make_rom_manifest.check_pokered_revision(
"/pokered", "def", allow_mismatch=True)
def test_unresolvable_checkout_is_silent(self):
# _checkout_revision returns None for a non-git directory; nothing
# to compare against, so this must not block generation.
with mock.patch.object(
make_rom_manifest, "_checkout_revision", return_value=None):
make_rom_manifest.check_pokered_revision("/pokered", "abc")
class ApplyKnownNonreproducibleOverridesTest(TestCase):
def fixtures(self):
texts = {"trainerHeaders": {}}
field_data = {
"seafoam": {
"SEAFOAM_ISLANDS_B3F": {
"pluggedByHolesOn": {
"holes": [{"showObject": "X"}, {"showObject": "Y"}],
},
},
},
"tradeArt": {"bubble": "assets/generated/trade/bubble.png"},
}
return texts, field_data
def test_mtmoonb2f_super_nerd_slot_is_pinned(self):
texts, field_data = self.fixtures()
make_rom_manifest.apply_known_nonreproducible_overrides(
texts, field_data)
self.assertEqual(texts["trainerHeaders"]["MtMoonB2F"][1], {
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
"won": "_MtMoonB2FSuperNerdOkIllShareText",
})
def test_mtmoonb2f_survives_a_populated_map_entry(self):
# A fresh extraction already fills in the map's other slots (2-5);
# the override must add slot 1 alongside them, not replace them.
texts, field_data = self.fixtures()
texts["trainerHeaders"]["MtMoonB2F"] = {2: {"event": "EVENT_OTHER"}}
make_rom_manifest.apply_known_nonreproducible_overrides(
texts, field_data)
self.assertIn(1, texts["trainerHeaders"]["MtMoonB2F"])
self.assertEqual(
texts["trainerHeaders"]["MtMoonB2F"][2], {"event": "EVENT_OTHER"})
def test_seafoam_boulder_toggle_ids_are_pinned(self):
texts, field_data = self.fixtures()
make_rom_manifest.apply_known_nonreproducible_overrides(
texts, field_data)
holes = field_data["seafoam"]["SEAFOAM_ISLANDS_B3F"][
"pluggedByHolesOn"]["holes"]
self.assertEqual(holes[0]["showObject"],
"TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5")
self.assertEqual(holes[1]["showObject"],
"TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6")
def test_trade_art_is_dropped(self):
texts, field_data = self.fixtures()
make_rom_manifest.apply_known_nonreproducible_overrides(
texts, field_data)
self.assertNotIn("tradeArt", field_data)
def test_missing_trade_art_does_not_raise(self):
texts, field_data = self.fixtures()
del field_data["tradeArt"]
make_rom_manifest.apply_known_nonreproducible_overrides(
texts, field_data)
if __name__ == "__main__":
main()
+35 -21
View File
@@ -71,13 +71,17 @@ local data = {
maps = {},
}
local function newState()
local function newState(version)
version = version or "gold"
local prior = GameVersion.get()
GameVersion.set(version)
local S = State.new()
S.data = data
S.cat = Catalog.build(data)
S.save = Save2.newGame()
S.version = "gold"
S.version = version
Gen.ensureBoxes(S.save)
GameVersion.set(prior)
return S
end
@@ -85,10 +89,19 @@ do
GameVersion.set("gold")
eq(Gen.of({ generation = 2 }), 2, "Gen.of generation field")
eq(Gen.of({ version = "gold" }), 2, "Gen.of version gold")
eq(Gen.of({ version = "silver" }), 2, "Gen.of version silver")
eq(Gen.of(SaveData.newGame()), 1, "Gen.of gen1 newGame")
eq(Gen.of(Save2.newGame()), 2, "Gen.of gold newGame")
end
do
GameVersion.set("silver")
eq(Save2.newGame().version, "silver", "a silver boot stamps silver")
eq(Save2.newGame().player.name, "SILVER", "with silver's preset name")
eq(Gen.of(Save2.newGame()), 2, "Gen.of silver newGame")
GameVersion.set("gold")
end
do
local S = newState()
check(Ops.speciesUsable(S, "CYNDAQUIL"), "spa/spd species is usable")
@@ -103,36 +116,37 @@ do
check(not Ops.speciesUsable(S1, "BROKEN"), "partial record is not usable")
end
do
local S = newState()
for _, version in ipairs({ "gold", "silver" }) do
local S = newState(version)
Ops.partyAdd(S)
eq(#S.save.party, 1, "partyAdd on gold")
eq(#S.save.party, 1, "partyAdd on " .. version)
local mon = S.save.party[1]
eq(mon.species, "CYNDAQUIL", "first catalog species")
check(mon.experience ~= nil, "gold mon has experience")
check(mon.exp == nil or mon.experience ~= nil, "does not rely on gen1 exp")
eq(mon.species, "CYNDAQUIL", version .. " first catalog species")
check(mon.experience ~= nil, version .. " mon has experience")
check(mon.exp == nil or mon.experience ~= nil,
version .. " does not rely on gen1 exp")
check(mon.stats.specialAttack and mon.stats.specialDefense,
"gold stats have spa/spd")
check(mon.happiness ~= nil, "gold mon has happiness")
eq(mon.ot, S.save.player.name, "stampOT copies player name")
version .. " stats have spa/spd")
check(mon.happiness ~= nil, version .. " mon has happiness")
eq(mon.ot, S.save.player.name, version .. " stampOT copies player name")
Ops.setLevel(S, mon, 20)
eq(mon.level, 20, "setLevel 20")
check(mon.experience > 0, "experience resynced")
eq(mon.level, 20, version .. " setLevel 20")
check(mon.experience > 0, version .. " experience resynced")
Ops.setHappiness(S, mon, 200)
eq(mon.happiness, 200, "happiness 200")
eq(mon.happiness, 200, version .. " happiness 200")
Ops.setPokerus(S, mon, 15)
eq(mon.pokerus, 15, "pokerus byte")
eq(mon.pokerus, 15, version .. " pokerus byte")
Ops.setHeldItem(S, mon, "POTION")
eq(mon.item, "POTION", "held item")
eq(mon.item, "POTION", version .. " held item")
eq(mon.name, "CYNDAQUIL", "new mon copies species display name")
eq(mon.name, "CYNDAQUIL", version .. " new mon copies species display name")
Ops.setSpecies(S, mon, "TOTODILE")
eq(mon.species, "TOTODILE", "setSpecies id")
eq(mon.name, "TOTODILE", "setSpecies rewrites the Gold display name")
check(mon.nickname == nil, "setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", "setSpecies rewrites copied types")
eq(mon.species, "TOTODILE", version .. " setSpecies id")
eq(mon.name, "TOTODILE", version .. " setSpecies rewrites the display name")
check(mon.nickname == nil, version .. " setSpecies does not invent a nickname")
eq(mon.types[1], "WATER", version .. " setSpecies rewrites copied types")
end
do
+10
View File
@@ -149,12 +149,22 @@ local ok, err = pcall(function()
local Ops = require("Ops")
check(not ModTargets.supports({}, "gold"),
"legacy gen1-only fixture does not support gold")
check(not ModTargets.supports({}, "silver"),
"nor silver")
check(not ModTargets.supports({ games = { "red" } }, "gold"),
"explicit gen1 games list does not support gold")
check(not ModTargets.supports({ games = { "red" } }, "silver"),
"nor silver")
check(ModTargets.supports({ games = { "gold" } }, "gold"),
"gold-targeted manifest supports gold")
check(not ModTargets.supports({ games = { "gold" } }, "silver"),
"and names one edition, not the generation")
check(ModTargets.supports({ games = { "gold", "silver" } }, "silver"),
"a whole-Gen-2 games list supports silver")
check(ModTargets.supports({ gen2compat = true }, "gold"),
"gen2compat legacy still supports gold")
check(ModTargets.supports({ gen2compat = true }, "silver"),
"and silver with it")
local goldS = {
data = {
+131 -4
View File
@@ -40,14 +40,16 @@ local mockGame = {
print("Running SurfingMinigame unit tests...")
-- Test 1: Initialization
-- Test 1: Initialization & Title Screen transition
local mg = SurfingMinigame.new(mockGame)
assert_eq(mg.routine, -1, "Initial routine must be ROUTINE_TITLE (-1)")
mg:startFromTitle()
assert_eq(mg.routine, 0, "Routine must advance to ROUTINE_START_GAME (0) after startFromTitle()")
assert_eq(mg.hp, 6000, "Initial HP must be 6000 (60.00s)")
assert_eq(mg.speed, 0.25, "Initial speed must be 0.25")
assert_eq(mg.distance, 0, "Initial distance must be 0")
assert_eq(mg.routine, 0, "Initial routine must be ROUTINE_START_GAME (0)")
assert_eq(mg.pikaState, 0, "Initial Pikachu state must be PIKA_STATE_RIDING (0)")
print("✓ Initial state test passed")
print("✓ Initial state & Title transition test passed")
-- Test 2: Start banner transition to RunGame
for _ = 1, 40 do
@@ -65,6 +67,8 @@ assert_eq(mg.hp, initialHp - 1, "HP should decrease by 1 each frame")
print("✓ Auto acceleration and HP countdown test passed")
-- Test 4: Landing Evaluation Matrix
local old_getWaveTile = mg.getWaveTileUnderPika
mg.getWaveTileUnderPika = function() return 0x01 end -- force open water
mg.frameSet = 5
assert_eq(mg:evaluateLanding(), "rough", "Angle 5 on open water should be rough landing")
mg.frameSet = 6
@@ -77,6 +81,7 @@ for f = 8, 14 do
mg.frameSet = f
assert_eq(mg:evaluateLanding(), "wipeout", "Upside-down frame " .. f .. " must be wipeout")
end
mg.getWaveTileUnderPika = old_getWaveTile
print("✓ Landing evaluation matrix test passed (including upside-down frames 8..14)")
-- Test 5: Stunt Scoring
@@ -145,6 +150,128 @@ end
assert_eq(mg.radness, 0, "Radness should be tallied down to 0")
assert_eq(mg.totalScore, 300, "Total score should be 300 (100 HP + 200 Radness)")
assert_eq(mg.routine, 10, "Routine should advance to ROUTINE_WAIT_LAST (10)")
print("✓ Results tally countdown test passed")
-- Test 8: Crossing finish line while jumping upside-down crashes into water and rights Pikachu before results
local mg8 = SurfingMinigame.new(mockGame, nil, true)
mg8.routine = 1 -- ROUTINE_RUN_GAME
mg8.distanceFixed = (24 * 128 - 2) * 256
mg8.speedFixed = 512
mg8.pikaState = 1 -- PIKA_STATE_JUMPING
mg8.frameSet = 11 -- Upside down
mg8.pikaY = 60
mg8.jumpDescending = true
mg8.jumpArcMagnitude = 4
mg8.radness = 150
local preScore = mg8.radness
-- Update to cross the finish line
mg8:update()
assert_eq(mg8.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2) upon crossing finish")
assert_eq(mg8.pikaState, 1, "Pikachu should remain mid-air immediately after crossing line")
-- Update until Pikachu lands in water
while mg8.pikaState == 1 do
mg8:update()
end
assert_eq(mg8.pikaState, 3, "Upside-down landing post-finish line must trigger PIKA_STATE_CRASHED (3)")
assert_eq(mg8.radness, preScore, "Radness score must NOT change after crossing finish line")
assert_eq(mg8.crashTimer, 96, "Crash timer must be initialized to 96 frames")
-- Update while crashed to verify recovery
while mg8.pikaState == 3 do
mg8:update()
end
assert_eq(mg8.pikaState, 0, "Pikachu must recover back to PIKA_STATE_RIDING (0) and right itself on the board")
assert_eq(mg8.frameSet, 4, "Pikachu frameSet must be reset to upright (4)")
-- Let coasting finish and verify transition to results
while mg8.routine == 2 do
mg8:update()
end
assert_eq(mg8.routine, 3, "Routine should advance to ROUTINE_SCROLL_RESULTS (3) only after Pikachu is upright")
print("✓ Mid-air upside-down finish line crossing crash & recovery test passed")
-- Test 9: Crossing finish line while upright jumping lands cleanly and proceeds
local mg9 = SurfingMinigame.new(mockGame, nil, true)
mg9.routine = 1
mg9.distanceFixed = (24 * 128 - 2) * 256
mg9.speedFixed = 512
mg9.pikaState = 1
mg9.frameSet = 4 -- Clean flat
mg9.pikaY = 60
mg9.jumpDescending = true
mg9.jumpArcMagnitude = 4
mg9.radness = 200
preScore = mg9.radness
mg9:update()
assert_eq(mg9.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
while mg9.pikaState == 1 do
mg9:update()
end
assert_eq(mg9.pikaState, 2, "Upright landing post-finish line must trigger PIKA_STATE_LANDING (2)")
assert_eq(mg9.radness, preScore, "Radness score must NOT change post-finish")
while mg9.pikaState == 2 do
mg9:update()
end
assert_eq(mg9.pikaState, 0, "Pikachu must return to PIKA_STATE_RIDING (0)")
print("✓ Mid-air upright finish line crossing test passed")
-- Test 10: Crossing finish line while already crashed recovers before results
local mg10 = SurfingMinigame.new(mockGame, nil, true)
mg10.routine = 1
mg10.distanceFixed = (24 * 128 - 2) * 256
mg10.speedFixed = 512
mg10.pikaState = 3 -- PIKA_STATE_CRASHED
mg10.crashTimer = 50
mg10:update()
assert_eq(mg10.routine, 2, "Routine should advance to ROUTINE_WAIT_RESULTS (2)")
assert_eq(mg10.pikaState, 3, "Pikachu should still be crashed")
while mg10.pikaState == 3 do
mg10:update()
end
assert_eq(mg10.pikaState, 0, "Pikachu must recover upright before proceeding to results")
print("✓ Pre-crashed finish line crossing recovery test passed")
-- Test 11: Decoupled timestep accumulator (60Hz and 144Hz framerate consistency)
local mg11_60 = SurfingMinigame.new(mockGame, nil, true)
mg11_60.routine = 1 -- ROUTINE_RUN_GAME
for _ = 1, 60 do
mg11_60:update(1 / 60)
end
assert(mg11_60.t == 59 or mg11_60.t == 60, "60Hz update over 1s must produce approx 60 ticks (got " .. mg11_60.t .. ")")
local mg11_144 = SurfingMinigame.new(mockGame, nil, true)
mg11_144.routine = 1 -- ROUTINE_RUN_GAME
for _ = 1, 144 do
mg11_144:update(1 / 144)
end
assert(mg11_144.t == 59 or mg11_144.t == 60, "144Hz update over 1s must produce approx 60 ticks (got " .. mg11_144.t .. ")")
print("✓ Decoupled 59.7275Hz timestep accumulator test passed")
-- Test 12: Landing continuity on slopes (no position jumps while landing)
local mg12 = SurfingMinigame.new(mockGame, nil, true)
mg12.routine = 1
mg12.pikaState = 2 -- PIKA_STATE_LANDING
mg12.landingTimer = 20
mg12.speedFixed = 256
-- Place on a rising wave pattern
mg12.cols[5] = { pat = SurfingMinigame.WAVE_PATTERNS[0x06], hl = 110, hr = 100 }
mg12.distanceFixed = (5 * 16 - 80) * 256
local startY = mg12.pikaY
mg12:update()
assert(mg12.pikaY ~= startY, "pikaY must continuously follow wave surface height while in PIKA_STATE_LANDING")
print("✓ Landing slope height tracking continuity test passed")
-- Test 13: Fixed speed enforcement (minigames must always run at 1X speed)
assert(mg12.isFixedSpeed == true, "SurfingMinigame must have isFixedSpeed flag enabled")
assert(mg12.isMinigame == true, "SurfingMinigame must have isMinigame flag enabled")
local mockStack = { states = { mg12 } }
local Game = require("src.core.Game")
assert(Game.isFixedSpeedInStack(mockStack) == true, "Game.isFixedSpeedInStack must return true for SurfingMinigame")
print("✓ Minigame fixed speed enforcement test passed")
print("All SurfingMinigame unit tests passed successfully!")