mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user