mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
Merge pull request #1601 from bryanthaboi/dev
bug fixes and uncles neighbor
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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!")
|
||||
|
||||
Reference in New Issue
Block a user