Spider Car Unleashed

CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
This commit is contained in:
bryanthaboi
2026-08-24 07:52:05 -04:00
parent f895293217
commit 1905261c5b
180 changed files with 19567 additions and 1556 deletions
@@ -0,0 +1,244 @@
-- The BICYCLE is used straight off the item list, with no USE/TOSS box
-- (#1705).
--
-- StartMenu_Item's .choseItem reads wCurItem and, before it ever loads
-- USE_TOSS_MENU_TEMPLATE into wTextBoxID, does `cp BICYCLE / jp z,
-- .useOrTossItem` (engine/menus/start_sub_menus.asm:340-342). So the bike
-- mounts, dismounts or refuses on one A press, and -- having no TOSS row to
-- reach -- can never be thrown away from the bag at all. Every other item,
-- key items included, still gets the option box.
--
-- The port pushed the USE/TOSS Menu for every field item, so the bike took
-- two presses and offered a TOSS the cart does not.
-- luajit tests/engine/bag_bicycle_no_options_bug1705.lua
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")
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
local music = {}
package.loaded["src.core.Music"] = {
playMap = function(_, mapId, biking) music[#music + 1] = { mapId, biking } end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function() return nil end,
}
package.loaded["src.ui.BagMenu"] = nil
local BagMenu = require("src.ui.BagMenu")
local Menu = require("src.ui.Menu")
require("src.ui.Screens").invalidate()
local Fixtures = require("tests.modkit.fixtures")
local Bag = require("src.inventory.Bag")
local Data = Fixtures.fresh()
-- the fixture item table carries neither, and BagMenu reads only
-- name/keyItem/machine off a def; the branches under test key on the id
Data.items.BICYCLE = { id = "BICYCLE", index = 6, name = "BICYCLE", price = 0,
keyItem = true }
Data.items.TOWN_MAP = { id = "TOWN_MAP", index = 5, name = "TOWN MAP",
price = 0, keyItem = true }
Data.items.FIX_POTION = Data.items.FIX_POTION
or { id = "FIX_POTION", index = 20, name = "FIX POTION", price = 300 }
local BIKE_MAP = { id = "FIX_TOWN", def = { tileset = "OVERWORLD" } }
local function freshGame(extras)
local game = {
data = Data,
save = {
party = {},
player = { name = "RED", id = 1 },
inventory = {},
options = {},
flags = {},
money = 0,
},
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = { pressed = nil }
function game.input:wasPressed(b) return self.pressed == b end
function game.input:isDown() return false end
-- IsBikeRidingAllowed reads the tileset off the loaded map, and the
-- mount/dismount lines read the player's name off the save
game.overworld = { map = BIKE_MAP, player = { surfing = false } }
Bag.add(game.save, "FIX_POTION", 3)
Bag.add(game.save, "BICYCLE", 1)
Bag.add(game.save, "TOWN_MAP", 1)
for k, v in pairs(extras or {}) do game.save[k] = v end
return game
end
local function rowFor(list, id)
for i, r in ipairs(list.items) do
if r.value == id then return i end
end
return nil
end
local function isMenu(s) return getmetatable(s) == Menu end
local function isBox(s) return type(s) == "table" and s.textBox == true end
local function inStack(game, pred)
for _, s in ipairs(game.stack.states) do
if pred(s) then return true end
end
return false
end
-- Open the bag, put the cursor on `id` and press A once. Returns the list
-- and every state the press left on the stack above it, so a USE/TOSS box
-- that appears and is then replaced is still caught.
local function chooseOnce(game, id)
local list = BagMenu.new(game, {})
game.stack:push(list)
local row = rowFor(list, id)
if not row then return nil, nil, "no " .. id .. " row in the bag" end
list.index = row
local seen = {}
local realPush = game.stack.push
game.stack.push = function(self, s)
seen[#seen + 1] = s
return realPush(self, s)
end
game.input.pressed = "a"
list:update(1 / 60)
game.input.pressed = nil
game.stack.push = realPush
return list, seen
end
local function sawMenu(seen)
for _, s in ipairs(seen or {}) do
if isMenu(s) then return true end
end
return false
end
local function boxText(game)
local top = game.stack:top()
return isBox(top) and top.text or nil
end
-- on foot, somewhere cycling is allowed: one A press mounts
do
music = {}
local game = freshGame()
local list, seen, why = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the BICYCLE row: " .. tostring(why)) then
check(not sawMenu(seen),
"no USE/TOSS Menu was pushed, not even for a frame (:340-342)")
eq(game.save.onBike, true, "the single press mounted the bike")
local said = boxText(game)
if check(said ~= nil, "and printed a line") then
check(said:find("got on", 1, true) ~= nil,
"which is the mount text: " .. tostring(said))
end
eq(game.save.inventory.BICYCLE, 1, "the bike is still in the bag")
eq(#music, 1, "the bike theme was cued once")
check(music[1] and music[1][2] == true, "as the riding track")
end
end
-- already riding: the same single press dismounts
do
music = {}
local game = freshGame({ onBike = true })
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened while riding") then
check(not sawMenu(seen), "still no option box on the way off the bike")
eq(game.save.onBike, false, "one press dismounted")
local said = boxText(game)
if check(said ~= nil, "and printed a line") then
check(said:find("got off", 1, true) ~= nil,
"which is the dismount text: " .. tostring(said))
end
check(music[1] and music[1][2] == false, "and the map theme came back")
end
end
-- Cycling Road: the refusal is reached on the same single press, and the
-- item list stays open behind it (`jp ItemMenuLoop`, #513)
do
local game = freshGame({ onBike = true, forcedBike = true })
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the Cycling Road") then
check(not sawMenu(seen), "the refusal is not behind an option box either")
eq(game.save.onBike, true, "and the player is still riding")
local said = boxText(game)
if check(said ~= nil, "the refusal printed") then
check(said:find("get off", 1, true) ~= nil,
"with _CannotGetOffHereText: " .. tostring(said))
end
check(inStack(game, function(s) return s == list end),
"the bag list is still on the stack under it (#513)")
end
end
-- somewhere cycling is not allowed: still one press, still no box
do
local game = freshGame()
game.overworld.map = { id = "FIX_INDOORS", def = { tileset = "FIX_HOUSE" } }
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened indoors") then
check(not sawMenu(seen), "the no-cycling refusal skips the box too")
eq(game.save.onBike, nil, "and nothing was mounted")
local said = boxText(game)
check(said ~= nil and said:find("cycling", 1, true) ~= nil,
"NoCyclingAllowedHere printed: " .. tostring(said))
end
end
-- the control cases: everything else still gets USE/TOSS, key items included
do
local game = freshGame()
local list, seen = chooseOnce(game, "FIX_POTION")
if check(list ~= nil, "the bag opened on a plain item") then
check(sawMenu(seen), "an ordinary item still opens the option box")
check(isMenu(game.stack:top()), "and it is what the A press left on top")
end
end
do
local game = freshGame()
local list, seen = chooseOnce(game, "TOWN_MAP")
if check(list ~= nil, "the bag opened on the TOWN MAP") then
check(sawMenu(seen),
"another key item still gets the box: pokered special-cases the "
.. "BICYCLE by id, not key items as a class")
end
end
-- the box the bike no longer opens is the only route to TOSS, so the bike
-- cannot be thrown away from the bag at all
do
local game = freshGame()
local list, seen = chooseOnce(game, "BICYCLE")
if check(list ~= nil, "the bag opened on the BICYCLE row") then
local rows = {}
for _, s in ipairs(seen or {}) do
for _, it in ipairs((isMenu(s) and s.items) or {}) do
rows[#rows + 1] = tostring(it.label)
end
end
eq(#rows, 0, "the press offered no menu rows at all, TOSS included")
eq(game.save.inventory.BICYCLE, 1, "and the bike survives the press")
end
end
package.loaded["src.core.Sound"] = nil
package.loaded["src.core.Music"] = nil
package.loaded["src.render.TextBox"] = nil
package.loaded["src.ui.BagMenu"] = nil
require("src.ui.Screens").invalidate()
T.finish()
+253
View File
@@ -0,0 +1,253 @@
-- The bag list ends on the terminator's CANCEL row (#1685).
--
-- pokered's item lists are $ff-terminated. PrintListMenuEntries walks four
-- entries and, on the terminator, `jp z, .printCancelMenuItem` -- which is
-- `ld de, ListMenuCancelText / jp PlaceString`, a TAIL jump that RETURNS
-- from PrintListMenuEntries (home/list_menu.asm:371-372, 523-528). The '▼'
-- at :518-522 is only reached by the fall-through, so any page showing
-- CANCEL shows no down arrow. DisplayListMenuIDLoop compares the selection
-- against wListCount and takes ExitListMenu when it is the row past the last
-- item (:105-110), which is the same exit B takes.
--
-- The port emitted one row per bag entry and stopped, so the list had no
-- CANCEL to walk onto and printed an invented "Nothing here." when the bag
-- was empty.
-- luajit tests/engine/bag_cancel_row_bug1685.lua
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")
-- Font wants a real atlas and this suite is about what lands on which row,
-- so it records the calls instead (the shape tests/engine/
-- bag_item_box_bug1521.lua uses). ListMenu and Theme bind Font at require
-- time, and BagMenu binds ListMenu and TextBox at require time.
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
-- Menu.new sizes its box off split(); reaching it at all is the failure
-- this suite reports, so the stub answers instead of erroring
split = function(text)
local spans = {}
for i = 1, #tostring(text) do spans[i] = { from = i, to = i, code = 0 } end
return spans
end,
}
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
soundOpts = function() return nil end,
}
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
package.loaded["src.ui.BagMenu"] = nil
local ListMenu = require("src.ui.ListMenu")
local Theme = require("src.ui.Theme")
local BagMenu = require("src.ui.BagMenu")
require("src.ui.Screens").invalidate()
local Fixtures = require("tests.modkit.fixtures")
local Bag = require("src.inventory.Bag")
local Strings = require("src.core.Strings")
local CANCEL = Strings("CANCEL")
local Data = Fixtures.fresh()
for i = 1, 6 do
local id = "FIXITEM_" .. i
Data.items[id] = { id = id, index = 100 + i, name = "FIX ITEM " .. i,
price = 100, tossable = true }
end
local function freshGame(count)
local game = {
data = Data,
save = {
party = {},
player = { name = "RED", id = 1 },
inventory = {},
options = {},
flags = {},
money = 0,
},
}
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
-- one button edge per update, the way Input reports a fixed step
game.input = { pressed = nil }
function game.input:wasPressed(b) return self.pressed == b end
function game.input:isDown() return false end
for i = 1, count do Bag.add(game.save, "FIXITEM_" .. i, i) end
return game
end
local function openBag(game, opts)
local list = BagMenu.new(game, opts)
game.stack:push(list)
return list
end
local function found(kind, pred)
for _, c in ipairs(calls) do
if c[1] == kind and pred(c) then return c end
end
return nil
end
local function drawnAt(text, x, y)
return found("draw", function(c)
return c[2] == text and c[3] == x and (y == nil or c[4] == y)
end)
end
local function press(list, btn)
list.game.input.pressed = btn
list:update(1 / 60)
list.game.input.pressed = nil
end
-- the row itself: one CANCEL after the last bag entry, carrying no id and no
-- quantity (home/list_menu.asm:523-528)
do
local game = freshGame(6)
local list = openBag(game)
eq(#list.items, 7, "six bag entries plus the terminator's CANCEL row")
local last = list.items[#list.items]
eq(last.label, CANCEL, "the last row is CANCEL")
eq(last.cancel, true, "flagged as the terminator row, not an item")
eq(last.value, nil, "with no item id behind it")
eq(last.right, nil, "and no 'xN' count (PrintListMenuEntries never gets "
.. "that far for the terminator)")
eq(CANCEL, "CANCEL", "ListMenuCancelText reads CANCEL")
for i = 1, 6 do
eq(list.items[i].value, "FIXITEM_" .. i, "row " .. i .. " is still its item")
end
end
-- page one of a long list: four real names, the '▼', no CANCEL yet
do
local game = freshGame(6)
local list = openBag(game)
calls = {}
list:draw()
for row = 1, 4 do
local y = 32 + (row - 1) * 16
check(drawnAt("FIX ITEM " .. row, 48, y) ~= nil,
"name " .. row .. " sits at (48, " .. y .. ")")
end
check(found("draw", function(c) return c[2] == CANCEL end) == nil,
"CANCEL is below the fold on page one, not printed on it")
check(found("code", function(c)
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
end) ~= nil, "four real rows fill the page, so the '▼' prints (:518-522)")
end
-- walking down: the cursor reaches CANCEL and stops there, and the page it
-- lands on drops the '▼'
do
local game = freshGame(6)
local list = openBag(game)
for _ = 1, 6 do press(list, "down") end
eq(list.index, 7, "six downs put the cursor on the CANCEL row")
eq(list.scroll, 4, "with the scroll capped at wListCount - 2")
press(list, "down")
eq(list.index, 7, "and a seventh down goes nowhere: CANCEL is the last row")
eq(list.scroll, 4, "the list does not scroll past it either")
calls = {}
list:draw()
-- rows 5, 6, CANCEL, then the terminator's return
check(drawnAt("FIX ITEM 5", 48, 32) ~= nil, "FIX ITEM 5 leads the last page")
check(drawnAt(CANCEL, 48, 64) ~= nil,
"CANCEL sits one row below the last item, at the item names' x")
check(found("code", function(c)
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 64
end) ~= nil, "the cursor is on it, in wTopMenuItemX's column")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"and the '▼' is gone: .printCancelMenuItem returns before it")
end
-- the subtle one: three items plus CANCEL fills all four printed rows, so
-- `shown == rows` alone would still paint the arrow next to CANCEL
do
local game = freshGame(3)
local list = openBag(game)
eq(#list.items, 4, "three items and CANCEL exactly fill the four rows")
calls = {}
list:draw()
check(drawnAt(CANCEL, 48, 80) ~= nil, "CANCEL is the fourth printed row")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"a full page whose last row is CANCEL still prints no '▼' (:371-372)")
end
-- an empty bag is a box with CANCEL alone, which is what the cart shows
do
local game = freshGame(0)
local list = openBag(game)
eq(#list.items, 1, "no items: the terminator is the whole list")
eq(list.items[1] and list.items[1].cancel, true, "and that row is CANCEL")
calls = {}
list:draw()
check(drawnAt(CANCEL, 48, 32) ~= nil, "printed on the first row")
check(found("draw", function(c)
return tostring(c[2]):find("Nothing here", 1, true) ~= nil
end) == nil, "the port's invented \"Nothing here.\" line is gone")
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"one row, no '▼'")
end
-- A on CANCEL is ExitListMenu: the same exit B takes (:105-110)
do
local game = freshGame(6)
local cancels = 0
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
for _ = 1, 6 do press(list, "down") end
eq(list.index, 7, "cursor on CANCEL")
press(list, "a")
check(game.stack:top() ~= list, "A on CANCEL leaves the item list")
eq(#game.stack.states, 0, "with nothing pushed over it: no USE/TOSS box, "
.. "no message")
eq(cancels, 1, "and the caller's onCancel ran, exactly as for B")
end
do
local game = freshGame(6)
local cancels = 0
local list = openBag(game, { onCancel = function() cancels = cancels + 1 end })
press(list, "b")
check(game.stack:top() ~= list, "B still exits the list")
eq(cancels, 1, "through the same callback")
end
-- SELECT swaps two bag entries; the terminator is not one
-- (engine/menus/swap_items.asm:19-22)
do
local game = freshGame(6)
local list = openBag(game)
for _ = 1, 6 do press(list, "down") end
press(list, "select")
eq(list.swapIndex, nil, "SELECT on the CANCEL row starts no swap")
press(list, "up")
press(list, "select")
eq(list.swapIndex, 6, "while SELECT on a real row still does")
end
package.loaded["src.render.Font"] = nil
package.loaded["src.core.Sound"] = nil
package.loaded["src.render.TextBox"] = nil
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
package.loaded["src.ui.BagMenu"] = nil
require("src.ui.Screens").invalidate()
T.finish()
+863 -7
View File
@@ -96,6 +96,38 @@ eq(#imp:_ensureCarts("blue"), 1, "blue lists only its own cart")
eq(imp:_ensureCarts("blue")[1].id, "johto_lite", "and that one is Johto Lite")
eq(#imp:_ensureCarts("yellow"), 0, "a game with no carts lists none")
-- A .g1rcart dropped into the carts folder by hand, with no registry entry:
-- the launcher must adopt it, not read past it. _refreshCarts used to call
-- CartStore.index (registry only) and never saw one.
do
local stray = CartManifest.parse(cartTable({
id = "dropped_in", title = "Dropped In", version = "2.0.0" }))
check(stray ~= nil, "the stray fixture parses")
local opts = SaveData.loadOptions()
opts[CartStore.OPTIONS_KEY] = nil
SaveData.saveOptions(opts)
love.filesystem.createDirectory(CartStore.DIR)
love.filesystem.write(CartStore.fileFor("dropped_in"),
CartManifest.encode(stray))
local fresh = freshLauncher()
local found
for _, row in ipairs(fresh:_ensureCarts("red")) do
if row.id == "dropped_in" then found = row end
end
check(found ~= nil, "the launcher lists a cart dropped into the folder")
if found then
eq(found.title, "Dropped In", "and reads its title from the file")
eq(found.base, "red", "and its base game")
end
eq(CartStore.index()[1] ~= nil, true,
"listing healed the registry so the cheap index sees it too")
-- Put the fixture set back: later blocks assert on the picker's own layout,
-- and a third red cart changes its height.
CartStore.uninstall("dropped_in")
end
imp.tab = "red"
imp.ready.red = true
imp._cartPopup = "red"
@@ -109,8 +141,9 @@ check(picker:find("Johto Lite", 1, true) == nil,
check(picker:find("v1.2.0", 1, true) ~= nil, "a cart row carries its version")
check(picker:find("sealed", 1, true) ~= nil, "a cart row carries its seal state")
check(picker:find("open", 1, true) ~= nil, "including an open one")
check(picker:find("Get more carts", 1, true) ~= nil,
"the last row is the browse placeholder")
check(picker:find("Import a cart", 1, true) ~= nil
or picker:find("Get more carts", 1, true) ~= nil,
"the last row imports a cart, by picker or by folder")
imp._cartPopup = nil
local vanillaColors = drawColors(imp)
@@ -305,14 +338,14 @@ local maker = freshLauncher()
maker.tab = "red"
maker.ready.red = true
maker:_setModScope("red")
eq(maker:_cartCaptureCount("red"), 2,
"the control counts only the mods enabled for this game")
eq(maker:_cartCaptureCount("red"), 3,
"the control counts every mod the capture would pin, on or off")
maker:_beginCartSave("red")
check(maker._cartSave ~= nil, "Save as cart opens a form")
eq(maker._cartSave.count, 2, "the form reports the captured mod count")
eq(maker._cartSave.count, 3, "the form reports the captured mod count")
eq(maker._cartSave.version, "red", "scoped to the game the panel is showing")
eq(#maker._cartSave.unresolved, 1, "capture reports one pin it could not resolve")
eq(#maker._cartSave.unresolved, 2, "capture reports the pins it could not resolve")
eq(maker._cartSave.unresolved[1].id, "wide_gym", "naming the mod it belongs to")
check(tostring(maker._cartSave.unresolved[1].reason):find("semantic", 1, true) ~= nil,
"and why it could only be pinned locally")
@@ -345,7 +378,13 @@ eq(made.base, "red", "based on the game the panel was showing")
eq(made.version, "1.0.0", "at the default cart version")
eq(made.seal, "sealed", "sealed by default")
eq(made.shell, "#ff3c48", "wearing the base game's rail colour")
eq(#made.mods, 2, "pinning exactly the enabled mods")
eq(#made.mods, 3, "pinning every installed mod, on or off")
local madeOff
for _, entry in ipairs(made.mods) do
if entry.id == "off_mod" then madeOff = entry end
end
check(madeOff ~= nil, "including the one the player has switched off")
eq(madeOff.enabled, false, "which is pinned switched off rather than dropped")
local listedNow = false
for _, row in ipairs(maker:_ensureCarts("red")) do
@@ -472,9 +511,513 @@ eq(SaveData.slotSealBroken("kanto_plus", freshSlot), false,
"starts sealed again")
eq(gapCart:cartPlan("red").refused, true, "so the cart refuses that one")
install({ id = "plus_cart", title = "Plus Cart", seal = "sealed+",
shell = "#445566" })
LauncherMods.list = function() return FULL_MODS end
local plusCart = freshLauncher()
plusCart.tab = "red"
plusCart.ready.red = true
plusCart._cartPopup = "red"
local plusPicker = drawAndCapture(plusCart)
check(plusPicker:find("sealed+", 1, true) ~= nil,
"the picker spells a sealed+ cart's seal out")
plusCart._cartPopup = nil
plusCart:_selectCart("red", "plus_cart")
local plusPlan = plusCart:cartPlan("red")
eq(plusPlan.seal, "sealed+", "the plan carries the sealed+ seal")
eq(plusPlan.sealed, true, "sealed+ is a sealed cart")
eq(plusPlan.enforced, true, "and enforces its mod set")
local plusText = drawAndCapture(plusCart)
check(plusText:find("Sealed", 1, true) ~= nil, "the page says it is sealed")
check(plusText:find("switch any of them on or off", 1, true) ~= nil,
"and that the player may switch the mods it pins")
check(plusText:find("Break the seal", 1, true) ~= nil,
"while still offering the escape hatch")
eq(SaveData.slotSealBroken("plus_cart",
plusCart.activeSlot[plusCart:slotScope("red")] or "slot1"), false,
"and reading the page breaks nothing")
-- ------- installing the mods a cart pins, instead of defeating its seal
local ModUpdate = require("src.mods.ModUpdate")
local realFetchBegin, realFetchPump =
ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases
local realDlBegin, realDlPump =
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip
local realInstallDownloaded = LauncherMods.installDownloadedZip
love.data = love.data or {}
local savedData = { hash = love.data.hash, encode = love.data.encode }
-- The archive's digest IS its bytes here, so a fixture writes the hash it
-- wants the downloaded zip to have.
love.data.hash = function(_, data) return tostring(data) end
love.data.encode = function(_, _, digest) return tostring(digest) end
local SHA_ONE = ("0123abcd"):rep(8)
local SHA_TWO = ("dead9876"):rep(8)
local net = { versions = {}, bytes = {}, err = nil, repos = {} }
ModUpdate.beginFetchReleases = function(repo, modId)
net.repos[modId] = repo
return { modId = modId, repo = repo }
end
ModUpdate.pumpFetchReleases = function(h)
if net.err then return true, nil, net.err end
local out = {}
for _, v in ipairs(net.versions[h.modId] or {}) do
out[#out + 1] = { version = v, tag = "v" .. v,
zip = { url = h.modId .. "@" .. v, name = h.modId .. ".zip" } }
end
return true, out
end
ModUpdate.beginDownloadZip = function(url, destName)
love.filesystem.write(destName, net.bytes[url] or "not-the-pinned-archive")
return { path = destName }
end
ModUpdate.pumpDownloadZip = function(h) return true, h.path end
local haveMods = {}
LauncherMods.installDownloadedZip = function(modId, localPath, version)
haveMods[modId] = version or "?"
pcall(love.filesystem.remove, localPath)
return true, haveMods[modId]
end
LauncherMods.list = function()
local rows = {}
for id, v in pairs(haveMods) do
rows[#rows + 1] = fakeRow({ id = id, name = id, version = v })
end
return rows
end
install({ id = "fill_cart", title = "Fill Cart", shell = "#227744",
mods = {
{ id = "fill_one", source = "github", repo = "ren/fill-one",
version = "1.0.0", sha256 = SHA_ONE },
{ id = "fill_two", source = "github", repo = "ren/fill-two",
version = "2.1.0", sha256 = SHA_TWO },
},
load_order = { "fill_one", "fill_two" } })
install({ id = "odd_cart", title = "Odd Cart", shell = "#772244",
mods = {
{ id = "banana_mod", source = "gamebanana", mod = 4821, file = 99123,
md5 = ("ab"):rep(16) },
{ id = "here_mod", source = "local", version = "0.0.0" },
},
load_order = { "banana_mod", "here_mod" } })
local function fillPage(cartId)
local page = freshLauncher()
page.tab = "red"
page.ready.red = true
page:_selectCart("red", cartId)
return page
end
local function runFill(page)
page:pressInstallCartMods("red")
local guard = 0
while page._cartFill and guard < 500 do
page:_pumpModInstall()
page:_pumpCartFill()
guard = guard + 1
end
check(page._cartFill == nil, "the install run finishes")
return page.cartFillNotice or {}
end
local function failureText(notice)
return table.concat(notice.failures or {}, " | ")
end
net.versions.fill_one = { "1.0.0", "0.9.0" }
net.versions.fill_two = { "2.1.0" }
net.bytes["fill_one@1.0.0"] = SHA_ONE
net.bytes["fill_two@2.1.0"] = SHA_TWO
-- A cart whose pins are all installed offers nothing to install.
haveMods = { fill_one = "1.0.0", fill_two = "2.1.0" }
local readyFill = fillPage("fill_cart")
eq(readyFill:cartPlan("red").refused, false, "every pin installed, so it plays")
eq(#readyFill:cartFillRows("red"), 0, "with nothing left to install")
local readyText = drawAndCapture(readyFill)
check(readyText:find("Install required mods", 1, true) == nil
and readyText:find("required mods", 1, true) == nil,
"so a ready cart never offers the install button")
check(readyText:find("Break the seal", 1, true) ~= nil,
"while the seal control is where it always was")
-- Nothing installed: the button appears, named for the count, beside the seal.
haveMods = {}
local gapFill = fillPage("fill_cart")
eq(#gapFill:cartFillRows("red"), 2, "both uninstalled pins queue up")
local gapFillText = drawAndCapture(gapFill)
check(gapFillText:find("Install 2 required mods", 1, true) ~= nil,
"a refused cart offers to install what it pins, counted")
check(gapFillText:find("the way its author built it", 1, true) ~= nil,
"saying plainly that this is the way to play it as built")
check(gapFillText:find("Break the seal", 1, true) ~= nil,
"with breaking the seal still there as the fallback")
-- A pin installed at the WRONG version is a gap too: the cart wants its own.
haveMods = { fill_one = "0.9.0", fill_two = "2.1.0" }
local skewFill = fillPage("fill_cart")
eq(skewFill:cartPlan("red").mismatched[1].id, "fill_one",
"a pin at another version is a mismatch")
eq(#skewFill:cartFillRows("red"), 1, "which queues for install like a gap")
eq(skewFill:cartFillRows("red")[1].version, "1.0.0",
"at the version the cart pins, not the one on disk")
local skewText = drawAndCapture(skewFill)
check(skewText:find("Install required mods", 1, true) ~= nil,
"a single gap drops the count from the label")
-- THE HASH GATE. An archive that is not the one the cart recorded installs
-- nothing, however well the id and version line up.
haveMods = {}
net.bytes["fill_one@1.0.0"] = "some other build entirely"
local badHash = fillPage("fill_cart")
local badNotice = runFill(badHash)
eq(haveMods.fill_one, nil, "a hash mismatch installs nothing at all")
eq(badNotice.ok, false, "and the run reports as failed")
check(failureText(badNotice):find("fill_one", 1, true) ~= nil,
"naming the mod whose archive did not match")
check(failureText(badNotice):find("sha256", 1, true) ~= nil,
"and saying it was the hash: " .. failureText(badNotice))
eq(badHash:cartPlan("red").refused, true, "so the cart still refuses")
net.bytes["fill_one@1.0.0"] = SHA_ONE
-- Partial: one archive verifies, the other does not.
haveMods = {}
net.bytes["fill_two@2.1.0"] = "wrong bytes for the second mod"
local partial = fillPage("fill_cart")
local partialNotice = runFill(partial)
eq(haveMods.fill_one, "1.0.0", "the pin that verified is installed")
eq(haveMods.fill_two, nil, "the pin that did not is not")
eq(partialNotice.ok, false, "a partial run reads as a failure")
check(tostring(partialNotice.text):find("1 of 2", 1, true) ~= nil,
"counting what landed: " .. tostring(partialNotice.text))
check(failureText(partialNotice):find("fill_two", 1, true) ~= nil,
"and naming only the mod that failed")
check(failureText(partialNotice):find("fill_one", 1, true) == nil,
"not the one that worked")
local partialText = drawAndCapture(partial)
check(partialText:find("fill_two", 1, true) ~= nil,
"the card reports the failure where the player pressed the button")
net.bytes["fill_two@2.1.0"] = SHA_TWO
-- The whole run, verified, with the seal untouched at the end of it.
haveMods = {}
local good = fillPage("fill_cart")
local goodScope = good:slotScope("red")
good:_ensureSlots(goodScope)
local goodSlot = good.activeSlot[goodScope]
eq(good:cartPlan("red").refused, true, "the cart refuses before the run")
local goodNotice = runFill(good)
eq(goodNotice.ok, true, "the run reports success: " .. tostring(goodNotice.text))
eq(haveMods.fill_one, "1.0.0", "the first pin lands at its pinned version")
eq(haveMods.fill_two, "2.1.0", "and the second at its own")
eq(net.repos.fill_one, "ren/fill-one", "each pin was looked up in its own repo")
eq(net.repos.fill_two, "ren/fill-two", "including the second")
eq(good:cartPlan("red").refused, false,
"and the cart is ready to play with no further presses")
eq(SaveData.slotSealBroken("fill_cart", goodSlot or "slot1"), false,
"installing never breaks the cart's seal")
eq(SaveData.isSealBroken(), false, "nor the session's")
local goodText = drawAndCapture(good)
check(goodText:find("Sealed", 1, true) ~= nil,
"the card flips to ready without another click")
check(goodText:find("required mods", 1, true) == nil,
"and stops offering the install button")
-- No release carrying that version.
haveMods = {}
net.versions.fill_one = { "0.9.0" }
local noRel = fillPage("fill_cart")
local noRelNotice = runFill(noRel)
eq(haveMods.fill_one, nil, "a version the repo does not publish installs nothing")
check(failureText(noRelNotice):find("v1.0.0", 1, true) ~= nil,
"and the failure names the version it wanted: " .. failureText(noRelNotice))
net.versions.fill_one = { "1.0.0", "0.9.0" }
-- No .zip on the matching release.
haveMods = {}
local savedPump = ModUpdate.pumpFetchReleases
ModUpdate.pumpFetchReleases = function(h)
return true, { { version = "1.0.0", tag = "v1.0.0" },
{ version = "2.1.0", tag = "v2.1.0" } }
end
local noZip = fillPage("fill_cart")
local noZipNotice = runFill(noZip)
eq(haveMods.fill_one, nil, "a release with no archive installs nothing")
check(failureText(noZipNotice):find(".zip", 1, true) ~= nil,
"and says so: " .. failureText(noZipNotice))
ModUpdate.pumpFetchReleases = savedPump
-- Network down.
haveMods = {}
net.err = "no network transport on this platform"
local offline = fillPage("fill_cart")
local offlineNotice = runFill(offline)
eq(haveMods.fill_one, nil, "an offline run installs nothing")
eq(offlineNotice.ok, false, "and reports as failed")
check(failureText(offlineNotice):find("no network transport", 1, true) ~= nil,
"carrying the transport's own words: " .. failureText(offlineNotice))
net.err = nil
-- Sources the launcher cannot fetch are refused per mod, not as one silence.
haveMods = {}
local odd = fillPage("odd_cart")
eq(#odd:cartFillRows("red"), 2, "both odd pins are gaps")
local oddText = drawAndCapture(odd)
check(oddText:find("Install 2 required mods", 1, true) ~= nil,
"the button is offered even when a pin may not be fetchable")
local oddNotice = runFill(odd)
eq(oddNotice.ok, false, "neither can be installed")
eq(#(oddNotice.failures or {}), 2, "and each is reported on its own line")
check(failureText(oddNotice):find("banana_mod", 1, true) ~= nil
and failureText(oddNotice):find("GameBanana", 1, true) ~= nil,
"the gamebanana pin says what it is pinned to: " .. failureText(oddNotice))
check(failureText(oddNotice):find("here_mod", 1, true) ~= nil
and failureText(oddNotice):find("nothing to download", 1, true) ~= nil,
"and the local pin says there is nothing to fetch")
eq(next(haveMods), nil, "with nothing installed either way")
local oddAfter = drawAndCapture(odd)
check(oddAfter:find("Break the seal", 1, true) ~= nil,
"and the seal is still the fallback it was")
-- Breaking the seal still works exactly as it did, install button or not.
local sealStill = fillPage("odd_cart")
local oddScope = sealStill:slotScope("red")
sealStill:_newSlot(oddScope)
local oddSlot = sealStill.activeSlot[oddScope]
check(type(oddSlot) == "string", "the cart page has a loaded save slot")
eq(sealStill:pressBreakSeal("red"), false, "the first press still only arms")
eq(SaveData.slotSealBroken("odd_cart", oddSlot), false, "breaking nothing")
eq(sealStill:pressBreakSeal("red"), true, "and the second still breaks it")
eq(SaveData.slotSealBroken("odd_cart", oddSlot), true, "on that slot")
eq(sealStill:cartPlan("red").refused, false, "so the cart plays")
love.data.hash, love.data.encode = savedData.hash, savedData.encode
ModUpdate.beginFetchReleases, ModUpdate.pumpFetchReleases =
realFetchBegin, realFetchPump
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip =
realDlBegin, realDlPump
LauncherMods.installDownloadedZip = realInstallDownloaded
-- Later blocks audit the picker's layout, which counts the carts on red.
CartStore.uninstall("fill_cart")
CartStore.uninstall("odd_cart")
LauncherMods.list = realModList
window(1280, 720)
-- ------- the MODS tab under an active cart
-- the rows the base game's own list would hand back, so the panel with no
-- cart active has something real to disagree with the carts about
local PIN_MODS = {
fakeRow({ id = "pin_on", name = "Pin On", version = "1.0.0" }),
fakeRow({ id = "pin_off", name = "Pin Off", version = "1.0.0",
enabled = false,
enabledByVersion = { red = false, blue = false, yellow = false,
gold = false, silver = false } }),
}
local function localPin(id, off)
local entry = { id = id, source = "local", version = "1.0.0" }
if off then entry.enabled = false end
return entry
end
local PIN_SET = { localPin("pin_on"), localPin("pin_off", true) }
local PIN_ORDER = { "pin_on", "pin_off" }
install({ id = "plus_mods", title = "Plus Mods", seal = "sealed+",
shell = "#2b8a3e", mods = PIN_SET, load_order = PIN_ORDER })
install({ id = "hard_mods", title = "Hard Mods", seal = "sealed",
shell = "#8a2b3e", mods = PIN_SET, load_order = PIN_ORDER })
install({ id = "gap_mods", title = "Gap Mods", seal = "sealed+",
shell = "#3e2b8a",
mods = { localPin("pin_on"), localPin("ghost_mod") },
load_order = { "pin_on", "ghost_mod" } })
local realSetEnabled, realSetAllEnabled = LauncherMods.setEnabled, LauncherMods.setAllEnabled
local perGameWrites = 0
LauncherMods.setEnabled = function(...)
perGameWrites = perGameWrites + 1
return realSetEnabled(...)
end
LauncherMods.setAllEnabled = function(...)
perGameWrites = perGameWrites + 1
return realSetAllEnabled(...)
end
LauncherMods.list = function() return PIN_MODS end
-- the base game's own answer for the same mod, which nothing below may move
local seeded = SaveData.loadOptions()
SaveData.setModEnabled(seeded, "pin_off", false, "red")
SaveData.setModEnabled(seeded, "pin_on", true, "red")
SaveData.saveOptions(seeded)
local function rowsById(imp)
local byId = {}
for _, row in ipairs(imp.mods or {}) do byId[row.id] = row end
return byId
end
local plain = freshLauncher()
plain.tab = "mods"
plain.ready.red = true
plain:_selectCart("red", nil)
plain:_setModScope("red")
eq(#plain.mods, 2, "with no cart the panel lists the installed mods")
eq(plain.mods[1].cartPin, nil, "which are not marked as any cart's")
check(type(plain.mods[1].enabledByVersion) == "table",
"and still carry their per-game answers")
perGameWrites = 0
plain:_toggleMod("pin_on", nil, "red")
eq(perGameWrites, 1, "a toggle with no cart still writes the per-game flag")
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), false,
"which is what changed")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_on"), nil,
"and no cart scope was touched")
SaveData.setModEnabled(seeded, "pin_on", true, "red")
SaveData.saveOptions(seeded)
local pins = freshLauncher()
pins.tab = "mods"
pins.ready.red = true
pins:_selectCart("red", "plus_mods")
pins:_setModScope("red")
eq(#pins.mods, 2, "an active cart makes the panel list its pins")
local pinRows = rowsById(pins)
eq(pinRows.pin_on.cartPin, true, "each row is marked as the cart's")
eq(pinRows.pin_on.cartId, "plus_mods", "naming the cart it came from")
eq(pinRows.pin_on.enabled, true, "a pin shipped switched on shows on")
eq(pinRows.pin_off.enabled, false, "a pin shipped switched off shows off")
eq(pinRows.pin_off.cartTogglable, true, "sealed+ hands every pin's switch over")
eq(pinRows.pin_on.enabledByVersion, nil,
"and a cart row carries no per-game answer, because a cart is one game")
local pinText = drawAndCapture(pins)
check(pinText:find("PINNED", 1, true) ~= nil,
"the list says on every row that these are the cart's mods")
check(pinText:find("Plus Mods", 1, true) ~= nil, "and names the cart above them")
check(pinText:find("In this cart:", 1, true) ~= nil,
"with a switch that answers the cart, not the game")
perGameWrites = 0
pins:_toggleMod("pin_off", nil, "red")
local afterOn = SaveData.loadOptions()
eq(SaveData.cartModEnabled(afterOn, "plus_mods", "pin_off"), true,
"a sealed+ toggle writes the player's answer into the cart's scope")
eq(perGameWrites, 0, "and never through the per-game path")
eq(SaveData.modEnabled(afterOn, "pin_off", "red"), false,
"so the base game's flag for that mod is untouched")
eq(SaveData.modEnabled(afterOn, "pin_on", "red"), true, "as is every other")
eq(rowsById(pins).pin_off.enabled, true, "the row follows the new answer")
local pinScope = pins:slotScope("red")
pins:_ensureSlots(pinScope)
local pinSlot = pins.activeSlot[pinScope]
eq(SaveData.slotSealBroken("plus_mods", pinSlot or "slot1"), false,
"switching a sealed+ pin does not break the cart's seal")
eq(SaveData.isSealBroken(), false, "nor the session's")
eq(pins:cartPlan("red").broken, false, "and the plan still reads it as intact")
pins:_toggleMod("pin_off", nil, "red")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
"pressing again switches it back off, still in the cart's scope")
perGameWrites = 0
pins:_setAllMods(true)
eq(perGameWrites, 0, "Enable all cannot reach a cart's mod set")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
"so no pin moved")
check(tostring(pins.modNotice.text):find("Plus Mods", 1, true) ~= nil,
"and the panel says which cart is deciding")
eq(pins.modNotice.ok, false, "as a refusal")
pins:_setAllMods(false)
eq(perGameWrites, 0, "Disable all cannot either")
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_on", "red"), true,
"and the base game's list is where it was")
pins:_toggleMod("wide_gym", nil, "red")
check(tostring(pins.modNotice.text):find("cannot be added to", 1, true) ~= nil,
"a mod the cart does not pin cannot be switched on from here")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "wide_gym"), nil,
"and nothing is written for it")
pins.safeMode = true
pins:_toggleMod("pin_off", nil, "red")
check(tostring(pins.modNotice.text):find("Safe mode", 1, true) ~= nil,
"safe mode still refuses a cart toggle first")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
"and writes nothing")
pins.safeMode = false
local sealedPins = freshLauncher()
sealedPins.tab = "mods"
sealedPins.ready.red = true
sealedPins:_selectCart("red", "hard_mods")
sealedPins:_setModScope("red")
local hardRows = rowsById(sealedPins)
eq(hardRows.pin_off.enabled, false, "a sealed cart's off pin shows off")
eq(hardRows.pin_off.cartTogglable, false, "and hands no switch over")
eq(hardRows.pin_on.cartTogglable, false, "nor does the pin it ships on")
perGameWrites = 0
sealedPins:_toggleMod("pin_off", nil, "red")
eq(perGameWrites, 0, "a sealed pin never reaches the per-game path either")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "hard_mods", "pin_off"), nil,
"and a press under a sealed cart writes nothing")
eq(sealedPins.modNotice.ok, false, "the press is refused")
check(tostring(sealedPins.modNotice.text):find("sealed", 1, true) ~= nil,
"with the panel saying why rather than doing nothing")
check(tostring(sealedPins.modNotice.text):find("Break the seal", 1, true) ~= nil,
"and pointing at the one way to change it")
local hardScope = sealedPins:slotScope("red")
sealedPins:_ensureSlots(hardScope)
eq(SaveData.slotSealBroken("hard_mods",
sealedPins.activeSlot[hardScope] or "slot1"), false,
"a refused press breaks no seal of its own")
local hardText = drawAndCapture(sealedPins)
check(hardText:find("Pinned, sealed:", 1, true) ~= nil,
"and the row itself reads as locked")
local gapPins = freshLauncher()
gapPins.tab = "mods"
gapPins.ready.red = true
gapPins:_selectCart("red", "gap_mods")
gapPins:_setModScope("red")
local gapRows = rowsById(gapPins)
eq(#gapPins.mods, 2, "a pin that is not installed is still listed")
eq(gapRows.ghost_mod.status, "missing", "as missing")
eq(gapRows.ghost_mod.enabled, false, "and switched off")
gapPins:_toggleMod("ghost_mod", nil, "red")
check(tostring(gapPins.modNotice.text):find("not installed", 1, true) ~= nil,
"switching it says so instead of writing an answer for a mod that is absent")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "gap_mods", "ghost_mod"), nil,
"and writes nothing")
pins:_selectCart("red", nil)
pins:_setModScope("red")
local backRows = rowsById(pins)
eq(#pins.mods, 2, "choosing the base game lists the player's own mods again")
eq(backRows.pin_on.cartPin, nil, "with no cart marks left on them")
check(type(backRows.pin_on.enabledByVersion) == "table",
"and their per-game answers back")
eq(backRows.pin_off.enabled, false,
"reading exactly what the base game's flags say")
perGameWrites = 0
pins:_toggleMod("pin_off", nil, "red")
eq(perGameWrites, 1, "and a toggle is a per-game write once more")
eq(SaveData.modEnabled(SaveData.loadOptions(), "pin_off", "red"), true,
"which lands in the game's own flags")
eq(SaveData.cartModEnabled(SaveData.loadOptions(), "plus_mods", "pin_off"), false,
"leaving the cart's answer where the player left it")
LauncherMods.setEnabled, LauncherMods.setAllEnabled = realSetEnabled, realSetAllEnabled
LauncherMods.list = realModList
local function clipped(r)
local x1, y1, x2, y2 = r.x, r.y, r.x + r.w, r.y + r.h
if r.clip then
@@ -540,6 +1083,22 @@ for _, size in ipairs(SIZES) do
if ok then
auditFrame(("%dx%d %s"):format(W, H, cart and "cart" or "vanilla"),
"Custom Carts")
-- The refused card carries two chips now, so both have to stay on it.
if cart then
local sawFill = false
for _, r in ipairs(Kit.audit or {}) do
local label = tostring(r.label)
if label == "Install required mods" or label == "Break the seal" then
sawFill = sawFill or label == "Install required mods"
check(r.x >= -0.5 and r.x + r.w <= W + 0.5
and r.y >= -0.5 and r.y + r.h <= H + 0.5,
("%dx%d cart card: %q stays inside the window")
:format(W, H, label))
end
end
check(sawFill,
("%dx%d cart card: drew Install required mods"):format(W, H))
end
end
Kit.audit = nil
@@ -595,6 +1154,303 @@ for _, size in ipairs(SIZES) do
end
Kit.audit = nil
end
LauncherMods.list = function() return PIN_MODS end
for _, size in ipairs(SIZES) do
local W, H = size[1], size[2]
window(W, H)
for _, cart in ipairs({ "plus_mods", "hard_mods" }) do
local panel = freshLauncher()
panel.tab = "mods"
panel.ready.red = true
panel:_selectCart("red", cart)
panel:_setModScope("red")
LauncherView.draw(panel)
Kit.audit = {}
local ok, err = pcall(LauncherView.draw, panel)
Kit.audit = ok and Kit.audit or nil
check(ok, ("%dx%d %s mods draws: %s"):format(W, H, cart, tostring(err)))
if ok then auditFrame(("%dx%d %s mods"):format(W, H, cart)) end
Kit.audit = nil
end
end
LauncherMods.list = realModList
-- ------- FIND tab: browsing the index's carts instead of its mods
--
-- The feed carries carts beside mods at the same schema_version, so the panel
-- has a Mods / Carts switch. Installing a cart from a listing goes through
-- CartStore.install with the downloaded bytes, never the mod installer.
local ModIndex = require("src.mods.ModIndex")
local ModUpdate = require("src.mods.ModUpdate")
local Json = require("src.link.Json")
local INDEX_CART = {
id = "indexed_cart", title = "Indexed Cart", author = "Ren",
version = "1.4.0", base = "silver", seal = "sealed",
repo = "https://github.com/ren/indexed-cart",
github = "ren/indexed-cart",
mods = { { id = "rare_soda", source = "github", repo = "ren/rare-soda",
version = "0.4.1", sha256 = SHA } },
update_check = "ok",
latest = { version = "1.4.0", tag = "v1.4.0",
zip = { name = "indexed_cart-1.4.0.zip",
url = "https://example.test/indexed_cart-1.4.0.zip" } },
}
local INDEX_FEED = ModIndex.parse(Json.encode({
schema_version = 1,
categories = { "GAMEPLAY" },
base_games = { "red", "blue", "yellow", "gold", "silver" },
mods = {
{ id = "rare_soda", title = "Rare Soda", author = "Ren", version = "0.4.1",
categories = { "GAMEPLAY" }, update_check = "off" },
{ id = "true_colour", title = "True Colour", author = "Sam",
version = "1.0.0", categories = { "ART" }, update_check = "off" },
},
carts = {
INDEX_CART,
{ id = "gold_rush", title = "Gold Rush", author = "Sam", version = "3.0.0",
base = "gold", seal = "open", repo = "https://github.com/sam/gold-rush",
mods = { { id = "steps", source = "github", repo = "sam/steps",
version = "1.0.0", sha256 = SHA } },
update_check = "off" },
},
}))
check(INDEX_FEED ~= nil, "the find-tab fixture feed parses")
window(1280, 720)
local find = freshLauncher()
eq(find.findKind, "mods", "the Find tab browses mods by default")
check(find.findBase == nil, "with no base-game filter armed")
find.tab = "find"
find.modScope = nil
find.findLoaded = true
find.findSources = { { feed = "https://example.test/data/index.json",
base = "https://example.test/",
label = "example/index" } }
find.findIndex = { mods = INDEX_FEED.mods, carts = INDEX_FEED.carts,
categories = { "GAMEPLAY", "ART" },
baseGames = ModIndex.baseGamesIn(INDEX_FEED) }
eq(#find:_findRows(), 2, "the default rows are the feed's mods")
eq(find:_findRows()[1].id, "rare_soda", "and they are the mod entries")
local modsFind = drawAndCapture(find)
check(modsFind:find("Mods (2)", 1, true) ~= nil,
"the switch says how many mods the feed lists")
check(modsFind:find("Carts (2)", 1, true) ~= nil, "and how many carts")
check(modsFind:find("Rare Soda", 1, true) ~= nil, "mods are what is listed")
find:_setFindKind("carts")
eq(find.findKind, "carts", "the switch flips to carts")
eq(#find:_findRows(), 2, "and the rows become the feed's carts")
check(ModIndex.isCart(find:_findRows()[1]), "which are cart entries")
local cartsFind = drawAndCapture(find)
check(cartsFind:find("Indexed Cart", 1, true) ~= nil,
"a cart listing is drawn on the Carts half")
check(cartsFind:find("Rare Soda", 1, true) == nil,
"and the mod listings are gone")
check(cartsFind:find("Search carts", 1, true) ~= nil,
"the search field asks for carts")
-- search spans the same fields it does for a mod
find.findQuery = "gold"
eq(#find:_findRows(), 1, "searching a cart title narrows the list")
eq(find:_findRows()[1].id, "gold_rush", "to that cart")
find.findQuery = "Ren"
eq(find:_findRows()[1].id, "indexed_cart", "searching by author works too")
find.findQuery = ""
-- the cart-side filter is by base game, and the popup offers only the base
-- games the feed's carts actually play as
find.findBase = "gold"
eq(#find:_findRows(), 1, "filtering by base game keeps that game's carts")
eq(find:_findRows()[1].id, "gold_rush", "and only those")
local filterText
do
find._filterPopup = true
filterText = drawAndCapture(find)
find._filterPopup = nil
end
check(filterText:find("Filter by base game", 1, true) ~= nil,
"the filter popup filters carts by base game")
check(filterText:find("Filter by category", 1, true) == nil,
"not by a category no cart has")
find.findBase = nil
-- MODS-tab scope still applies: a cart plays as exactly one game
find.modScope = "silver"
eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts")
eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver")
find.modScope = nil
find:_setFindKind("mods")
eq(#find:_findRows(), 2, "switching back restores the mod rows")
find:_setFindKind("carts")
-- ------- installing a cart from a listing
local INDEX_CART_BYTES = CartManifest.encode(CartManifest.parse(cartTable({
id = "indexed_cart", title = "Indexed Cart", version = "1.4.0",
base = "silver", shell = "#d8c24a" })))
eq(#find:_ensureCarts("silver"), 0, "silver has no carts before the install")
local entry
for _, row in ipairs(find:_findRows()) do
if row.id == "indexed_cart" then entry = row end
end
check(entry ~= nil, "the cart listing is on screen to install")
local realBegin, realPump = ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip
local asked = {}
ModUpdate.beginDownloadZip = function(url, name)
asked[#asked + 1] = { url = url, name = name }
return { name = name }
end
ModUpdate.pumpDownloadZip = function(h)
love.filesystem.write(h.name, INDEX_CART_BYTES)
return true, h.name
end
find:_findConfirmInstall(entry)
check(find._modConfirm ~= nil, "installing a cart arms a confirm first")
eq(find._modConfirm.title, "Install cart", "and it says it is a cart")
eq(find._modConfirm.indexEntry, entry, "carrying the listing it will install")
do
local lines = table.concat(find._modConfirm.lines or {}, "\n")
check(lines:find("Pins 1 mod", 1, true) ~= nil,
"the confirm says how many mods the cart pins")
check(lines:find("installed separately", 1, true) ~= nil,
"and that installing the cart does not install them")
end
find._modConfirm = nil
find:_findInstall(entry)
check(find._cartInstall ~= nil, "the cart download is in flight")
check(find._modInstall == nil, "and it is not a mod install")
eq(asked[1].url, "https://example.test/indexed_cart-1.4.0.zip",
"the release the listing resolves is what gets downloaded")
check(asked[1].name:find(".g1rcart", 1, true) ~= nil,
"into a cart file, not a mod zip")
-- single in flight: a mod install cannot start on top of a cart download
find:_beginModInstall({ modId = "rare_soda", name = "Rare Soda", notice = "find",
release = { version = "0.4.1",
zip = { url = "https://example.test/rare_soda.zip" } } })
check(find._modInstall == nil, "a mod install cannot race a cart install")
eq(#asked, 1, "and nothing else was downloaded")
find:_pumpCartInstall()
check(find._cartInstall == nil, "the cart install completes")
check(find.findNotice ~= nil and find.findNotice.ok,
"and reports success on the Find tab")
local installedCart = CartStore.get("indexed_cart")
check(installedCart ~= nil, "the cart is installed through CartStore")
if installedCart then
eq(installedCart.title, "Indexed Cart", "with its own title")
eq(installedCart.base, "silver", "and the game it plays as")
end
-- the per-version cache is the whole point: without invalidating it the new
-- cart would not show in Custom Carts until a relaunch
eq(#find.carts["silver"], 1,
"the cached cart list for that game is refreshed in place")
eq(find.carts["silver"][1].id, "indexed_cart", "with the new cart in it")
eq(find:_findInstalledCarts()["indexed_cart"], "1.4.0",
"and the Find rows now read it as installed")
-- the Custom Carts picker draws the new cart in the same session, with no
-- refresh call from here and no relaunch
do
local picker = freshLauncher()
picker.tab = "silver"
picker.ready.silver = true
picker._cartPopup = "silver"
local pickerText = drawAndCapture(picker)
check(pickerText:find("Indexed Cart", 1, true) ~= nil,
"the Custom Carts picker lists the freshly installed cart")
end
do
local same = drawAndCapture(find)
check(same ~= nil, "the Find tab still draws after an install")
end
-- ------- the pins prompt
--
-- Installing a cart never installs its mods behind the player's back, but a
-- cart whose pins are missing will not start, so it asks once and routes a
-- yes at the existing hash-verified fill queue.
check(find._modConfirm ~= nil, "a cart with missing pins prompts after install")
eq(find._modConfirm.kind, "cartPins", "through the launcher's confirm modal")
eq(find._modConfirm.version, "silver", "for the game the cart plays as")
eq(find._modConfirm.id, "indexed_cart", "naming the cart just installed")
do
local lines = table.concat(find._modConfirm.lines or {}, "\n")
check(lines:find("Indexed Cart pins 1 mod", 1, true) ~= nil,
"the prompt names the cart and how many pins are missing")
end
check(find.activeCart["silver"] == nil,
"and asking does not select the cart on its own")
-- yes routes at pressInstallCartMods, which owns the hash check
do
local realFetchRel = ModUpdate.beginFetchReleases
local realPumpRel = ModUpdate.pumpFetchReleases
local asked_repos = {}
ModUpdate.beginFetchReleases = function(repo)
asked_repos[#asked_repos + 1] = repo
return { repo = repo }
end
ModUpdate.pumpFetchReleases = function() return true, nil, "no releases" end
find._modConfirm = nil
find:_installCartPins("silver", "indexed_cart")
eq(find.activeCart["silver"], "indexed_cart",
"saying yes selects the cart the pins belong to")
for _ = 1, 8 do find:_pumpCartFill() end
eq(asked_repos[1], "ren/rare-soda",
"and the fill queue resolves the cart's own pin")
check(find.cartFillNotice ~= nil and not find.cartFillNotice.ok,
"reporting per-mod failures through the existing notice")
find:_selectCart("silver", nil)
find.cartFillNotice = nil
ModUpdate.beginFetchReleases = realFetchRel
ModUpdate.pumpFetchReleases = realPumpRel
end
-- a cart whose pins are all installed must not prompt at all
do
local realList = LauncherMods.list
LauncherMods.list = function()
return { { id = "rare_soda", version = "0.4.1",
manifest = { id = "rare_soda", version = "0.4.1" } } }
end
local satisfied = freshLauncher()
eq(#satisfied:_cartPinsMissing("silver", "indexed_cart"), 0,
"a cart with every pin installed is missing none")
satisfied:_offerCartPins({ id = "indexed_cart", title = "Indexed Cart",
base = "silver" })
check(satisfied._modConfirm == nil, "so it does not prompt")
LauncherMods.list = realList
end
-- a download that is not a cart at all fails loudly instead of installing
ModUpdate.pumpDownloadZip = function(h)
love.filesystem.write(h.name, "not a cart at all")
return true, h.name
end
find.findNotice = nil
find:_findInstall(entry)
find:_pumpCartInstall()
check(find._cartInstall == nil, "a junk download ends the job")
check(find.findNotice ~= nil and not find.findNotice.ok,
"and says so rather than installing anything")
ModUpdate.beginDownloadZip, ModUpdate.pumpDownloadZip = realBegin, realPump
T.finish("cart launcher")
+94
View File
@@ -198,8 +198,102 @@ rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
rejects(function(c) c.load_order = { "rare_soda", "master_ball" } end,
"does not pin", "a load_order naming an unpinned mod")
rejects(function(c) c.mods[1].enabled = "no" end, "enabled must be",
"a string enabled flag")
rejects(function(c) c.mods[1].enabled = 0 end, "enabled must be",
"a numeric enabled flag")
rejects(function(c) c.options = "fast" end, "cart options must be a table",
"a non-table cart options block")
rejects(function(c) c.options = { [2] = 1 } end, "cart option keys",
"a numeric cart option key")
rejects(function(c) c.options = { textSpeed = { 1 } } end,
"must be a string, number or boolean", "a table cart option value")
T.eq(CartManifest.parse(nil), nil, "parse refuses a non-table")
-- ------- a pin the cart ships switched off
T.eq(cart.mods[1].enabled, nil, "a pin that says nothing carries no enabled flag")
T.eq(CartManifest.modEnabled(cart.mods[1]), true, "and defaults to enabled")
T.eq(CartManifest.modEnabled(nil), false, "modEnabled refuses a non-entry")
local offRaw = baseCart()
offRaw.mods[1].enabled = false
local offCart = CartManifest.parse(offRaw)
T.check(offCart ~= nil, "a pin switched off parses")
T.eq(offCart.mods[1].enabled, false, "and keeps the flag")
T.eq(CartManifest.modEnabled(offCart.mods[1]), false, "which reads back as off")
T.eq(offCart.mods[1].sha256, SHA, "a switched-off pin is pinned like any other")
T.eq(offCart.mods[1].options.flavour, "grape", "with its options captured")
T.same(CartManifest.decode(CartManifest.encode(offCart)), offCart,
"a switched-off pin survives the file round trip")
T.neq(CartManifest.hash(offCart), CartManifest.hash(cart),
"and is part of the cart hash")
local onRaw = baseCart()
onRaw.mods[1].enabled = true
T.eq(CartManifest.canonical(CartManifest.parse(onRaw)),
CartManifest.canonical(cart),
"an explicit enabled = true is the same cart as saying nothing")
-- ------- settings the cart ships
local optRaw = baseCart()
optRaw.options = { textSpeed = 1, animations = false, ruleset = "gen1_faithful" }
local optCart, optErr = CartManifest.parse(optRaw)
T.check(optCart ~= nil, "a cart that ships settings parses: " .. tostring(optErr))
T.eq(optCart.options.textSpeed, 1, "the shipped number survives")
T.eq(optCart.options.animations, false, "the shipped boolean survives")
T.eq(optCart.options.ruleset, "gen1_faithful", "the shipped string survives")
T.eq(cart.options, nil, "a cart that ships none carries no options table")
T.same(CartManifest.decode(CartManifest.encode(optCart)), optCart,
"shipped settings survive the file round trip")
T.neq(CartManifest.hash(optCart), CartManifest.hash(cart),
"and are part of the cart hash")
T.neq(optCart.options, optCart.mods[1].options,
"a cart's own settings are not a mod's")
-- ------- the sealed+ seal
local plusRaw = baseCart()
plusRaw.seal = "sealed+"
local plusCart, plusErr = CartManifest.parse(plusRaw)
T.check(plusCart ~= nil, "a sealed+ cart parses: " .. tostring(plusErr))
T.eq(plusCart.seal, "sealed+", "the seal survives")
T.same(CartManifest.decode(CartManifest.encode(plusCart)), plusCart,
"a sealed+ cart round trips through a file")
T.neq(CartManifest.hash(plusCart), CartManifest.hash(cart),
"and sealed+ is part of the cart hash")
T.eq(CartManifest.SEALS["sealed+"], true, "sealed+ is a known seal")
-- ------- the launcher fields the file round trip used to drop
local dressed = baseCart()
dressed.finish = "sparkle+holo"
dressed.speeds = { 1, 2 }
local dressedCart = CartManifest.parse(dressed)
local reread = CartManifest.decode(CartManifest.encode(dressedCart))
T.eq(reread.finish, "sparkle+holo", "a cart's finish survives the file round trip")
T.same(reread.speeds, { 1, 2 }, "and so does its speed ladder")
T.eq(CartManifest.hash(reread), CartManifest.hash(dressedCart),
"so an installed copy hashes the same as the one that was written")
-- ------- a cart that uses none of the above serializes exactly as it always did
T.eq(CartManifest.canonical(cart),
"[cart].6:author$3:Ren.4:base$3:red.6:engine$7:>=1.4.0.2:id$10:kanto_plus"
.. ".5:label$13:art/label.png.4:repo$14:ren/kanto-plus.4:seal$6:sealed"
.. ".5:shell$7:#3fa9f5.7:summary$20:A sealed set of five.5:title$10:Kanto Plus"
.. ".7:version$5:1.2.0[mods]@9:rare_soda.2:id$9:rare_soda"
.. ".4:repo$13:ren/rare-soda.6:sha256$64:" .. SHA
.. ".6:source$6:github.7:version$5:0.4.1[options].5:fizzyT.7:flavour$5:grape"
.. ".9:sweetness#3@9:hard_mode.4:file#99123.2:id$9:hard_mode.3:md5$32:" .. MD5
.. ".3:mod#4821.6:source$10:gamebanana[options][order]@9:rare_soda@9:hard_mode",
"the canonical string of a cart using no new field is byte for byte the old one")
T.eq(CartManifest.hash(cart), "2a8fbbacbd1df3f349b6a6ed387fa036",
"so its hash, and every save stamped with it, is unchanged")
local open = baseCart()
open.seal = "open"
open.repo = nil
+84
View File
@@ -264,6 +264,90 @@ do
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
end
-- ------- cart-scoped settings
do
local files = fresh()
local base = SaveData.loadOptions()
base.textSpeed = 5
base.musicVol = 2
base.speedBattle = 1
T.check(SaveData.saveOptions(base), "the base game writes its own settings")
local plain = files["options.lua"]
SaveData.setCart("nuzlocke", "hash1")
local inCart = SaveData.loadOptions()
T.eq(inCart.textSpeed, 5, "a cart starts from the global value")
T.eq(inCart.musicVol, 2, "for every key, scoped or not")
inCart.textSpeed = 1
inCart.musicVol = 7
inCart.speedBattle = 4
T.check(SaveData.saveOptions(inCart), "change some settings inside the cart")
local stored = options(files)
T.eq(stored.textSpeed, 5, "the global text speed is untouched")
T.eq(stored.cartOptions.nuzlocke.textSpeed, 1, "the cart keeps its own")
T.eq(stored.cartOptions.nuzlocke.speedBattle, 4, "and its own battle speed")
T.eq(stored.musicVol, 7, "an unscoped setting is written globally")
T.eq(stored.cartOptions.nuzlocke.musicVol, nil, "and never lands in the cart")
T.eq(SaveData.loadOptions().textSpeed, 1, "the cart reads its value back")
T.eq(SaveData.loadOptions().musicVol, 7, "and the machine's shared volume")
SaveData.setCart("marathon", "hash2")
T.eq(SaveData.loadOptions().textSpeed, 5,
"another cart does not see the first cart's setting")
local other = SaveData.loadOptions()
other.textSpeed = 3
T.check(SaveData.saveOptions(other), "the second cart sets its own")
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
"which leaves the first cart's alone")
SaveData.setCart(nil)
T.eq(SaveData.loadOptions().textSpeed, 5, "and the base game keeps the global")
T.eq(SaveData.loadOptions().speedBattle, 1, "for every scoped key")
T.check(plain:find("cartOptions", 1, true) ~= nil,
"the options file declares the overlay")
T.check(plain:find("nuzlocke", 1, true) == nil,
"but an install that never ran a cart names no cart in it")
SaveData.setCart(nil)
local vanilla = SaveData.loadOptions()
vanilla.textSpeed = 4
T.check(SaveData.saveOptions(vanilla), "a base-game write after playing carts")
T.eq(options(files).cartOptions.nuzlocke.textSpeed, 1,
"leaves every cart's overlay intact")
T.eq(options(files).cartOptions.marathon.textSpeed, 3, "for every cart")
end
do
local files = fresh()
SaveData.setCart("author_cart", "hash3")
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false,
musicVol = 0, nonesuch = 3 }), true, "a cart seeds its shipped settings")
local seeded = SaveData.loadOptions()
T.eq(seeded.textSpeed, 1, "the author's text speed applies")
T.eq(seeded.animations, false, "and the author's battle animation choice")
T.eq(seeded.musicVol, 7, "a machine setting is never seeded")
T.eq(options(files).cartOptions.author_cart.musicVol, nil, "not even into the cart")
T.eq(options(files).cartOptions.author_cart.nonesuch, nil,
"and a key outside the cart-scoped set is ignored")
T.eq(options(files).cartOptionsSeeded.author_cart, true, "the seed is recorded")
local player = SaveData.loadOptions()
player.textSpeed = 5
T.check(SaveData.saveOptions(player), "the player then picks their own speed")
T.eq(SaveData.seedCartOptions({ textSpeed = 1, animations = false }), false,
"a second boot re-seeds nothing")
T.eq(SaveData.loadOptions().textSpeed, 5, "so the player's change survives")
SaveData.setCart(nil)
T.eq(SaveData.loadOptions().textSpeed, 3,
"and the base game still has the shipped default of its own")
T.eq(SaveData.seedCartOptions({ textSpeed = 1 }), false,
"seeding with no cart active does nothing")
end
love.filesystem = realFS
T.finish("cart_saves")
+171
View File
@@ -98,6 +98,12 @@ local function pin(id, version, options)
options = options }
end
local function offPin(id, version, options)
local p = pin(id, version, options)
p.enabled = false
return p
end
local function boot(files)
local data = { pokemon = {} }
local loader = Loader.new({ fs = memfs(files) })
@@ -175,6 +181,171 @@ do
T.eq(loader:cartStatus().enforced, false, "an open cart enforces nothing")
end
-- ------- a pin the cart ships switched off
local function withFlags(files, flags)
local opts = SaveSerializer.decode(files["options.lua"]) or {}
opts.mods = opts.mods or {}
for id, on in pairs(flags) do opts.mods[id] = on end
files["options.lua"] = SaveSerializer.encode(opts)
return files
end
local function withCartFlags(files, cartId, flags)
local opts = SaveSerializer.decode(files["options.lua"]) or {}
opts.cartMods = opts.cartMods or {}
opts.cartMods[cartId] = opts.cartMods[cartId] or {}
for id, on in pairs(flags) do opts.cartMods[cartId][id] = on end
files["options.lua"] = SaveSerializer.encode(opts)
return files
end
do
local files = install()
writeCart(files, cartTable("shipped_off", "sealed",
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
{ "alpha", "gamma" }))
SaveData.setCart("shipped_off", "hashA")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart that ships a mod switched off loads")
T.eq(names(loader.order), "alpha", "and runs only the pins it ships switched on")
T.eq(data.pokemon.GAMMA, nil, "the switched-off pin does not run")
T.eq(loader.mods.gamma.enabled, false, "and is reported as inactive")
T.eq(loader.mods.gamma.state, "disabled", "with the disabled row state")
T.check(loader.mods.gamma ~= nil, "while still being installed")
T.eq(loader:cartStatus().pins.gamma.options.tint, "cart",
"carrying the settings the cart shipped it with")
end
do
local files = withCartFlags(install(), "welded", { gamma = true })
writeCart(files, cartTable("welded", "sealed",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("welded", "hashB")
local loader, data, ok = boot(files)
T.check(ok, "a sealed cart loads with the player asking for the off pin")
T.eq(data.pokemon.GAMMA, nil, "but sealed refuses the toggle")
T.eq(loader.mods.gamma.enabled, false, "the pin stays exactly as the cart shipped it")
end
-- ------- sealed+ : the same fixed set, switchable
do
local files = withCartFlags(install(), "plus", { gamma = true })
writeCart(files, cartTable("plus", "sealed+",
{ pin("alpha"), offPin("gamma", "1.0.0", { tint = "cart" }) },
{ "alpha", "gamma" }))
SaveData.setCart("plus", "hashC")
local loader, data, ok = boot(files)
T.check(ok, "a sealed+ cart loads")
local report = loader:cartStatus()
T.eq(report.seal, "sealed+", "the report carries the new seal")
T.eq(report.sealed, true, "which is a sealed cart")
T.eq(report.enforced, true, "and is enforced like one")
T.eq(names(loader.order), "alpha,gamma",
"the player switched the cart's off pin on and it runs")
T.eq(data.pokemon.GAMMA.name, "cart",
"still with the cart's frozen option value, not the player's")
T.eq(data.pokemon.BETA, nil, "a mod the cart does not pin cannot be added")
T.eq(loader.mods.beta.enabled, false, "and stays switched off")
end
do
local files = withCartFlags(install(), "plus_off", { alpha = false })
writeCart(files, cartTable("plus_off", "sealed+",
{ pin("alpha"), pin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_off", "hashD")
local loader, data, ok = boot(files)
T.check(ok, "a sealed+ cart loads with a pin the player switched off")
T.eq(names(loader.order), "gamma", "which does not run")
T.eq(data.pokemon.ALPHA, nil, "so its content is absent")
T.eq(loader.mods.alpha.enabled, false, "and the row reads as off")
end
do
local files = install()
writeCart(files, cartTable("plus_gap", "sealed+",
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
SaveData.setCart("plus_gap", "hashE")
local loader, _, ok = boot(files)
T.check(not ok, "sealed+ still refuses a pin that is not installed")
T.eq(#loader.order, 0, "and plays no subset of itself")
T.eq(loader:cartStatus().refused, true, "the report refuses it")
end
do
local files = install()
love.filesystem = memfs(files)
SaveData.resetSlotState()
GameVersion.set("red")
writeCart(files, cartTable("plus_seal", "sealed+",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_seal", "hashF")
local slot = SaveData.createCartSlot("plus_seal")
SaveData.setActiveCartSlot("plus_seal", slot)
local loader = boot(files)
T.check(loader:setEnabled("gamma", true), "switch a sealed+ pin on")
T.eq(SaveData.isSealBroken(), false, "which does not break the session seal")
T.eq(SaveData.slotSealBroken("plus_seal", slot), false,
"nor mark the save slot modified")
T.eq(SaveData.adoptCartSeal("plus_seal"), false,
"so the next boot still adopts an intact seal")
local again, data, ok = boot(files)
T.check(ok, "and the cart loads again")
T.eq(names(again.order), "alpha,gamma", "with the mod the player switched on")
T.eq(data.pokemon.BETA, nil, "and still nothing the cart does not pin")
end
do
local files = withFlags(install(), { gamma = false })
writeCart(files, cartTable("plus_split", "sealed+",
{ pin("alpha"), offPin("gamma") }, { "alpha", "gamma" }))
SaveData.setCart("plus_split", "hashI")
local loader = boot(files)
T.eq(loader.mods.gamma.enabled, false, "a cart's off pin starts off")
T.check(loader:setEnabled("gamma", true), "and the player switches it on")
local opts = options(files)
T.eq(opts.cartMods.plus_split.gamma, true,
"the answer is stored under the cart that asked for it")
T.eq(SaveData.modEnabled(opts, "gamma", "red"), false,
"and the base game's own flag for that mod is untouched")
end
-- ------- an open cart hands back only the pins it ships switched off
do
local files = withCartFlags(install(), "open_off", { gamma = true })
writeCart(files, cartTable("open_off", "open",
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
SaveData.setCart("open_off", "hashG")
local loader, data, ok = boot(files)
T.check(ok, "an open cart with a switched-off pin loads")
T.check(data.pokemon.GAMMA ~= nil, "the player switched it on, so it runs")
T.eq(loader.mods.beta.enabled, true,
"while a pin the cart says nothing about is still forced on")
end
do
local files = install()
writeCart(files, cartTable("open_quiet", "open",
{ pin("beta"), offPin("gamma") }, { "beta", "gamma" }))
SaveData.setCart("open_quiet", "hashH")
local loader, data, ok = boot(files)
T.check(ok, "an open cart whose off pin the player never touched loads")
T.eq(data.pokemon.GAMMA, nil, "with that pin off by default")
T.eq(loader.mods.gamma.enabled, false, "and reported off")
end
-- ------- a missing pin: refusal when sealed, warning when open
do
+59 -19
View File
@@ -224,6 +224,33 @@ local empty = memfs()
T.eq(#CartStore.list(empty), 0, "a fresh install lists no carts")
T.eq(#CartStore.listFor("red", empty), 0, "listFor is empty on a fresh install")
-- ------- the fields the registry rows have to carry through the store
local dressedFs = memfs()
local dressed = select(2, bytesOf({ id = "dressed", title = "Dressed",
finish = "holo", speeds = { 1, 2 }, seal = "sealed+",
options = { textSpeed = 1 } }))
local dressedCart, dressedHash = CartStore.install(CartManifest.encode(dressed),
dressedFs)
T.check(dressedCart ~= nil, "a cart with a finish, speeds and settings installs")
T.eq(dressedHash, CartManifest.hash(dressed), "hashing the same as it was written")
T.same(CartStore.get("dressed", dressedFs), dressed,
"and coming back off disk unchanged")
local dressedReg = SaveData.loadOptions(dressedFs).carts.dressed
T.eq(dressedReg.finish, "holo", "the registry row carries the finish")
T.same(dressedReg.speeds, { 1, 2 }, "and the speed ladder")
T.eq(dressedReg.seal, "sealed+", "and the seal, spelled in full")
local dressedRow = CartStore.list(dressedFs)[1]
T.eq(dressedRow.finish, "holo", "the list row carries the finish")
T.same(dressedRow.speeds, { 1, 2 }, "and the speed ladder")
T.eq(dressedRow.seal, "sealed+", "and the seal")
T.eq(dressedRow.cart.options.textSpeed, 1,
"and the settings the cart ships, on the parsed cart")
T.eq(SaveData.loadOptions(dressedFs).carts.dressed.hash, dressedHash,
"listing does not rewrite the registry it already agrees with")
local function rowSet()
return {
{ id = "hard_mode", name = "Hard Mode", version = "2.0.0", enabled = true,
@@ -258,26 +285,33 @@ T.eq(captured.title, "My Cart", "the captured cart keeps the title")
T.eq(captured.shell, "#ff8800", "the captured shell normalises")
T.eq(captured.seal, "open", "the captured seal is the author's choice")
T.eq(captured.base, "red", "the captured base is the identity's")
T.eq(#captured.mods, 3, "only the enabled mods are pinned")
T.eq(#captured.mods, 4, "every installed mod is pinned, switched on or off")
T.eq(captured.load_order[1], "hard_mode", "load order follows the row order")
T.eq(captured.load_order[2], "rare_soda", "load order follows the row order")
T.eq(captured.load_order[3], "sprite_pack", "load order follows the row order")
for _, entry in ipairs(captured.mods) do
T.neq(entry.id, "off_mode", "a disabled mod is never pinned")
end
T.eq(captured.load_order[2], "off_mode", "including the row that is switched off")
T.eq(captured.load_order[3], "rare_soda", "load order follows the row order")
T.eq(captured.load_order[4], "sprite_pack", "load order follows the row order")
T.eq(captured.mods[1].source, "github", "a mod with repo, version and hash pins to github")
T.eq(captured.mods[1].repo, "ren/hard-mode", "the github pin keeps the repo")
T.eq(captured.mods[1].sha256, SHA, "the github pin keeps the recorded hash")
T.eq(captured.mods[2].source, "local", "a mod with no archive hash pins locally")
T.eq(captured.mods[2].version, "0.4.1", "the local pin keeps the installed version")
T.eq(captured.mods[2].repo, nil, "a local pin carries no repo")
T.eq(captured.mods[2].sha256, nil, "a local pin carries no hash")
T.eq(captured.mods[2].options.flavour, "grape", "the author's option values are frozen in")
T.eq(captured.mods[2].options.sweetness, 3, "every scalar option is frozen in")
T.eq(captured.mods[2].options.nested, nil, "a table option value is dropped")
T.eq(captured.mods[3].source, "local", "a mod with no repo pins locally")
T.eq(captured.mods[3].version, "0.0.0", "an unparsable version pins as 0.0.0")
T.eq(captured.mods[1].enabled, nil, "an enabled mod pins with no enabled field")
T.eq(CartManifest.modEnabled(captured.mods[1]), true, "and reads back as enabled")
T.eq(captured.mods[2].id, "off_mode", "the switched-off mod is pinned in place")
T.eq(captured.mods[2].enabled, false, "and is pinned switched off")
T.eq(CartManifest.modEnabled(captured.mods[2]), false, "which reads back as off")
T.eq(captured.mods[2].source, "github", "a disabled pin still resolves its source")
T.eq(captured.mods[2].sha256, SHA2, "and still carries its archive hash")
T.eq(captured.mods[2].options.unused, true,
"a disabled mod's options are captured like any other")
T.eq(captured.mods[3].source, "local", "a mod with no archive hash pins locally")
T.eq(captured.mods[3].version, "0.4.1", "the local pin keeps the installed version")
T.eq(captured.mods[3].repo, nil, "a local pin carries no repo")
T.eq(captured.mods[3].sha256, nil, "a local pin carries no hash")
T.eq(captured.mods[3].options.flavour, "grape", "the author's option values are frozen in")
T.eq(captured.mods[3].options.sweetness, 3, "every scalar option is frozen in")
T.eq(captured.mods[3].options.nested, nil, "a table option value is dropped")
T.eq(captured.mods[4].source, "local", "a mod with no repo pins locally")
T.eq(captured.mods[4].version, "0.0.0", "an unparsable version pins as 0.0.0")
T.eq(captured.mods[1].options, nil, "a mod with no options freezes none")
T.eq(#unresolved, 2, "capture reports every locally pinned mod")
@@ -309,12 +343,18 @@ pinned[4] = nil
local full, fullUnresolved = CartStore.capture(identity, pinned, modOptions)
T.check(full ~= nil, "a fully pinned capture builds a cart")
T.eq(#fullUnresolved, 0, "a fully pinned capture reports nothing unresolved")
T.eq(full.mods[2].source, "github", "a recorded hash promotes the pin to github")
T.eq(full.mods[2].sha256, SHA2, "the promoted pin uses the recorded hash")
T.eq(full.mods[3].source, "github", "a recorded hash promotes the pin to github")
T.eq(full.mods[3].sha256, SHA2, "the promoted pin uses the recorded hash")
T.eq(CartManifest.publishable(full), true, "a fully pinned cart is publishable")
local noMods, noModsErr = CartStore.capture(identity, { rowSet()[2] }, modOptions)
T.eq(noMods, nil, "a capture with nothing enabled is refused")
local offOnly = CartStore.capture(identity, { rowSet()[2] }, modOptions)
T.check(offOnly ~= nil, "a capture of nothing but switched-off mods still builds")
T.eq(#offOnly.mods, 1, "pinning the one mod it was given")
T.eq(offOnly.mods[1].enabled, false, "switched off")
T.eq(offOnly.mods[1].options.unused, true, "with its settings kept")
local noMods, noModsErr = CartStore.capture(identity, {}, modOptions)
T.eq(noMods, nil, "a capture with no mods at all is refused")
T.check(type(noModsErr) == "string" and noModsErr:find("cart must pin", 1, true) ~= nil,
"the empty capture says why (got " .. tostring(noModsErr) .. ")")
T.eq(CartStore.capture({ id = "bad id" }, rowSet(), modOptions), nil,
@@ -193,4 +193,31 @@ do
eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu")
end
-- A cart may narrow the ladder (CartManifest's `speeds`), and returning to
-- the launcher must put it back.
do
local GameSpeed = require("src.core.GameSpeed")
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS, "no cart means the full ladder")
check(not GameSpeed.isLocked(), "and nothing is pinned")
GameSpeed.setAllowed({ 1, 2 })
eq(#GameSpeed.allowed(), 2, "a cart narrows the ladder")
eq(GameSpeed.cycle(1, 1), 2, "cycling stays inside the cart's levels")
eq(GameSpeed.cycle(2, 1), 1, "and wraps within them")
eq(GameSpeed.clamp(100), 2, "a value past the cart's top clamps into it")
check(not GameSpeed.isLocked(), "two levels is narrowed, not pinned")
GameSpeed.setAllowed({ 1 })
check(GameSpeed.isLocked(), "one level reads as pinned")
eq(GameSpeed.cycle(1, 1), 1, "and cycling cannot leave it")
GameSpeed.setAllowed({ 1, 7 })
eq(#GameSpeed.allowed(), 1, "a level that is not on the ladder is dropped")
GameSpeed.setAllowed(nil)
eq(#GameSpeed.allowed(), #GameSpeed.LEVELS,
"leaving the cart restores the full ladder")
eq(GameSpeed.cycle(4, 1), 10, "and cycling reaches the levels again")
end
T.finish("game_speed_categories")
+16 -1
View File
@@ -21,6 +21,21 @@ for p = 1, 3 do
end
end
-- gfx/stats/stats.pal, the colour LoadStatsScreenPals drops into colour 0 of
-- BG palettes 0 and 2 (engine/gfx/color.asm:386-390)
local ROM_TINTS = { { 31, 19, 31 }, { 21, 31, 14 }, { 17, 31, 31 } }
for p = 1, 3 do
for ch = 1, 3 do
T.eq(SummaryMenu.PAGE_TINTS[p][ch], up(ROM_TINTS[p][ch]),
("stats.pal tint %d channel %d"):format(p, ch))
end
local page = setmetatable({ page = p }, { __index = SummaryMenu })
local lower = page:lowerColors()
T.eq(lower[1][1], SummaryMenu.PAGE_TINTS[p][1],
("lower half palette %d takes the page tint as colour 0"):format(p))
T.eq(lower[4][1], 0, ("lower half palette %d keeps black ink"):format(p))
end
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
love.graphics = love.graphics or {}
local realNewQuad = love.graphics.newQuad
@@ -48,4 +63,4 @@ local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
love.graphics.newQuad = realNewQuad
T.finish("gen2 stats tiles and page palettes (#1558)")
T.finish("gen2 stats tiles and page palettes (#1558, #1693)")
+28
View File
@@ -265,4 +265,32 @@ check(update and update:find("_pumpSkinFetch", 1, true) ~= nil,
check(TouchSkin.ARCHIVE_EXTS.deltaskin == true,
"and the installer accepts the extension")
-- Turning skins off must leave the pad on. `enabled` is the pad's own switch,
-- and the button that calls this only exists while a skin is active, which
-- already requires the pad to be enabled.
do
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local opts = SaveData.loadOptions()
opts.touchControls = { enabled = true, skin = "some_skin" }
SaveData.saveOptions(opts)
local imp = RomImporter.new(function() end, { launcher = true })
imp:_disableSkins()
local after = SaveData.loadOptions().touchControls
eq(after.skin, nil, "turning skins off clears the selected skin")
check(after.enabled ~= false, "and leaves the touch pad enabled")
local cfg = TouchControls.normalizeConfig(after)
check(cfg.enabled, "so the pad is on after normalizing")
eq(cfg.skin, nil, "with no skin behind it")
check(imp._skinNotice ~= nil and imp._skinNotice.ok == true,
"and the notice reports success")
check(tostring(imp._skinNotice.text):find("built-in pad", 1, true) ~= nil,
"promising the built-in pad, which is now what happens")
end
T.finish("launcher_skins_ux")
+148
View File
@@ -385,4 +385,152 @@ do
eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order")
end
-- ------- carts: a second array on the same feed, at the same schema_version
--
-- The published index added carts without bumping schema_version, so an older
-- build has to keep reading the feed and this one has to read both halves.
-- The fixture below is the shape carts/<Author>@<id>/meta.json validates to.
local OMEGA = {
folder = "bryanthaboi@omega_random_competition",
id = "omega_random_competition",
title = "OMEGA RANDOM COMPETITION",
author = "bryanthaboi",
summary = "Bois Club Randomizer as its own cartridge.",
version = "1.0.0",
base = "red",
seal = "sealed",
shell = "#7B1B22",
finish = "holo",
speeds = { 1, 2 },
tags = { "randomizer", "competition" },
repo = "https://github.com/bryanthaboi/omega-random-competition",
github = "bryanthaboi/omega-random-competition",
automatic_version_check = true,
mods = { { id = "bcr", source = "github", repo = "bryanthaboi/bcr",
version = "1.0.0", sha256 = ("83a111b4"):rep(8) } },
load_order = { "bcr" },
license = "MIT",
description_url = "data/carts/bryanthaboi@omega_random_competition/description.md",
update_check = "pending",
}
local JOHTO_CART = {
id = "johto_run", title = "Johto Run", author = "Ren", version = "2.1.0",
base = "gold", seal = "open",
repo = "https://github.com/ren/johto-run",
github = "ren/johto-run",
mods = { { id = "steps", source = "gamebanana", mod = 42, file = 99,
md5 = ("ab"):rep(16), enabled = false,
options = { pace = "fast" } } },
update_check = "ok",
latest = { version = "2.1.0", tag = "v2.1.0",
zip = { name = "johto_run-2.1.0.zip",
url = "https://example.test/johto_run-2.1.0.zip" } },
}
local function cartFeed(carts, overrides)
local doc = { schema_version = 1, count = 1, cart_count = #carts,
categories = { "GAMEPLAY" },
base_games = { "red", "blue", "yellow", "gold", "silver" },
mods = { NUZLOCKE }, carts = carts }
for k, v in pairs(overrides or {}) do doc[k] = v end
return Json.encode(doc)
end
do
local index, err = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
check(index ~= nil, "a feed carrying carts parses: " .. tostring(err))
eq(index.schemaVersion, 1, "carts arrive at schema_version 1, unbumped")
eq(#index.mods, 1, "the mods array still parses")
eq(#index.carts, 2, "and the carts array parses beside it")
local c = index.carts[1]
eq(c.id, "omega_random_competition", "cart id")
eq(c.title, "OMEGA RANDOM COMPETITION", "cart title")
eq(c.base, "red", "the game the cart plays as")
eq(c.seal, "sealed", "its seal")
eq(c.shell, "#7B1B22", "its shell colour")
eq(c.finish, "holo", "its finish")
eq(c.speeds[2], 2, "its speed ladder")
eq(c.tags[1], "randomizer", "its tags")
eq(c.license, "MIT", "its license")
eq(c.load_order[1], "bcr", "its load order")
eq(#c.mods, 1, "its pinned mod set")
eq(c.mods[1].source, "github", "a github pin keeps its source")
eq(c.mods[1].repo, "bryanthaboi/bcr", "with the repo it comes from")
eq(c.mods[1].version, "1.0.0", "the exact pinned version")
eq(#c.mods[1].sha256, 64, "and the digest that gates it")
check(ModIndex.isCart(c), "a parsed cart is marked as one")
check(not ModIndex.isCart(index.mods[1]), "a mod is not")
local g = index.carts[2]
eq(g.mods[1].source, "gamebanana", "a gamebanana pin keeps its source")
eq(g.mods[1].mod, 42, "with its mod page id")
eq(g.mods[1].file, 99, "and its file id")
eq(#g.mods[1].md5, 32, "and the digest GameBanana reports")
eq(g.mods[1].enabled, false, "a pin shipped switched off stays off")
eq(g.mods[1].options.pace, "fast", "frozen options survive")
eq(ModIndex.installUrl(g), "https://example.test/johto_run-2.1.0.zip",
"a cart resolves its release asset the same way a mod does")
end
-- the old-feed case: no carts key at all
do
local index, err = ModIndex.parse(feed({ NUZLOCKE }))
check(index ~= nil, "a feed with no carts key still parses: " .. tostring(err))
eq(#index.mods, 1, "its mods are unaffected")
eq(type(index.carts), "table", "and carts is a list, never nil")
eq(#index.carts, 0, "an absent carts array is an empty one")
end
-- a broken cart row costs itself, not the whole feed
do
local noBase = {}
for k, v in pairs(OMEGA) do noBase[k] = v end
noBase.base = nil
local noMods = {}
for k, v in pairs(JOHTO_CART) do noMods[k] = v end
noMods.id, noMods.mods = "empty_pins", {}
local index, err = ModIndex.parse(cartFeed({
noBase, "not even an object", { id = "bare" }, noMods, OMEGA }))
check(index ~= nil, "a feed with malformed carts still parses: " .. tostring(err))
eq(#index.carts, 1, "only the well-formed cart is listed")
eq(index.carts[1].id, "omega_random_competition", "and it is the intact one")
eq(#index.mods, 1, "the mods array is untouched by a bad cart")
end
-- search and filter: matches() already spans title / author / summary / id,
-- so the cart half reuses it wholesale. The category filter does not apply
-- (a cart has none); base does.
do
local index = ModIndex.parse(cartFeed({ OMEGA, JOHTO_CART }))
local carts = index.carts
eq(#ModIndex.filter(carts, {}), 2, "no filter keeps every cart")
eq(ModIndex.filter(carts, { query = "omega" })[1].id,
"omega_random_competition", "search matches a cart by title")
eq(ModIndex.filter(carts, { query = "Ren" })[1].id, "johto_run",
"search matches a cart by author")
eq(ModIndex.filter(carts, { query = "randomizer" })[1].id,
"omega_random_competition", "and by summary")
eq(#ModIndex.filter(carts, { query = "omega johto" }), 0,
"cart search terms are ANDed too")
eq(ModIndex.filter(carts, { base = "gold" })[1].id, "johto_run",
"filtering by base game keeps the carts for that game")
eq(#ModIndex.filter(carts, { base = "RED" }), 1,
"and compares case-insensitively")
eq(#ModIndex.filter(carts, { base = "silver" }), 0,
"a base no cart plays as filters everything out")
eq(#ModIndex.filter(carts, { category = "GAMEPLAY" }), 0,
"a cart carries no categories, so a category filter never matches one")
eq(ModIndex.filter(carts, { tag = "competition" })[1].id,
"omega_random_competition", "cart tags filter")
local bases = ModIndex.baseGamesIn(index)
eq(#bases, 2, "only base games a cart actually plays as are offered")
eq(bases[1], "red", "and they keep the feed's declared base_games order")
eq(bases[2], "gold", "in that order")
eq(#ModIndex.baseGamesIn(ModIndex.parse(feed({ NUZLOCKE }))), 0,
"a cartless feed offers no base games")
end
print("ok mod_index_tests")
@@ -0,0 +1,222 @@
-- The Pewter museum ticket clerk dispatches on the player's coordinates
-- before it looks at the ticket flag (#1690). scripts/Museum1F.asm:45
-- reads wYCoord/wXCoord first: (13,4) and (12,3) are behind the counter
-- (the AMBER question), Y==4 otherwise is the ticket path, and anything
-- else is the "go to the other side" brush-off. The port only ever had
-- the middle branch, so two thirds of the NPC never fired.
--
-- tests/engine/museum_money_box_bug1335.lua and museum_1f_clerk_
-- translation_test.lua both call the clerk with ow == nil, which stays on
-- the ticket path either way, so neither can see the coordinate branches.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/story2.lua"))()
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
-- data/maps/objects/Museum1F.asm: MUSEUM1F_SCIENTIST1 stands at (12,4)
-- with the counter column at x==11. (13,4) is the cell east of him and
-- (12,3) the cell north of him, both reachable only from the back door;
-- (10,4) talks to him across the counter from the public side.
local BEHIND = { { 13, 4 }, { 12, 3 } }
local FRONT = { 10, 4 }
local pushed
local function mkGame(cash)
pushed = {}
return {
data = { text = {} },
save = { money = cash, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
local function mkOw(x, y)
return { player = { cellX = x, cellY = y } }
end
-- nil-tolerant: a branch that pushes nothing at all is a failure to
-- report, not a crash that hides every check after it
local function has(box, needle)
return box ~= nil and tostring(box.text):find(needle, 1, true) ~= nil
end
-- answering a box that never offered a choice is the failure mode itself,
-- so report it rather than dying on a nil index and hiding the rest
local function answer(box, yes)
if box and box.opts and box.opts.choice then box.opts.choice(yes) end
end
-- ------------------------------------------------ behind the counter
for _, cell in ipairs(BEHIND) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local g = mkGame(3000)
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
local ask = pushed[1]
T.check(has(ask, "sneak"),
where .. " opens the can't-sneak-in-the-back-way box")
T.check(ask.opts ~= nil and ask.opts.choice ~= nil,
where .. " carries the AMBER yes/no choice")
T.check(ask.opts.money == nil,
where .. " raises no money window behind the AMBER question")
T.check(ask.onDone == nil, where .. " chains through the choice, not onDone")
-- YES: .TheresALabSomewhereText
answer(ask, true)
T.check(has(pushed[2], "lab"), where .. " YES gives the resurrection lab line")
-- NO: .AmberIsFossilizedTreeSapText
g = mkGame(3000)
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
answer(pushed[1], false)
T.check(has(pushed[2], "tree sap"), where .. " NO explains AMBER is tree sap")
T.eq(g.save.money, 3000, where .. " never charges the player")
T.check(not g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET,
where .. " never hands out a ticket")
end
-- the coordinate check runs BEFORE the ticket check, so a ticket holder
-- who wanders round the back still gets told off (asm:45 precedes asm:59)
for _, cell in ipairs(BEHIND) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local g = mkGame(3000)
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, mkOw(cell[1], cell[2]), nil, function() end)
T.check(has(pushed[1], "sneak"),
where .. " with a ticket is still the back-way box, not take-your-time")
answer(pushed[1], false)
T.check(has(pushed[2], "tree sap"),
where .. " with a ticket still answers NO with the tree sap line")
end
-- the done callback reaches the second box on both answers
local g = mkGame(3000)
local doneFired = false
clerk(g, mkOw(13, 4), nil, function() doneFired = true end)
answer(pushed[1], true)
T.check(pushed[2].onDone ~= nil, "the AMBER answer box carries the done callback")
pushed[2].onDone()
T.check(doneFired, "and it chains back out of the conversation")
-- ------------------------------------------------ the brush-off
-- scripts/Museum1F.asm:58: no ticket and not on row 4 means "other side"
local OFF_ROW = { { 12, 5 }, { 13, 3 }, { 12, 2 }, { 11, 3 } }
for _, cell in ipairs(OFF_ROW) do
local where = ("(%d,%d)"):format(cell[1], cell[2])
local gg = mkGame(3000)
clerk(gg, mkOw(cell[1], cell[2]), nil, function() end)
T.check(has(pushed[1], "other side"), where .. " gets the brush-off line")
T.check(pushed[1].opts == nil, where .. " raises no money window")
T.eq(gg.save.money, 3000, where .. " never charges the player")
end
-- with a ticket, off-row talk falls through to take-your-time instead
-- (asm:59 CheckEvent gates the brush-off)
local g2 = mkGame(3000)
g2.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g2, mkOw(12, 5), nil, function() end)
T.check(has(pushed[1], "Take your time"),
"(12,5) with a ticket gets take-your-time, not the brush-off")
-- ------------------------------------------------ the ticket path holds
-- across the counter from the public side is row 4: the money box
local g3 = mkGame(3000)
clerk(g3, mkOw(FRONT[1], FRONT[2]), nil, function() end)
T.check(has(pushed[1], "50"), "(10,4) still opens the child's ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
"and the ask still raises the money window")
answer(pushed[1], true)
T.eq(g3.save.money, 2950, "buying from the front still costs 50")
T.check(g3.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "and still sets the ticket flag")
local g4 = mkGame(3000)
g4.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g4, mkOw(FRONT[1], FRONT[2]), nil, function() end)
T.check(has(pushed[1], "Take your time"),
"(10,4) with a ticket still gets take-your-time")
T.check(pushed[1].opts == nil, "and no money window on that branch")
-- a caller with no overworld (the two older suites, and any script that
-- talks to the clerk out of band) must keep the pre-#1690 ticket path
local g5 = mkGame(3000)
clerk(g5, nil, nil, function() end)
T.check(has(pushed[1], "50"), "ow == nil still opens the ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
"ow == nil still raises the money window")
-- ------------------------------------------------ the rope onStep path
-- Museum1FDefaultScript calls the clerk over when the player steps onto
-- (9,4) or (10,4); OverworldController passes the player's own cell, so
-- the brush-off must not swallow the ask there
for _, x in ipairs({ 9, 10 }) do
local where = ("(%d,4)"):format(x)
local g6 = mkGame(3000)
local moved = false
local ow = mkOw(x, 4)
ow.scriptMove = function() moved = true end
local handled = M.MUSEUM_1F.onStep(g6, ow, x, 4)
T.check(handled, where .. " on the rope is handled by onStep")
T.check(has(pushed[1], "50"), where .. " on the rope opens the ticket ask")
T.check(pushed[1].opts ~= nil and pushed[1].opts.money ~= nil,
where .. " on the rope keeps the money window")
answer(pushed[1], false)
T.check(has(pushed[2], "Come again"), where .. " declining says come again")
pushed[2].onDone()
T.check(moved, where .. " declining still shoves the player back south (#151)")
end
-- a ticket holder walking the rope is not stopped at all
local g7 = mkGame(3000)
g7.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
local ow7 = mkOw(9, 4)
ow7.scriptMove = function() end
T.check(not M.MUSEUM_1F.onStep(g7, ow7, 9, 4),
"a ticket holder crosses the rope without being stopped")
T.eq(#pushed, 0, "and no box is pushed")
-- ------------------------------------------------ translation reaches it
-- the two new branches must read game.data.text, not bare literals
local TRANSLATED = {
_Museum1FScientist1DoYouKnowWhatAmberIsText = "Pas par derriere !\nL'AMBRE, tu connais ?",
_Museum1FScientist1TheresALabSomewhereText = "Un labo essaie de\nles ressusciter.",
_Museum1FScientist1AmberIsFossilizedTreeSapText = "C'est de la resine\nfossilisee.",
_Museum1FScientist1GoToOtherSideText = "Passe de l'autre\ncote !",
}
local function mkTranslated()
pushed = {}
return {
data = { text = TRANSLATED },
save = { money = 3000, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
local g8 = mkTranslated()
clerk(g8, mkOw(13, 4), nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1DoYouKnowWhatAmberIsText,
"the back-way box uses the translated AMBER question")
answer(pushed[1], true)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1TheresALabSomewhereText,
"YES uses the translated lab line")
g8 = mkTranslated()
clerk(g8, mkOw(12, 3), nil, function() end)
answer(pushed[1], false)
T.eq(pushed[2].text, TRANSLATED._Museum1FScientist1AmberIsFossilizedTreeSapText,
"NO uses the translated tree sap line")
g8 = mkTranslated()
clerk(g8, mkOw(12, 5), nil, function() end)
T.eq(pushed[1].text, TRANSLATED._Museum1FScientist1GoToOtherSideText,
"the brush-off uses the translated other-side line")
T.finish("museum_clerk_back_entrance_bug1690")
+6 -4
View File
@@ -84,12 +84,14 @@ T.eq(Performance.label(nil), "AUTO", "label nil -> AUTO")
-- -------------------------------------------------------------------- caps
local hi, lo = Performance.CAPS.high, Performance.CAPS.low
T.check(hi.tilt and hi.gbcfx and hi.survey and not hi.fpsMax, "high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.gbcfx) and (not lo.survey), "low caps: heavy extras off")
T.check(hi.tilt and hi.survey and hi.shaderfx and not hi.fpsMax,
"high caps: all on, no fps ceiling")
T.check((not lo.tilt) and (not lo.survey) and (not lo.shaderfx),
"low caps: heavy extras off")
T.eq(lo.fpsMax, 60, "low caps: FPS ceiling of 60")
local bal = Performance.CAPS.balanced
T.check((not bal.tilt) and (not bal.gbcfx) and bal.survey and not bal.fpsMax,
"balanced caps: no tilt/gbcfx, survey kept, no fps ceiling")
T.check((not bal.tilt) and bal.survey and (not bal.shaderfx) and not bal.fpsMax,
"balanced caps: no tilt, no shaderfx, survey kept, no fps ceiling")
device("Linux", "arm64", 4)
T.eq(Performance.caps("auto"), Performance.CAPS.low, "caps(auto) resolves through detect")