mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 00:48:32 +02:00
Merge upstream dev into dataset view API
This commit is contained in:
@@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper,
|
||||
local mainFile = assert(io.open("main.lua", "rb"))
|
||||
local main = mainFile:read("*a")
|
||||
mainFile:close()
|
||||
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||
"returning from a game disables mod-owned secondary output")
|
||||
check(main:find("SessionLifecycle.endGameSession", 1, true),
|
||||
"returning from a game goes through SessionLifecycle.endGameSession")
|
||||
local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb"))
|
||||
local lifecycle = lifecycleFile:read("*a")
|
||||
lifecycleFile:close()
|
||||
check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||
"endGameSession disables mod-owned secondary output")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
|
||||
@@ -41,7 +41,7 @@ end
|
||||
-- Mock isReady
|
||||
RomImporter.isReady = function(v)
|
||||
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
|
||||
or v == "silver"
|
||||
or v == "silver" or v == "crystal"
|
||||
end
|
||||
|
||||
local ok = RomImporter.syncAndroidShortcuts("gold")
|
||||
@@ -57,6 +57,14 @@ RomImporter.syncAndroidShortcuts("silver")
|
||||
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
|
||||
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
|
||||
|
||||
capturedShortcuts = nil
|
||||
RomImporter.syncAndroidShortcuts("crystal")
|
||||
check(#capturedShortcuts == 4, "nor does a sixth")
|
||||
check(capturedShortcuts[1] == "crystal", "activeVersion 'crystal' is placed first")
|
||||
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
|
||||
and capturedShortcuts[4] == "yellow",
|
||||
"and the rest still follow GameVersion.ORDER until the cap")
|
||||
|
||||
-- Test with subset of ready games (e.g. only Red and Gold)
|
||||
RomImporter.isReady = function(v)
|
||||
return v == "red" or v == "gold"
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,336 @@
|
||||
-- Validation and fail-closed behavior for battle.field_residual descriptors.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Events = require("src.mods.Events")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local data = T.fixtures.fresh()
|
||||
TypeChart.load(data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||
local game = {
|
||||
data = data,
|
||||
save = save,
|
||||
stack = { top = function() return nil end, push = function() end },
|
||||
}
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
return battle
|
||||
end
|
||||
|
||||
local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks,
|
||||
Runtime.errors
|
||||
local hooks = Hooks.new()
|
||||
Runtime.install(savedEvents, hooks, {})
|
||||
|
||||
local battle = newBattle()
|
||||
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
|
||||
local playerType = battle.player.curTypes[1]
|
||||
local cyclic = { label = "cycle-data" }
|
||||
cyclic.self = cyclic
|
||||
local metatableData = setmetatable({ label = "plain-data" }, {
|
||||
__index = { hidden = "metatable-data" },
|
||||
})
|
||||
battle.field.weather = {
|
||||
id = "probe", turns = 3,
|
||||
callback = function() end,
|
||||
handle = io.stdout,
|
||||
worker = coroutine.create(function() end),
|
||||
cyclic = cyclic,
|
||||
metatableData = metatableData,
|
||||
}
|
||||
battle.field.tokens[1] = { id = "nested", turns = 2,
|
||||
state = { intensity = 4 } }
|
||||
battle:enter()
|
||||
hooks:wrap("battle.field_residual", function(next, context)
|
||||
local vanilla = next(context)
|
||||
T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list")
|
||||
context.battlers.player.hp = 0
|
||||
context.battlers.player.types[1] = "MUTATED"
|
||||
T.eq(context.field.sides, nil,
|
||||
"the field view has the checkpoint shape and no live side graph")
|
||||
T.eq(context.field.weather.callback, nil,
|
||||
"the field view omits executable values")
|
||||
T.eq(context.field.weather.handle, nil,
|
||||
"the field view omits userdata")
|
||||
T.eq(context.field.weather.worker, nil,
|
||||
"the field view omits threads")
|
||||
T.eq(context.field.weather.cyclic.label, "cycle-data",
|
||||
"the field view retains scalar data around a cycle")
|
||||
T.eq(context.field.weather.cyclic.self, nil,
|
||||
"the field view omits cyclic edges")
|
||||
T.eq(getmetatable(context.field.weather.metatableData), nil,
|
||||
"the field view carries no metatable")
|
||||
T.eq(context.field.weather.metatableData.label, "plain-data",
|
||||
"the field view retains raw data from a metatable-bearing table")
|
||||
T.eq(context.field.weather.metatableData.hidden, nil,
|
||||
"the field view does not expose metatable-provided values")
|
||||
context.field.weather.turns = 0
|
||||
context.field.tokens[1].state.intensity = 99
|
||||
return {
|
||||
false,
|
||||
{ side = "unknown", amount = 20, message = "invalid side" },
|
||||
{ side = "player", amount = "3", message = "numeric string" },
|
||||
{ side = "player", amount = 0, message = "zero" },
|
||||
{ side = "player", amount = -2, message = "negative" },
|
||||
{ side = "player", amount = 1.5, message = "fractional" },
|
||||
{ side = "player", amount = "not a number", message = "bad amount" },
|
||||
{ side = "player", amount = 0 / 0, message = "not finite" },
|
||||
{ side = "player", amount = math.huge, message = "non-finite" },
|
||||
{ side = "player", amount = 2, message = function() end },
|
||||
{ side = "enemy", amount = 3 },
|
||||
}
|
||||
end, 0, "validation_probe")
|
||||
|
||||
battle:applyFieldResiduals()
|
||||
T.eq(battle.player.mon.hp, playerHp,
|
||||
"a descriptor with a non-string message fails closed")
|
||||
T.eq(battle.enemy.mon.hp, enemyHp - 3,
|
||||
"a valid descriptor may omit its message")
|
||||
T.eq(battle.player.curTypes[1], playerType,
|
||||
"mutating the detached type view cannot mutate the live battler")
|
||||
T.check(battle.player.mon.hp ~= 0,
|
||||
"mutating detached HP cannot replace engine damage authority")
|
||||
T.eq(battle.field.weather.turns, 3,
|
||||
"mutating the detached weather view cannot mutate live field state")
|
||||
T.eq(battle.field.tokens[1].state.intensity, 4,
|
||||
"mutating nested detached token state cannot mutate live field state")
|
||||
|
||||
local guarded = newBattle()
|
||||
local fieldReads, runtimeCalls = 0, 0
|
||||
guarded.field = setmetatable({}, { __index = function()
|
||||
fieldReads = fieldReads + 1
|
||||
return nil
|
||||
end })
|
||||
local realRuntimeCall = Runtime.call
|
||||
Runtime.call = function(...)
|
||||
runtimeCalls = runtimeCalls + 1
|
||||
return realRuntimeCall(...)
|
||||
end
|
||||
Runtime.install(savedEvents, Hooks.new(), {})
|
||||
guarded:applyFieldResiduals()
|
||||
Runtime.call = realRuntimeCall
|
||||
T.eq(runtimeCalls, 0,
|
||||
"a disabled field hook never enters Runtime.call")
|
||||
T.eq(fieldReads, 0,
|
||||
"a disabled field hook does not construct its field context")
|
||||
|
||||
local nilBattle = newBattle()
|
||||
local nilHp = nilBattle.player.mon.hp
|
||||
local nilHooks = Hooks.new()
|
||||
Runtime.install(savedEvents, nilHooks, {})
|
||||
nilHooks:wrap("battle.field_residual", function() return nil end,
|
||||
0, "nil_probe")
|
||||
nilBattle:applyFieldResiduals()
|
||||
T.eq(nilBattle.player.mon.hp, nilHp,
|
||||
"a non-table hook result fails closed")
|
||||
|
||||
local settled = newBattle()
|
||||
local settledCalls = 0
|
||||
local settledHooks = Hooks.new()
|
||||
Runtime.install(savedEvents, settledHooks, {})
|
||||
settledHooks:wrap("battle.field_residual", function(next, context)
|
||||
settledCalls = settledCalls + 1
|
||||
return next(context)
|
||||
end, 0, "settled_probe")
|
||||
settled.result = "win"
|
||||
settled:endOfTurn()
|
||||
T.eq(settledCalls, 0,
|
||||
"a settled battle never invokes field residual policy")
|
||||
|
||||
local function drainQueue(battle)
|
||||
local rows, guard = {}, 0
|
||||
while battle.queue[1] and guard < 1000 do
|
||||
guard = guard + 1
|
||||
local row = table.remove(battle.queue, 1)
|
||||
rows[#rows + 1] = row
|
||||
if row.fn then
|
||||
battle.nextInsert = 0
|
||||
row.fn()
|
||||
end
|
||||
end
|
||||
T.check(guard < 1000, "the simultaneous-faint queue completes")
|
||||
return rows
|
||||
end
|
||||
|
||||
local function simultaneousTerminal(order)
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||
local game = {
|
||||
data = data,
|
||||
save = save,
|
||||
stack = { top = function() return nil end, push = function() end },
|
||||
}
|
||||
local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
double.phase, double.queue = "menu", {}
|
||||
local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex
|
||||
local startingExp = double.player.mon.exp
|
||||
local events, doubleHooks = Events.new(), Hooks.new()
|
||||
local awardCalls, expEvents, switches = 0, 0, 0
|
||||
events:on("battle.exp_gained", function() expEvents = expEvents + 1 end,
|
||||
0, "double_probe")
|
||||
events:on("battle.battler_switched", function() switches = switches + 1 end,
|
||||
0, "double_probe")
|
||||
Runtime.install(events, doubleHooks, {})
|
||||
doubleHooks:wrap("battle.field_residual", function(next, context)
|
||||
local rows = next(context)
|
||||
for _, side in ipairs(order) do
|
||||
rows[#rows + 1] = {
|
||||
side = side,
|
||||
amount = context.battlers[side].hp,
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end, 0, "double_probe")
|
||||
doubleHooks:wrap("battle.exp_award", function(next, context)
|
||||
awardCalls = awardCalls + 1
|
||||
return next(context)
|
||||
end, 0, "double_probe")
|
||||
double:endOfTurn()
|
||||
local queued = drainQueue(double)
|
||||
T.eq(double.player.mon.hp, 0,
|
||||
"simultaneous residuals settle the player side")
|
||||
T.eq(double.enemy.mon.hp, 0,
|
||||
"simultaneous residuals settle the enemy side")
|
||||
T.eq(double.result, "lose",
|
||||
"a simultaneous terminal residual resolves as player blackout")
|
||||
T.eq(double.afterQueue, "finish",
|
||||
"the completed simultaneous-faint queue closes the battle")
|
||||
T.eq(double.player.faintQueued, true,
|
||||
"the terminal hook batch queues player faint authority")
|
||||
T.eq(double.enemy.faintQueued, nil,
|
||||
"the terminal hook batch suppresses only its enemy faint authority")
|
||||
T.eq(double.player.mon.exp, startingExp,
|
||||
"a blackout does not award contradictory enemy-faint EXP")
|
||||
T.eq(awardCalls, 0,
|
||||
"a blackout never enters the enemy EXP-award policy")
|
||||
T.eq(expEvents, 0,
|
||||
"a blackout emits no contradictory EXP event")
|
||||
T.eq(switches, 0,
|
||||
"a blackout does not send the trainer's reserve into battle")
|
||||
T.eq(double.enemyIndex, originalEnemyIndex,
|
||||
"a blackout leaves the enemy roster position unchanged")
|
||||
T.check(double.enemy == originalEnemy,
|
||||
"a blackout queues no contradictory enemy replacement")
|
||||
for _, row in ipairs(queued) do
|
||||
T.eq(row.ui, nil,
|
||||
"a simultaneous terminal residual queues no replacement UI")
|
||||
end
|
||||
end
|
||||
|
||||
simultaneousTerminal({ "player", "enemy" })
|
||||
simultaneousTerminal({ "enemy", "player" })
|
||||
|
||||
local timing = newBattle()
|
||||
local timingOrder = {}
|
||||
timing.ruleset = require("src.battle.rulesets.modern_clean")
|
||||
timing.player.mon.status = "PSN"
|
||||
timing.field.tokens[1] = { id = "expires", turns = 1,
|
||||
onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end }
|
||||
local timingEvents, timingHooks = Events.new(), Hooks.new()
|
||||
timingEvents:on("battle.turn_ended", function()
|
||||
timingOrder[#timingOrder + 1] = "turn_ended"
|
||||
end, 0, "timing_probe")
|
||||
Runtime.install(timingEvents, timingHooks, {})
|
||||
local preStatusHp = timing.player.mon.hp
|
||||
timingHooks:wrap("battle.field_residual", function(next, context)
|
||||
timingOrder[#timingOrder + 1] = "field_residual"
|
||||
T.check(context.battlers.player.hp < preStatusHp,
|
||||
"the hook snapshot observes completed vanilla status residuals")
|
||||
return next(context)
|
||||
end, 0, "timing_probe")
|
||||
timing:endOfTurn()
|
||||
T.same(timingOrder,
|
||||
{ "field_residual", "token_expired", "turn_ended" },
|
||||
"the hook runs before token expiry and battle.turn_ended")
|
||||
|
||||
local oldGetState, oldSetState = love.math.getRandomState,
|
||||
love.math.setRandomState
|
||||
local checkpointRng = "field-residual-rng"
|
||||
love.math.getRandomState = function() return checkpointRng end
|
||||
love.math.setRandomState = function(state) checkpointRng = state end
|
||||
|
||||
local function checkpointBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "field-residual-checkpoint"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||
SaveData.validate(save, data)
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {},
|
||||
scriptMoves = {},
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing = self.player.facing
|
||||
target.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function overworld:restoreBattleContinuation(restored, origin)
|
||||
restored.onFinish = function() end
|
||||
return origin.kind == "wild_encounter" and origin.map == self.map.id
|
||||
end
|
||||
local game = setmetatable({ data = data, save = save, stack = stack,
|
||||
overworld = overworld }, { __index = GameMethods })
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
battle.field.weather = { id = "checkpoint-weather", turns = 5 }
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local checkpointGame = checkpointBattle()
|
||||
local checkpointHooks, restoredCalls = Hooks.new(), 0
|
||||
Runtime.install(Events.new(), checkpointHooks, {})
|
||||
checkpointHooks:wrap("battle.field_residual", function(next, context)
|
||||
restoredCalls = restoredCalls + 1
|
||||
T.same(context.field.weather,
|
||||
{ id = "checkpoint-weather", turns = 5 },
|
||||
"the enabled hook observes checkpointed field state after restore")
|
||||
return next(context)
|
||||
end, 0, "checkpoint_probe")
|
||||
local snapshot, captureCode = Checkpoint.capture(checkpointGame)
|
||||
T.check(snapshot ~= nil,
|
||||
"an enabled process-local hook does not enter the checkpoint: "
|
||||
.. tostring(captureCode))
|
||||
if snapshot then
|
||||
checkpointGame.stack:top().field.weather.turns = 1
|
||||
local restored, restoreCode, restoreMessage =
|
||||
Checkpoint.restore(checkpointGame, snapshot)
|
||||
T.check(restored == true,
|
||||
"field state reconstructs while the hook remains enabled: "
|
||||
.. tostring(restoreCode or restoreMessage))
|
||||
if restored then checkpointGame.stack:top():applyFieldResiduals() end
|
||||
if restored then
|
||||
T.same(Checkpoint.capture(checkpointGame), snapshot,
|
||||
"enabled-hook field state completes capture/restore/capture round-trip")
|
||||
end
|
||||
end
|
||||
T.eq(restoredCalls, 1,
|
||||
"the process-local hook still runs after checkpoint reconstruction")
|
||||
love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState
|
||||
|
||||
Runtime.install(savedEvents, savedHooks, savedErrors)
|
||||
T.finish("battle.field_residual validation")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,507 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
|
||||
local ok, bit32 = pcall(require, "bit32")
|
||||
if ok then _G.bit32 = bit32 end
|
||||
end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Base64 = require("src.core.Base64")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
local MD5 = ("0123456789abcdef"):rep(2)
|
||||
|
||||
local function baseCart()
|
||||
return {
|
||||
id = "kanto_plus",
|
||||
title = " Kanto Plus ",
|
||||
version = "1.2.0",
|
||||
author = "Ren",
|
||||
repo = "ren/kanto-plus",
|
||||
summary = "A sealed set of five",
|
||||
shell = "#3FA9F5",
|
||||
label = "./art/label.png",
|
||||
base = "red",
|
||||
engine = ">=1.4.0",
|
||||
mods = {
|
||||
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA,
|
||||
options = { sweetness = 3, flavour = "grape", fizzy = true } },
|
||||
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
|
||||
md5 = MD5 },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function rejects(mutate, fragment, what)
|
||||
local tbl = baseCart()
|
||||
mutate(tbl)
|
||||
local cart, err = CartManifest.parse(tbl)
|
||||
T.eq(cart, nil, what .. " is rejected")
|
||||
T.check(type(err) == "string" and err:find(fragment, 1, true) ~= nil,
|
||||
("%s says why (got %s)"):format(what, tostring(err)))
|
||||
end
|
||||
|
||||
local raw = baseCart()
|
||||
local cart, err = CartManifest.parse(raw)
|
||||
T.check(cart ~= nil, "a good manifest parses: " .. tostring(err))
|
||||
T.eq(cart.title, "Kanto Plus", "title is trimmed")
|
||||
T.eq(cart.shell, "#3fa9f5", "shell normalises to lowercase")
|
||||
T.eq(cart.label, "art/label.png", "label normalises through SafePath")
|
||||
T.eq(cart.seal, "sealed", "seal defaults to sealed")
|
||||
T.eq(cart.base, "red", "base survives")
|
||||
T.eq(cart.engine, ">=1.4.0", "engine range is kept unevaluated")
|
||||
T.eq(#cart.mods, 2, "both pins survive")
|
||||
T.eq(cart.mods[1].sha256, SHA, "the github pin keeps its sha256")
|
||||
T.eq(cart.mods[1].repo, "ren/rare-soda", "the github pin keeps its repo")
|
||||
T.eq(cart.mods[1].version, "0.4.1", "the github pin keeps its version")
|
||||
T.eq(cart.mods[1].options.flavour, "grape", "frozen option values survive")
|
||||
T.eq(cart.mods[2].source, "gamebanana", "the gamebanana pin keeps its source")
|
||||
T.eq(cart.mods[2].mod, 4821, "the gamebanana pin keeps its mod id")
|
||||
T.eq(cart.mods[2].file, 99123, "the gamebanana pin keeps its file id")
|
||||
T.eq(cart.mods[2].md5, MD5, "the gamebanana pin keeps its md5")
|
||||
T.eq(cart.mods[2].sha256, nil, "a gamebanana pin carries no sha256")
|
||||
T.eq(cart.load_order[1], "rare_soda", "load_order defaults to the mods order")
|
||||
T.eq(cart.load_order[2], "hard_mode", "load_order defaults to the mods order")
|
||||
|
||||
T.eq(raw.title, " Kanto Plus ", "parse does not trim the input in place")
|
||||
T.eq(raw.shell, "#3FA9F5", "parse does not recolour the input in place")
|
||||
T.eq(raw.load_order, nil, "parse does not add load_order to the input")
|
||||
T.neq(cart.mods, raw.mods, "the parsed mods array is a fresh table")
|
||||
T.neq(cart.mods[1].options, raw.mods[1].options, "options are copied")
|
||||
|
||||
local again = CartManifest.parse(baseCart())
|
||||
T.eq(CartManifest.canonical(again), CartManifest.canonical(cart),
|
||||
"two independent parses encode identically")
|
||||
T.eq(CartManifest.hash(again), CartManifest.hash(cart),
|
||||
"two independent parses hash identically")
|
||||
T.eq(#CartManifest.hash(cart), 32, "the cart hash is an MD5 hex digest")
|
||||
|
||||
local bumped = baseCart()
|
||||
bumped.mods[1].version = "0.4.2"
|
||||
T.neq(CartManifest.hash(CartManifest.parse(bumped)), CartManifest.hash(cart),
|
||||
"a bumped mod version moves the cart hash")
|
||||
|
||||
local retuned = baseCart()
|
||||
retuned.mods[1].options.sweetness = 4
|
||||
T.neq(CartManifest.hash(CartManifest.parse(retuned)), CartManifest.hash(cart),
|
||||
"a changed option value moves the cart hash")
|
||||
|
||||
local reordered = baseCart()
|
||||
reordered.load_order = { "hard_mode", "rare_soda" }
|
||||
local reorderedCart = CartManifest.parse(reordered)
|
||||
T.eq(reorderedCart.load_order[1], "hard_mode", "an explicit load_order is kept")
|
||||
T.neq(CartManifest.hash(reorderedCart), CartManifest.hash(cart),
|
||||
"a different load order moves the cart hash")
|
||||
|
||||
local encoded = CartManifest.encode(cart)
|
||||
local decoded, decodeErr = CartManifest.decode(encoded)
|
||||
T.check(decoded ~= nil, "an encoded cart decodes: " .. tostring(decodeErr))
|
||||
T.same(decoded, cart, "the round trip is lossless")
|
||||
T.eq(CartManifest.hash(decoded), CartManifest.hash(cart),
|
||||
"the round trip keeps the cart hash")
|
||||
|
||||
T.eq(CartManifest.decode(nil), nil, "decode refuses a non-string")
|
||||
T.eq(CartManifest.decode(""), nil, "decode refuses an empty file")
|
||||
T.eq(CartManifest.decode("return { }"), nil, "decode refuses an untagged file")
|
||||
T.eq(CartManifest.decode('return { format = "g1rmodlist" }'), nil,
|
||||
"decode refuses another format's file")
|
||||
T.eq(CartManifest.decode(
|
||||
('return { format = "%s", formatVersion = 99, cart = {} }')
|
||||
:format(CartManifest.FORMAT)), nil, "decode refuses an unknown schema")
|
||||
T.eq(CartManifest.decode('return os.exit(1)'), nil,
|
||||
"decode refuses a file that tries to call out")
|
||||
T.eq(CartManifest.decode(
|
||||
('return { format = "%s", formatVersion = 1, cart = { id = "x" } }')
|
||||
:format(CartManifest.FORMAT)), nil, "decode validates the cart it carries")
|
||||
|
||||
rejects(function(c) c.id = nil end, "cart id", "a missing id")
|
||||
rejects(function(c) c.id = "kanto plus" end, "cart id", "an id with a space")
|
||||
rejects(function(c) c.id = ("k"):rep(65) end, "cart id", "a 65 character id")
|
||||
rejects(function(c) c.title = nil end, "cart title", "a missing title")
|
||||
rejects(function(c) c.title = " " end, "cart title", "a blank title")
|
||||
rejects(function(c) c.title = ("T"):rep(49) end, "cart title", "a 49 character title")
|
||||
rejects(function(c) c.version = nil end, "cart version", "a missing version")
|
||||
rejects(function(c) c.version = "one" end, "cart version", "a non-semver version")
|
||||
rejects(function(c) c.author = nil end, "cart author", "a missing author")
|
||||
rejects(function(c) c.author = "" end, "cart author", "an empty author")
|
||||
rejects(function(c) c.author = ("A"):rep(65) end, "cart author", "a 65 character author")
|
||||
rejects(function(c) c.repo = "ren" end, "cart repo", "a repo with no owner")
|
||||
rejects(function(c) c.repo = "ren/kanto/plus" end, "cart repo", "a three part repo")
|
||||
rejects(function(c) c.summary = ("s"):rep(121) end, "cart summary", "a 121 character summary")
|
||||
rejects(function(c) c.shell = nil end, "cart shell", "a missing shell colour")
|
||||
rejects(function(c) c.shell = "3FA9F5" end, "cart shell", "a shell colour with no hash")
|
||||
rejects(function(c) c.shell = "#3FA9F" end, "cart shell", "a five digit shell colour")
|
||||
rejects(function(c) c.shell = "#gggggg" end, "cart shell", "a non-hex shell colour")
|
||||
rejects(function(c) c.label = "../../etc/passwd" end, "cart label", "a climbing label path")
|
||||
rejects(function(c) c.label = "/etc/passwd" end, "cart label", "an absolute label path")
|
||||
rejects(function(c) c.label = ("a"):rep(129) end, "cart label", "a 129 character label path")
|
||||
rejects(function(c) c.base = nil end, "cart base", "a missing base game")
|
||||
rejects(function(c) c.base = "nonesuch" end, "cart base", "an unknown base game")
|
||||
rejects(function(c) c.engine = "" end, "cart engine", "an empty engine range")
|
||||
rejects(function(c) c.engine = 3 end, "cart engine", "a numeric engine range")
|
||||
rejects(function(c) c.seal = "welded" end, "cart seal", "an unknown seal")
|
||||
rejects(function(c) c.mods = nil end, "cart mods", "a cart with no mods array")
|
||||
rejects(function(c) c.mods = {} end, "cart must pin", "a cart that pins nothing")
|
||||
rejects(function(c)
|
||||
for i = 1, 65 do
|
||||
c.mods[i] = { id = "mod" .. i, source = "gamebanana", mod = i, file = i, md5 = MD5 }
|
||||
end
|
||||
end, "cart must pin", "a cart that pins 65 mods")
|
||||
|
||||
rejects(function(c) c.mods[1] = "rare_soda" end, "must be a table", "a string mod entry")
|
||||
rejects(function(c) c.mods[1].id = nil end, "id must be", "a pin with no id")
|
||||
rejects(function(c) c.mods[1].id = "rare soda" end, "id must be", "a pin id with a space")
|
||||
rejects(function(c) c.mods[2].id = "rare_soda" end, "pinned twice", "the same mod pinned twice")
|
||||
rejects(function(c) c.mods[1].source = nil end, "source must be", "a pin with no source")
|
||||
rejects(function(c) c.mods[1].source = "dropbox" end, "source must be", "a pin from an unknown source")
|
||||
rejects(function(c) c.mods[1].repo = nil end, "repo must be", "a github pin with no repo")
|
||||
rejects(function(c) c.mods[1].version = "latest" end, "version must be", "a github pin with no semver")
|
||||
rejects(function(c) c.mods[1].sha256 = nil end, "sha256", "a github pin with no sha256")
|
||||
rejects(function(c) c.mods[1].sha256 = SHA:upper() end, "sha256", "an uppercase sha256")
|
||||
rejects(function(c) c.mods[1].sha256 = SHA:sub(1, 63) end, "sha256", "a short sha256")
|
||||
rejects(function(c) c.mods[2].mod = nil end, "mod must be", "a gamebanana pin with no mod id")
|
||||
rejects(function(c) c.mods[2].mod = 0 end, "mod must be", "a gamebanana mod id of zero")
|
||||
rejects(function(c) c.mods[2].mod = 12.5 end, "mod must be", "a fractional gamebanana mod id")
|
||||
rejects(function(c) c.mods[2].file = nil end, "file must be", "a gamebanana pin with no file id")
|
||||
rejects(function(c) c.mods[2].file = -3 end, "file must be", "a negative gamebanana file id")
|
||||
rejects(function(c) c.mods[2].md5 = nil end, "md5", "a gamebanana pin with no md5")
|
||||
rejects(function(c) c.mods[2].md5 = MD5:upper() end, "md5", "an uppercase md5")
|
||||
rejects(function(c) c.mods[2].md5 = MD5 .. "00" end, "md5", "an overlong md5")
|
||||
|
||||
rejects(function(c) c.mods[1].options = "grape" end, "options must be a table",
|
||||
"a non-table options block")
|
||||
rejects(function(c) c.mods[1].options = { [("k"):rep(65)] = 1 } end,
|
||||
"option keys", "a 65 character option key")
|
||||
rejects(function(c) c.mods[1].options = { [1] = "grape" } end,
|
||||
"option keys", "a numeric option key")
|
||||
rejects(function(c) c.mods[1].options.nested = { 1, 2 } end,
|
||||
"must be a string, number or boolean", "a table option value")
|
||||
rejects(function(c) c.mods[1].options.flavour = ("g"):rep(257) end,
|
||||
"characters or fewer", "a 257 character option value")
|
||||
rejects(function(c)
|
||||
local options = {}
|
||||
for i = 1, 65 do options["opt" .. i] = i end
|
||||
c.mods[1].options = options
|
||||
end, "more than 64 options", "a pin with 65 options")
|
||||
|
||||
rejects(function(c) c.load_order = "rare_soda" end, "load_order must be an array",
|
||||
"a string load_order")
|
||||
rejects(function(c) c.load_order = { "rare_soda" } end, "exactly once",
|
||||
"a load_order that drops a mod")
|
||||
rejects(function(c) c.load_order = { "rare_soda", "hard_mode", "hard_mode" } end,
|
||||
"exactly once", "a load_order longer than the mods array")
|
||||
rejects(function(c) c.load_order = { "rare_soda", "rare_soda" } end, "twice",
|
||||
"a load_order that repeats a mod")
|
||||
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
|
||||
open.summary = nil
|
||||
open.label = nil
|
||||
open.engine = nil
|
||||
local openCart = CartManifest.parse(open)
|
||||
T.check(openCart ~= nil, "an open cart with no optional fields parses")
|
||||
T.eq(openCart.seal, "open", "an open seal survives")
|
||||
T.eq(openCart.label, nil, "an absent label stays absent")
|
||||
T.same(CartManifest.decode(CartManifest.encode(openCart)), openCart,
|
||||
"an open cart round trips")
|
||||
T.neq(CartManifest.hash(openCart), CartManifest.hash(cart),
|
||||
"the seal is part of the cart hash")
|
||||
|
||||
T.eq(CartManifest.publishable(cart), true,
|
||||
"a cart pinned entirely to github and gamebanana is publishable")
|
||||
T.eq(CartManifest.publishable(nil), false, "publishable refuses a non-cart")
|
||||
T.eq(CartManifest.publishable({}), false, "publishable refuses an unparsed cart")
|
||||
|
||||
local localised = baseCart()
|
||||
localised.mods[1] = { id = "rare_soda", source = "local", version = "0.4.1",
|
||||
repo = "ren/rare-soda", sha256 = SHA,
|
||||
options = { flavour = "grape" } }
|
||||
local localCart, localErr = CartManifest.parse(localised)
|
||||
T.check(localCart ~= nil, "a local pin parses: " .. tostring(localErr))
|
||||
T.eq(localCart.mods[1].source, "local", "the local pin keeps its source")
|
||||
T.eq(localCart.mods[1].version, "0.4.1", "the local pin keeps its version")
|
||||
T.eq(localCart.mods[1].repo, nil, "a local pin carries no repo")
|
||||
T.eq(localCart.mods[1].sha256, nil, "a local pin carries no sha256")
|
||||
T.eq(localCart.mods[1].md5, nil, "a local pin carries no md5")
|
||||
T.eq(localCart.mods[1].options.flavour, "grape", "a local pin still freezes options")
|
||||
T.eq(localCart.mods[2].source, "gamebanana", "the sibling pin is untouched")
|
||||
|
||||
T.eq(CartManifest.canonical(CartManifest.parse(localised)),
|
||||
CartManifest.canonical(localCart), "two parses of a local pin encode identically")
|
||||
T.eq(CartManifest.hash(CartManifest.parse(localised)), CartManifest.hash(localCart),
|
||||
"two parses of a local pin hash identically")
|
||||
T.neq(CartManifest.hash(localCart), CartManifest.hash(cart),
|
||||
"a local pin hashes differently from the github pin it replaced")
|
||||
|
||||
local localBump = baseCart()
|
||||
localBump.mods[1] = { id = "rare_soda", source = "local", version = "0.4.2",
|
||||
options = { flavour = "grape" } }
|
||||
T.neq(CartManifest.hash(CartManifest.parse(localBump)), CartManifest.hash(localCart),
|
||||
"a bumped local pin version moves the cart hash")
|
||||
|
||||
T.same(CartManifest.decode(CartManifest.encode(localCart)), localCart,
|
||||
"a cart holding a local pin round trips")
|
||||
|
||||
local publishableLocal, localWhy = CartManifest.publishable(localCart)
|
||||
T.eq(publishableLocal, false, "a cart holding a local pin is not publishable")
|
||||
T.check(type(localWhy) == "string" and localWhy:find("rare_soda", 1, true) ~= nil,
|
||||
"the reason names the local pin (got " .. tostring(localWhy) .. ")")
|
||||
T.check(localWhy:find("hard_mode", 1, true) == nil,
|
||||
"the reason leaves the publishable pins out")
|
||||
|
||||
local allLocal = baseCart()
|
||||
allLocal.mods = {
|
||||
{ id = "rare_soda", source = "local", version = "0.4.1" },
|
||||
{ id = "hard_mode", source = "local", version = "2.0.0" },
|
||||
}
|
||||
local _, allWhy = CartManifest.publishable(CartManifest.parse(allLocal))
|
||||
T.check(allWhy:find("hard_mode", 1, true) ~= nil and allWhy:find("rare_soda", 1, true) ~= nil,
|
||||
"the reason names every local pin (got " .. tostring(allWhy) .. ")")
|
||||
|
||||
rejects(function(c) c.mods[1] = { id = "rare_soda", source = "local" } end,
|
||||
"version must be", "a local pin with no version")
|
||||
rejects(function(c)
|
||||
c.mods[1] = { id = "rare_soda", source = "local", version = "latest" }
|
||||
end, "version must be", "a local pin with a non-semver version")
|
||||
rejects(function(c) c.mods[1].source = "localhost" end, "source must be",
|
||||
"a source that merely starts like local")
|
||||
|
||||
local VECTORS = {
|
||||
{ "", "" }, { "f", "Zg==" }, { "fo", "Zm8=" }, { "foo", "Zm9v" },
|
||||
{ "foob", "Zm9vYg==" }, { "fooba", "Zm9vYmE=" }, { "foobar", "Zm9vYmFy" },
|
||||
{ "\0\255\0", "AP8A" }, { "\255\255\255\255", "/////w==" },
|
||||
}
|
||||
for _, row in ipairs(VECTORS) do
|
||||
T.eq(Base64.encode(row[1]), row[2],
|
||||
("base64 encodes %q as %s"):format(row[1], row[2]))
|
||||
T.eq(Base64.decode(row[2]), row[1],
|
||||
("base64 decodes %s back"):format(row[2] == "" and "an empty string" or row[2]))
|
||||
end
|
||||
|
||||
local seed = 7
|
||||
local function nextByte()
|
||||
seed = (seed * 75 + 74) % 65537
|
||||
return seed % 256
|
||||
end
|
||||
for n = 0, 24 do
|
||||
local chunk = {}
|
||||
for i = 1, n do chunk[i] = string.char(nextByte()) end
|
||||
local raw = table.concat(chunk)
|
||||
local text = Base64.encode(raw)
|
||||
T.eq(#text % 4, 0, ("base64 pads %d bytes to a multiple of four"):format(n))
|
||||
T.eq(Base64.decode(text), raw, ("base64 round trips %d random bytes"):format(n))
|
||||
end
|
||||
|
||||
T.eq(Base64.encode(nil), nil, "base64 encode refuses a non-string")
|
||||
T.eq(Base64.decode(nil), nil, "base64 decode refuses a non-string")
|
||||
T.eq(Base64.decode("TWF"), nil, "base64 refuses a length that is not a multiple of four")
|
||||
T.eq(Base64.decode("TW*u"), nil, "base64 refuses a character outside the alphabet")
|
||||
T.eq(Base64.decode("TWFu\n\n\n\n"), nil, "base64 refuses embedded whitespace")
|
||||
T.eq(Base64.decode("TW=u"), nil, "base64 refuses padding inside a group")
|
||||
T.eq(Base64.decode("=WFu"), nil, "base64 refuses a leading pad character")
|
||||
T.eq(Base64.decode("TWFu===="), nil, "base64 refuses a group that is all padding")
|
||||
T.eq(Base64.decode("TR=="), nil, "base64 refuses one-byte padding that carries data bits")
|
||||
T.eq(Base64.decode("Zm9vYmF="), nil, "base64 refuses two-byte padding that carries data bits")
|
||||
T.eq(Base64.decode("TWFu"), "Man", "base64 decodes a known vector")
|
||||
|
||||
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRtiny label art"
|
||||
local ART_DATA = Base64.encode(PNG)
|
||||
|
||||
local NONE = {}
|
||||
|
||||
local function artTable(over)
|
||||
local art = { name = "label.png", encoding = "base64", bytes = #PNG,
|
||||
data = ART_DATA }
|
||||
for key, value in pairs(over or {}) do
|
||||
if value == NONE then art[key] = nil else art[key] = value end
|
||||
end
|
||||
return art
|
||||
end
|
||||
|
||||
local function bundle(body, art)
|
||||
return SaveSerializer.encode({ format = CartManifest.FORMAT,
|
||||
formatVersion = CartManifest.SCHEMA, cart = body, labelArt = art })
|
||||
end
|
||||
|
||||
local arted = CartManifest.parse(baseCart())
|
||||
arted.labelArt = artTable()
|
||||
|
||||
local artedBytes = CartManifest.encode(arted)
|
||||
local artedRound, artedErr = CartManifest.decode(artedBytes)
|
||||
T.check(artedRound ~= nil, "a cart with label art decodes: " .. tostring(artedErr))
|
||||
T.same(artedRound, arted, "the label art survives the encode and decode round trip")
|
||||
T.eq(artedRound.labelArt.data, ART_DATA, "the base64 payload is preserved verbatim")
|
||||
T.eq(artedRound.labelArt.bytes, #PNG, "the declared byte count is preserved")
|
||||
T.eq(artedRound.labelArt.name, "label.png", "the art name is preserved")
|
||||
T.eq(CartManifest.encode(artedRound), artedBytes,
|
||||
"re-encoding a decoded cart writes the same bytes")
|
||||
|
||||
local artBytes, artName = CartManifest.labelArtBytes(artedRound)
|
||||
T.eq(artBytes, PNG, "labelArtBytes hands back the PNG that was packed")
|
||||
T.eq(artName, "label.png", "labelArtBytes hands back the art name")
|
||||
T.eq(CartManifest.labelArtBytes(cart), nil, "a cart with no art has no art bytes")
|
||||
|
||||
T.eq(CartManifest.hash(arted), CartManifest.hash(cart),
|
||||
"label art is not part of the cart hash")
|
||||
T.check(CartManifest.canonical(arted):find(ART_DATA, 1, true) == nil,
|
||||
"the canonical form leaves the art payload out")
|
||||
|
||||
local repainted = CartManifest.parse(baseCart())
|
||||
local REPAINT = PNG .. "repainted"
|
||||
repainted.labelArt = artTable({ data = Base64.encode(REPAINT), bytes = #REPAINT })
|
||||
T.eq(CartManifest.hash(repainted), CartManifest.hash(arted),
|
||||
"changing only the art leaves the cart hash alone")
|
||||
T.eq(CartManifest.labelArtBytes(CartManifest.decode(CartManifest.encode(repainted))),
|
||||
REPAINT, "the repainted art round trips")
|
||||
|
||||
local plain = CartManifest.decode(CartManifest.encode(cart))
|
||||
T.eq(plain.labelArt, nil, "a cart with no art still decodes without art")
|
||||
T.check(CartManifest.encode(cart):find("labelArt", 1, true) == nil,
|
||||
"a cart with no art writes no labelArt key")
|
||||
T.same(plain, cart, "a cart with no art round trips exactly as before")
|
||||
|
||||
local OVERSIZE = PNG .. string.rep("\0", CartManifest.MAX_LABEL_ART)
|
||||
local OVERSIZE_DATA = Base64.encode(OVERSIZE)
|
||||
|
||||
local function drops(art, what)
|
||||
local decodedArt, dropErr = CartManifest.decode(bundle(cart, art))
|
||||
T.check(decodedArt ~= nil, what .. " still loads the cart: " .. tostring(dropErr))
|
||||
T.eq(decodedArt.labelArt, nil, what .. " drops the art")
|
||||
T.same(decodedArt, cart, what .. " leaves the rest of the cart untouched")
|
||||
end
|
||||
|
||||
drops("label.png", "art that is not a table")
|
||||
drops(artTable({ encoding = "hex" }), "art in an encoding we do not support")
|
||||
drops(artTable({ encoding = NONE }), "art with no encoding")
|
||||
drops(artTable({ data = "not base64!!" }), "art whose payload is not base64")
|
||||
drops(artTable({ data = NONE }), "art with no payload")
|
||||
drops(artTable({ data = "" }), "art with an empty payload")
|
||||
drops(artTable({ bytes = #PNG + 1 }), "art whose byte count is too high")
|
||||
drops(artTable({ bytes = #PNG - 1 }), "art whose byte count is too low")
|
||||
drops(artTable({ bytes = NONE }), "art with no byte count")
|
||||
drops(artTable({ bytes = tostring(#PNG) }), "art whose byte count is a string")
|
||||
drops(artTable({ data = Base64.encode("GIF89a not a png at all"),
|
||||
bytes = #"GIF89a not a png at all" }), "art that is not a PNG")
|
||||
drops(artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }), "art past the size cap")
|
||||
drops(artTable({ name = "../../etc/passwd" }), "art whose name climbs out")
|
||||
drops(artTable({ name = ("a"):rep(129) }), "art with a 129 character name")
|
||||
drops(artTable({ name = 7 }), "art with a numeric name")
|
||||
|
||||
local unnamed = CartManifest.decode(bundle(cart, artTable({ name = NONE })))
|
||||
T.check(unnamed ~= nil and unnamed.labelArt ~= nil, "art with no name is still kept")
|
||||
T.eq(unnamed.labelArt.name, nil, "the missing art name stays missing")
|
||||
T.eq(CartManifest.labelArtBytes(unnamed), PNG, "unnamed art still decodes")
|
||||
|
||||
T.eq(CartManifest.parseLabelArt(nil), nil, "parseLabelArt refuses an absent payload")
|
||||
local _, capErr = CartManifest.parseLabelArt(
|
||||
artTable({ data = OVERSIZE_DATA, bytes = #OVERSIZE }))
|
||||
T.check(type(capErr) == "string" and capErr:find("bytes or fewer", 1, true) ~= nil,
|
||||
"the size cap says why (got " .. tostring(capErr) .. ")")
|
||||
local _, pngErr = CartManifest.parseLabelArt(
|
||||
artTable({ data = Base64.encode("nope"), bytes = 4 }))
|
||||
T.check(type(pngErr) == "string" and pngErr:find("PNG", 1, true) ~= nil,
|
||||
"a non-PNG payload says why (got " .. tostring(pngErr) .. ")")
|
||||
|
||||
T.finish("cart_manifest")
|
||||
@@ -0,0 +1,353 @@
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function options(files)
|
||||
return SaveSerializer.decode(files["options.lua"] or "") or {}
|
||||
end
|
||||
|
||||
local function plainSave(name, hash)
|
||||
return {
|
||||
version = "red",
|
||||
meta = hash and { cartHash = hash } or nil,
|
||||
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
inventory = {},
|
||||
playTime = 0,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
fresh()
|
||||
T.eq(SaveData.getCart(), nil, "no cart is active by default")
|
||||
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
|
||||
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
|
||||
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
|
||||
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
|
||||
T.eq(SaveData.getCart(), nil, "and getCart says so")
|
||||
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
|
||||
|
||||
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
|
||||
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
|
||||
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
|
||||
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
|
||||
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
|
||||
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
|
||||
|
||||
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
|
||||
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
|
||||
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
|
||||
T.check(not ok, "and deleting from it fails")
|
||||
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
|
||||
"with a user-presentable reason")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
|
||||
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
|
||||
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
|
||||
"setActiveCartSlot returns the chosen id")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
|
||||
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
|
||||
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
|
||||
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
|
||||
|
||||
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
|
||||
"renameCartSlot labels a registered slot")
|
||||
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
|
||||
"the label is trimmed and persisted")
|
||||
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
|
||||
"listCartSlots carries the label")
|
||||
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
|
||||
T.eq(options(files).cartSlots.nuzlocke.names, nil,
|
||||
"the names table leaves the registry once empty")
|
||||
|
||||
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
|
||||
T.check(not bad, "renaming an unregistered cart slot fails")
|
||||
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
|
||||
"unknown-slot rename error is user-presentable")
|
||||
|
||||
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
|
||||
"seed the active cart slot")
|
||||
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
|
||||
"the bytes land in the cart's own directory")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
|
||||
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
|
||||
opts = options(files)
|
||||
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
|
||||
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
|
||||
"active falls back to the remaining slot")
|
||||
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
|
||||
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
|
||||
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
|
||||
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
|
||||
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
|
||||
"seed alpha's slot")
|
||||
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
|
||||
"seed beta's slot")
|
||||
|
||||
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
|
||||
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
|
||||
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
|
||||
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
|
||||
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
|
||||
"two carts with the same slot id write different files")
|
||||
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
|
||||
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
|
||||
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
|
||||
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
|
||||
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
|
||||
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
|
||||
T.eq(SaveData.createCartSlot("red"), "slot1",
|
||||
"a cart may even be named after a version")
|
||||
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
|
||||
"seed the version slot")
|
||||
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
|
||||
"seed the cart slot")
|
||||
|
||||
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
|
||||
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
|
||||
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
|
||||
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
|
||||
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
|
||||
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
|
||||
"a version slot carries no cart hash")
|
||||
|
||||
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
|
||||
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
|
||||
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
|
||||
T.eq(options(files).saveSlots.red.list[1], "slot1",
|
||||
"the version registry is independent of the cart one")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
||||
"with no cart slot yet, the flat path follows the version suffix scheme")
|
||||
|
||||
SaveData.createCartSlot("nuzlocke")
|
||||
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
||||
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
|
||||
"the active cart slot owns the save path")
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "NUZ"
|
||||
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
|
||||
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
|
||||
T.eq(files["save.lua"], nil, "never into the base game's flat file")
|
||||
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
|
||||
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
|
||||
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
||||
"and the registry mirrors it for a listing with no save decode")
|
||||
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
|
||||
"listCartSlots surfaces the stamp")
|
||||
end
|
||||
|
||||
do
|
||||
fresh()
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
SaveData.createCartSlot("nuzlocke")
|
||||
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
||||
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
|
||||
T.eq(SaveData.load("red").meta.cartHash, "abc123",
|
||||
"buildMeta carries the stamp instead of dropping it")
|
||||
|
||||
SaveData.setCartHash("def456")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
||||
"installing an update does not restamp the slot")
|
||||
loaded = SaveData.load("red")
|
||||
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
|
||||
T.check(SaveData.save(loaded, {}), "save under the new build")
|
||||
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
|
||||
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
|
||||
|
||||
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
|
||||
T.check(not ok, "an unregistered slot cannot be stamped")
|
||||
T.check(tostring(err):find("not registered", 1, true) ~= nil,
|
||||
"with a user-presentable reason")
|
||||
end
|
||||
|
||||
do
|
||||
local files = fresh()
|
||||
SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", "slot1")
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "VANILLA"
|
||||
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
|
||||
|
||||
local bytes = files["saves/red/slot1.lua"]
|
||||
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
|
||||
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
||||
"and options.lua grows no cart registry")
|
||||
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.save(loaded), "re-save the migrated shape once")
|
||||
local before = files["saves/red/slot1.lua"]
|
||||
SaveData.setCart("nuzlocke", "abc123")
|
||||
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
||||
"the cart takes over the path while it is active")
|
||||
SaveData.setCart(nil)
|
||||
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
|
||||
"and hands it straight back")
|
||||
T.check(SaveData.save(loaded), "re-save after the cart round trip")
|
||||
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
|
||||
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
||||
"and options.lua still carries no cart registry")
|
||||
|
||||
local again = SaveData.load("red")
|
||||
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
|
||||
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")
|
||||
@@ -0,0 +1,641 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
getDirectoryItems = function(path)
|
||||
local seen, items = {}, {}
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
local function manifestJson(id)
|
||||
return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua"}]])
|
||||
:format(id, id)
|
||||
end
|
||||
|
||||
local function entry(record)
|
||||
return ([[
|
||||
return function(mod)
|
||||
mod.options:define({ { key = "tint", default = "base" } })
|
||||
mod.content.pokemon:register("%s", { name = tostring(mod.options:get("tint")) })
|
||||
end
|
||||
]]):format(record)
|
||||
end
|
||||
|
||||
local function install()
|
||||
local files = {
|
||||
["options.lua"] = SaveSerializer.encode({
|
||||
mods = { beta = false },
|
||||
modOptions = { alpha = { tint = "player" }, beta = { tint = "player" } },
|
||||
}),
|
||||
}
|
||||
for _, id in ipairs({ "alpha", "beta", "gamma" }) do
|
||||
files["mods/" .. id .. "/manifest.json"] = manifestJson(id)
|
||||
files["mods/" .. id .. "/main.lua"] = entry(id:upper())
|
||||
end
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function writeCart(files, tbl)
|
||||
local cart, err = CartManifest.parse(tbl)
|
||||
assert(cart, err)
|
||||
files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart)
|
||||
return cart
|
||||
end
|
||||
|
||||
local function cartTable(id, seal, mods, order)
|
||||
return { id = id, title = id, version = "1.0.0", author = "tester",
|
||||
shell = "#102030", base = "red", seal = seal,
|
||||
mods = mods, load_order = order }
|
||||
end
|
||||
|
||||
local function pin(id, version, options)
|
||||
return { id = id, source = "local", version = version or "1.0.0",
|
||||
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) })
|
||||
local ok = loader:load(data)
|
||||
return loader, data, ok
|
||||
end
|
||||
|
||||
local function options(files)
|
||||
return SaveSerializer.decode(files["options.lua"] or "") or {}
|
||||
end
|
||||
|
||||
local function names(list)
|
||||
return table.concat(list, ",")
|
||||
end
|
||||
|
||||
-- ------- a sealed cart loads its pins, in its order, with its options
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("sealed", "sealed",
|
||||
{ pin("alpha", "1.0.0", { tint = "cart" }), pin("beta") },
|
||||
{ "beta", "alpha" }))
|
||||
SaveData.setCart("sealed", "hash1")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a sealed cart whose pins are all installed loads cleanly")
|
||||
T.eq(names(loader.order), "beta,alpha",
|
||||
"the cart's load_order beats priority and the id tie-break")
|
||||
T.eq(data.pokemon.ALPHA.name, "cart",
|
||||
"a frozen option overrides the player's saved value")
|
||||
T.eq(data.pokemon.BETA.name, "base",
|
||||
"a pin that froze no options falls to the schema default, not the player's")
|
||||
T.eq(data.pokemon.GAMMA, nil, "an enabled mod the cart does not pin never runs")
|
||||
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.eq(loader.mods.beta.enabled, true, "a pin the player switched off still loads")
|
||||
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.id, "sealed", "the report names the cart")
|
||||
T.eq(report.seal, "sealed", "and its seal")
|
||||
T.eq(report.enforced, true, "which is enforced")
|
||||
T.eq(report.refused, false, "and not refused")
|
||||
T.eq(#report.missing, 0, "with no missing pins")
|
||||
T.eq(#report.mismatched, 0, "and no version mismatches")
|
||||
T.eq(loader:status().cart, report, "status() carries the same report")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
|
||||
"the player's disable flag is untouched on disk")
|
||||
T.eq(SaveData.modEnabled(opts, "gamma", "red") == false, false,
|
||||
"and so is the enable flag of the mod the seal left out")
|
||||
T.eq(opts.modOptions.alpha.tint, "player",
|
||||
"the frozen option never overwrote the player's saved value")
|
||||
T.eq(opts.modOptions.beta.tint, "player", "for any pinned mod")
|
||||
end
|
||||
|
||||
-- ------- an open cart layers the player's mods on top
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("open", "open",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("gamma", "1.0.0", { tint = "cart" }) },
|
||||
{ "beta", "gamma" }))
|
||||
SaveData.setCart("open", "hash2")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart loads")
|
||||
T.eq(names(loader.order), "beta,gamma,alpha",
|
||||
"the cart's mods come first in its order, then the player's own")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"an open cart's option is a starting value the player's own setting beats")
|
||||
T.eq(data.pokemon.GAMMA.name, "cart",
|
||||
"and it stands where the player set nothing")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "the player's extra mod loads normally")
|
||||
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
|
||||
local files = install()
|
||||
writeCart(files, cartTable("gap", "sealed",
|
||||
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
|
||||
SaveData.setCart("gap", "hash3")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(not ok, "a sealed cart with an uninstalled pin fails the load")
|
||||
T.eq(#loader.order, 0, "and plays no subset of itself")
|
||||
T.eq(data.pokemon.ALPHA, nil, "not even the pin that is installed")
|
||||
T.eq(data.pokemon.GAMMA, nil, "and certainly not the player's own mods")
|
||||
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.refused, true, "the report refuses the cart")
|
||||
T.eq(#report.missing, 1, "naming one missing pin")
|
||||
T.eq(report.missing[1].id, "delta", "by id")
|
||||
T.eq(report.missing[1].version, "2.0.0", "and by the version it pins")
|
||||
T.eq(report.missing[1].source, "local", "with the source it would come from")
|
||||
T.check(report.message:find("delta 2.0.0 is not installed", 1, true) ~= nil,
|
||||
"and a message the launcher can show")
|
||||
T.eq(loader.errors[1], report.message, "the refusal is on the boot error list")
|
||||
|
||||
local opts = options(files)
|
||||
T.eq(SaveData.modEnabled(opts, "beta", "red"), false,
|
||||
"a refusal still leaves the player's flags alone")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("gap", "open",
|
||||
{ pin("alpha"), pin("delta", "2.0.0") }, { "alpha", "delta" }))
|
||||
SaveData.setCart("gap", "hash4")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart with an uninstalled pin still loads")
|
||||
T.eq(names(loader.order), "alpha,gamma", "with the pins it does have, then the rest")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "the surviving pin runs")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.refused, false, "the missing pin is a warning, not a refusal")
|
||||
T.eq(report.missing[1].id, "delta", "and is still reported by id")
|
||||
end
|
||||
|
||||
-- ------- a pin installed at another version
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("skew", "sealed",
|
||||
{ pin("alpha", "2.0.0") }, { "alpha" }))
|
||||
SaveData.setCart("skew", "hash5")
|
||||
|
||||
local loader, _, ok = boot(files)
|
||||
T.check(not ok, "a sealed cart refuses a pin installed at another version")
|
||||
T.eq(#loader.order, 0, "and loads nothing")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.mismatched[1].id, "alpha", "the mismatch names the mod")
|
||||
T.eq(report.mismatched[1].version, "2.0.0", "the version the cart pins")
|
||||
T.eq(report.mismatched[1].installed, "1.0.0", "and the version installed")
|
||||
T.check(report.message:find("alpha is pinned at 2.0.0 but 1.0.0 is installed",
|
||||
1, true) ~= nil, "with a message the launcher can show")
|
||||
end
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("skew", "open",
|
||||
{ pin("alpha", "2.0.0") }, { "alpha" }))
|
||||
SaveData.setCart("skew", "hash6")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "an open cart warns about a version skew instead of refusing")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "and loads the version that is there")
|
||||
T.eq(loader:cartStatus().mismatched[1].installed, "1.0.0", "while reporting it")
|
||||
end
|
||||
|
||||
-- ------- an unreadable cart
|
||||
|
||||
do
|
||||
local files = install()
|
||||
SaveData.setCart("ghost", "hash7")
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(not ok, "a cart that is not installed cannot be played as that cart")
|
||||
T.eq(data.pokemon.GAMMA, nil, "so nothing loads under its name")
|
||||
T.check(loader:cartStatus().message:find("ghost", 1, true) ~= nil,
|
||||
"and the report names the cart that went missing")
|
||||
end
|
||||
|
||||
-- ------- breaking the seal downgrades a sealed cart to the open answer
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("sealed", "sealed",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
|
||||
{ "beta", "delta" }))
|
||||
SaveData.setCart("sealed", "hash8")
|
||||
SaveData.breakSeal()
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a broken seal no longer refuses over a missing pin")
|
||||
T.eq(names(loader.order), "beta,alpha,gamma",
|
||||
"the player's own mods load alongside the cart's")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"and the player's option values come back with them")
|
||||
local report = loader:cartStatus()
|
||||
T.eq(report.broken, true, "the report says the seal is broken")
|
||||
T.eq(report.enforced, false, "so the seal enforces nothing")
|
||||
end
|
||||
|
||||
-- ------- vanilla is untouched when no cart is active
|
||||
|
||||
do
|
||||
local files = install()
|
||||
SaveData.resetSlotState()
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "a vanilla boot with no cart loads")
|
||||
T.eq(loader:cartStatus(), nil, "and reports no cart at all")
|
||||
T.eq(names(loader.order), "alpha,gamma", "with the player's enabled set, in id order")
|
||||
T.eq(data.pokemon.ALPHA.name, "player", "and the player's option values")
|
||||
T.eq(data.pokemon.BETA, nil, "the mod the player switched off stays off")
|
||||
end
|
||||
|
||||
-- ------- planCart on its own
|
||||
|
||||
do
|
||||
local report = Loader.planCart(nil, {})
|
||||
T.eq(report.refused, true, "planCart refuses a cart it was handed nothing for")
|
||||
T.check(report.message:find("not installed", 1, true) ~= nil,
|
||||
"with a presentable reason")
|
||||
|
||||
local unpinned = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "0.0.0") }, { "gamma" }),
|
||||
{ { id = "gamma", version = "whatever" } })
|
||||
T.eq(#unpinned.mismatched, 0,
|
||||
"a local pin captured with no semantic version makes no version claim")
|
||||
T.eq(unpinned.refused, false, "so it cannot refuse over one")
|
||||
|
||||
local skew = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }),
|
||||
{ gamma = { manifest = { id = "gamma", version = "1.0.0-beta" } } })
|
||||
T.eq(skew.mismatched[1].installed, "1.0.0-beta",
|
||||
"a prerelease is a different version to a sealed cart")
|
||||
|
||||
local broken = Loader.planCart(
|
||||
cartTable("c", "sealed", { pin("gamma", "1.0.0") }, { "gamma" }), {}, true)
|
||||
T.eq(broken.refused, false, "a broken seal downgrades the refusal to a warning")
|
||||
T.eq(broken.missing[1].id, "gamma", "while still reporting the missing pin")
|
||||
end
|
||||
|
||||
-- ------- the broken-seal stamp
|
||||
|
||||
local function plainSave(name)
|
||||
return {
|
||||
version = "red",
|
||||
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
inventory = {},
|
||||
playTime = 0,
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
T.eq(SaveData.isSealBroken(), false, "a fresh session has no broken seal")
|
||||
local save = plainSave("INTACT")
|
||||
T.eq(SaveData.isSealBroken(save), false, "and neither does a fresh save")
|
||||
T.check(SaveData.save(save, {}), "write a save under an intact seal")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, nil, "which carries no stamp")
|
||||
|
||||
local loaded = SaveData.load("red")
|
||||
T.check(SaveData.breakSeal(loaded), "breaking the seal stamps the save")
|
||||
T.eq(SaveData.isSealBroken(loaded), true, "the save reads back as modified")
|
||||
T.eq(SaveData.isSealBroken(), true, "and the session is armed")
|
||||
T.check(SaveData.save(loaded, {}), "save the stamped file")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true,
|
||||
"the stamp survives a save/load round trip")
|
||||
|
||||
local again = SaveData.load("red")
|
||||
T.check(SaveData.save(again, {}), "re-save with a rebuilt meta stamp")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true, "buildMeta carries the stamp")
|
||||
|
||||
T.eq(SaveData.unbreakSeal, nil, "there is no public unset")
|
||||
T.eq(SaveData.clearSeal, nil, "under any spelling")
|
||||
T.eq(SaveData.setSealBroken, nil, "and no setter that takes a value")
|
||||
T.check(SaveData.breakSeal(again, false), "the setter takes no argument that clears")
|
||||
T.eq(SaveData.isSealBroken(again), true, "so the stamp is still there")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
T.eq(SaveData.isSealBroken(), false, "a new session starts unarmed")
|
||||
local reread = SaveData.load("red")
|
||||
T.eq(reread.meta.sealBroken, true, "but the file it stamped is modified for good")
|
||||
T.check(SaveData.save(reread, {}), "and re-saving it under a fresh session")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true, "does not un-modify it")
|
||||
|
||||
local fresh = plainSave("FRESH")
|
||||
T.check(SaveData.save(fresh, {}), "a save written while the session is unarmed")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, nil, "carries no stamp of its own")
|
||||
end
|
||||
|
||||
-- ------- the durable per-slot broken mark
|
||||
|
||||
do
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
local first = SaveData.createCartSlot("kanto")
|
||||
T.eq(first, "slot1", "a cart's first save slot")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), false, "starts sealed")
|
||||
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, false,
|
||||
"which its launcher row reports without loading a save")
|
||||
|
||||
T.check(SaveData.markSlotSealBroken("kanto", first), "break that slot's seal")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true, "the slot reads as broken")
|
||||
T.eq(SaveData.listCartSlots("kanto")[1].sealBroken, true,
|
||||
"and the launcher row carries it")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true,
|
||||
"the mark survives a restart")
|
||||
|
||||
local second = SaveData.createCartSlot("kanto")
|
||||
T.eq(SaveData.slotSealBroken("kanto", second), false,
|
||||
"a new slot under the same cart starts sealed again")
|
||||
|
||||
T.eq(SaveData.clearSlotSealBroken, nil, "there is no public unset")
|
||||
T.eq(SaveData.unmarkSlotSealBroken, nil, "under any spelling")
|
||||
T.eq(SaveData.setSlotSealBroken, nil, "and no setter that takes a value")
|
||||
T.check(SaveData.markSlotSealBroken("kanto", first, false),
|
||||
"the setter takes no argument that clears")
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), true, "so the mark stands")
|
||||
T.eq(SaveData.markSlotSealBroken("kanto", "slot9"), false,
|
||||
"a slot that is not registered cannot be marked")
|
||||
|
||||
SaveData.setCart("kanto", "hash9")
|
||||
SaveData.setActiveCartSlot("kanto", second)
|
||||
T.eq(SaveData.adoptCartSeal("kanto"), false,
|
||||
"booting an unmarked slot leaves the session sealed")
|
||||
T.eq(SaveData.isSealBroken(), false, "so the loader still enforces the cart")
|
||||
SaveData.setActiveCartSlot("kanto", first)
|
||||
T.eq(SaveData.adoptCartSeal("kanto"), true,
|
||||
"booting the marked slot breaks the seal for the session")
|
||||
T.eq(SaveData.isSealBroken(), true, "which is what the loader reads")
|
||||
|
||||
SaveData.deleteCartSlot("kanto", first)
|
||||
T.eq(SaveData.slotSealBroken("kanto", first), false,
|
||||
"deleting the playthrough takes its mark with it")
|
||||
end
|
||||
|
||||
-- ------- a marked slot loads the cart's pins first, then the player's mods
|
||||
|
||||
do
|
||||
local files = install()
|
||||
writeCart(files, cartTable("marked", "sealed",
|
||||
{ pin("beta", "1.0.0", { tint = "cart" }), pin("delta", "2.0.0") },
|
||||
{ "beta", "delta" }))
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
SaveData.setCart("marked", "hash10")
|
||||
local slot = SaveData.createCartSlot("marked")
|
||||
SaveData.setActiveCartSlot("marked", slot)
|
||||
|
||||
local intact, _, intactOk = boot(files)
|
||||
T.check(not intactOk, "an unmarked slot still refuses the missing pin")
|
||||
T.eq(intact:cartStatus().refused, true, "with the refusal on its report")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
SaveData.setCart("marked", "hash10")
|
||||
T.check(SaveData.markSlotSealBroken("marked", slot), "mark that slot broken")
|
||||
T.check(SaveData.adoptCartSeal("marked"), "boot adopts the mark")
|
||||
|
||||
local loader, data, ok = boot(files)
|
||||
T.check(ok, "and the cart loads")
|
||||
T.eq(names(loader.order), "beta,alpha,gamma",
|
||||
"the cart's pins load first, then the player's own enabled mods")
|
||||
T.eq(data.pokemon.BETA.name, "player",
|
||||
"with the player's own option values back")
|
||||
T.eq(loader:cartStatus().broken, true, "the report says the seal is broken")
|
||||
|
||||
local stamped = plainSave("BROKEN")
|
||||
T.check(SaveData.save(stamped, {}), "a save written under the adopted mark")
|
||||
T.eq(SaveData.load("red").meta.sealBroken, true,
|
||||
"carries the save's own permanent stamp")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
|
||||
T.finish("cart_seal")
|
||||
@@ -0,0 +1,433 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
if not rawget(_G, "bit") and not rawget(_G, "bit32") then
|
||||
local ok, bit32 = pcall(require, "bit32")
|
||||
if ok then _G.bit32 = bit32 end
|
||||
end
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Base64 = require("src.core.Base64")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CartManifest = require("src.carts.CartManifest")
|
||||
local CartStore = require("src.carts.CartStore")
|
||||
|
||||
local SHA = ("a1b2c3d4"):rep(8)
|
||||
local SHA2 = ("beefcafe"):rep(8)
|
||||
local MD5 = ("0123456789abcdef"):rep(2)
|
||||
|
||||
local function memfs()
|
||||
local files, dirs = {}, {}
|
||||
local fs
|
||||
fs = {
|
||||
files = files,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function(path) dirs[path] = true return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
if dirs[path] then return { type = "directory" } end
|
||||
for name in pairs(files) do
|
||||
if name:sub(1, #path + 1) == path .. "/" then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix = (path == "" or path == nil) and "" or (path .. "/")
|
||||
local out, seen = {}, {}
|
||||
for name in pairs(files) do
|
||||
if name:sub(1, #prefix) == prefix then
|
||||
local child = name:sub(#prefix + 1):match("^([^/]+)")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
return fs
|
||||
end
|
||||
|
||||
local function cartTable(over)
|
||||
local tbl = {
|
||||
id = "kanto_plus",
|
||||
title = "Kanto Plus",
|
||||
version = "1.2.0",
|
||||
author = "Ren",
|
||||
shell = "#3fa9f5",
|
||||
base = "red",
|
||||
seal = "sealed",
|
||||
mods = {
|
||||
{ id = "rare_soda", source = "github", repo = "ren/rare-soda",
|
||||
version = "0.4.1", sha256 = SHA,
|
||||
options = { flavour = "grape", sweetness = 3 } },
|
||||
{ id = "hard_mode", source = "gamebanana", mod = 4821, file = 99123,
|
||||
md5 = MD5 },
|
||||
},
|
||||
}
|
||||
for key, value in pairs(over or {}) do tbl[key] = value end
|
||||
return tbl
|
||||
end
|
||||
|
||||
local function bytesOf(over)
|
||||
local cart, err = CartManifest.parse(cartTable(over))
|
||||
if not cart then error("fixture does not parse: " .. tostring(err)) end
|
||||
return CartManifest.encode(cart), cart
|
||||
end
|
||||
|
||||
local fs = memfs()
|
||||
local bytes, fixture = bytesOf()
|
||||
local installed, hash = CartStore.install(bytes, fs)
|
||||
T.check(installed ~= nil, "a good cart installs: " .. tostring(hash))
|
||||
T.eq(installed.id, "kanto_plus", "install returns the parsed cart")
|
||||
T.eq(hash, CartManifest.hash(fixture), "install returns the cart hash")
|
||||
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
|
||||
"install writes carts/<id>.g1rcart")
|
||||
|
||||
local reg = SaveData.loadOptions(fs).carts
|
||||
T.check(type(reg) == "table" and type(reg.kanto_plus) == "table",
|
||||
"install registers the cart in options.carts")
|
||||
T.eq(reg.kanto_plus.title, "Kanto Plus", "the registry carries the title")
|
||||
T.eq(reg.kanto_plus.base, "red", "the registry carries the base game")
|
||||
T.eq(reg.kanto_plus.version, "1.2.0", "the registry carries the cart version")
|
||||
T.eq(reg.kanto_plus.hash, hash, "the registry carries the cart hash")
|
||||
T.eq(reg.kanto_plus.file, "carts/kanto_plus.g1rcart",
|
||||
"the registry names the cart file")
|
||||
|
||||
local index = CartStore.index(fs)
|
||||
T.eq(#index, 1, "index lists the registry without reading the files")
|
||||
T.eq(index[1].base, "red", "an index row carries the base game")
|
||||
|
||||
local rows = CartStore.list(fs)
|
||||
T.eq(#rows, 1, "list returns the installed cart")
|
||||
T.eq(rows[1].id, "kanto_plus", "the row names the cart")
|
||||
T.eq(rows[1].base, "red", "the row carries the base")
|
||||
T.eq(rows[1].cartHash, hash, "the row carries the cart hash")
|
||||
T.check(type(rows[1].cart) == "table" and rows[1].cart.mods ~= nil,
|
||||
"the row carries the parsed cart")
|
||||
T.eq(rows[1].cart.mods[1].sha256, SHA, "the parsed cart keeps its pins")
|
||||
|
||||
local got, gotHash = CartStore.get("kanto_plus", fs)
|
||||
T.check(got ~= nil, "get returns the cart")
|
||||
T.eq(gotHash, hash, "get returns the cart hash")
|
||||
T.same(got, fixture, "get returns exactly what was installed")
|
||||
T.eq(CartStore.get("nothing_here", fs), nil, "get refuses an unknown id")
|
||||
T.eq(CartStore.get("../etc/passwd", fs), nil, "get refuses a climbing id")
|
||||
|
||||
local exported, exportHash = CartStore.export("kanto_plus", fs)
|
||||
T.check(type(exported) == "string", "export hands back bytes")
|
||||
T.eq(exportHash, hash, "export reports the cart hash")
|
||||
T.same(CartManifest.decode(exported), fixture, "the exported bytes decode back")
|
||||
T.eq(CartStore.export("nothing_here", fs), nil, "export refuses an unknown id")
|
||||
|
||||
local blueBytes = bytesOf({ id = "johto_lite", title = "Aaa Johto Lite",
|
||||
base = "blue" })
|
||||
T.check(CartStore.install(blueBytes, fs) ~= nil, "a blue cart installs")
|
||||
T.eq(#CartStore.list(fs), 2, "list returns both carts")
|
||||
T.eq(CartStore.list(fs)[1].id, "johto_lite", "list sorts by title")
|
||||
T.eq(#CartStore.listFor("red", fs), 1, "listFor red returns one cart")
|
||||
T.eq(CartStore.listFor("red", fs)[1].id, "kanto_plus", "listFor red picks the red cart")
|
||||
T.eq(#CartStore.listFor("blue", fs), 1, "listFor blue returns one cart")
|
||||
T.eq(#CartStore.listFor("yellow", fs), 0, "listFor yellow returns nothing")
|
||||
|
||||
local newer, newerErr = CartStore.install(
|
||||
bytesOf({ version = "1.3.0", title = "Kanto Plus" }), fs)
|
||||
T.check(newer ~= nil, "a newer cart replaces the installed one: " .. tostring(newerErr))
|
||||
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
|
||||
"the newer version is what is installed")
|
||||
T.eq(#CartStore.list(fs), 2, "replacing does not add a second row")
|
||||
T.eq(SaveData.loadOptions(fs).carts.kanto_plus.version, "1.3.0",
|
||||
"the registry follows the replacement")
|
||||
|
||||
local same, sameErr = CartStore.install(bytesOf({ version = "1.3.0" }), fs)
|
||||
T.check(same ~= nil, "the same version reinstalls: " .. tostring(sameErr))
|
||||
|
||||
local older, olderErr = CartStore.install(bytesOf({ version = "1.1.0" }), fs)
|
||||
T.eq(older, nil, "an older cart is refused")
|
||||
T.check(type(olderErr) == "string" and olderErr:find("older", 1, true) ~= nil,
|
||||
"the refusal says why (got " .. tostring(olderErr) .. ")")
|
||||
T.eq(CartStore.get("kanto_plus", fs).version, "1.3.0",
|
||||
"the refused install leaves the newer cart in place")
|
||||
|
||||
T.eq(CartStore.install("return { }", fs), nil, "install refuses an untagged file")
|
||||
T.eq(CartStore.install(nil, fs), nil, "install refuses a non-string")
|
||||
T.eq(CartStore.install("\1\2\3 not lua", fs), nil, "install refuses noise")
|
||||
|
||||
fs.files["saves/cart_kanto_plus/slot1.lua"] = "return { player = { name = \"RED\" } }"
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.cartSlots = { kanto_plus = { list = { "slot1" }, active = "slot1" } }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
|
||||
T.check(CartStore.uninstall("kanto_plus", fs), "uninstall reports success")
|
||||
T.eq(fs.files["carts/kanto_plus.g1rcart"], nil, "uninstall removes the cart file")
|
||||
T.eq(SaveData.loadOptions(fs).carts.kanto_plus, nil,
|
||||
"uninstall clears the registry entry")
|
||||
T.eq(#CartStore.list(fs), 1, "the uninstalled cart is gone from the list")
|
||||
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
|
||||
"uninstall leaves the cart's save file alone")
|
||||
local slots = SaveData.loadOptions(fs).cartSlots
|
||||
T.check(type(slots) == "table" and type(slots.kanto_plus) == "table",
|
||||
"uninstall leaves the cart's slot registry alone")
|
||||
T.eq(slots.kanto_plus.active, "slot1", "the active slot survives an uninstall")
|
||||
|
||||
local gone, goneErr = CartStore.uninstall("kanto_plus", fs)
|
||||
T.eq(gone, nil, "uninstalling twice is refused")
|
||||
T.check(type(goneErr) == "string" and goneErr:find("not installed", 1, true) ~= nil,
|
||||
"the second uninstall says why (got " .. tostring(goneErr) .. ")")
|
||||
T.eq(CartStore.uninstall("../etc/passwd", fs), nil, "uninstall refuses a climbing id")
|
||||
|
||||
T.check(CartStore.install(bytes, fs) ~= nil, "the cart reinstalls after removal")
|
||||
T.same(CartStore.get("kanto_plus", fs), fixture, "reinstalling restores the cart")
|
||||
T.check(fs.files["saves/cart_kanto_plus/slot1.lua"] ~= nil,
|
||||
"the old playthrough is still there for the reinstalled cart")
|
||||
|
||||
fs.files["carts/kanto_plus.g1rcart"] = "return { format = \"nonsense\" }"
|
||||
local damaged = CartStore.list(fs)
|
||||
T.eq(#damaged, 1, "a corrupt cart file is skipped and the rest still list")
|
||||
T.eq(damaged[1].id, "johto_lite", "the healthy cart survives a corrupt sibling")
|
||||
T.check(fs.files["carts/kanto_plus.g1rcart"] ~= nil,
|
||||
"listing never deletes the file it could not read")
|
||||
T.eq(CartStore.get("kanto_plus", fs), nil, "get reports the corrupt cart as unreadable")
|
||||
fs.files["carts/kanto_plus.g1rcart"] = nil
|
||||
|
||||
opts = SaveData.loadOptions(fs)
|
||||
opts.carts = opts.carts or {}
|
||||
opts.carts.ghost = { id = "ghost", title = "Ghost", base = "red",
|
||||
version = "1.0.0", file = "carts/ghost.g1rcart" }
|
||||
SaveData.saveOptions(opts, fs)
|
||||
local haunted = CartStore.list(fs)
|
||||
T.eq(#haunted, 1, "a registry entry with no file is skipped")
|
||||
T.eq(haunted[1].id, "johto_lite", "the rest of the list still comes back")
|
||||
T.eq(SaveData.loadOptions(fs).carts.ghost, nil,
|
||||
"listing prunes the registry entry whose file is gone")
|
||||
|
||||
local strayCart = select(2, bytesOf({ id = "wanderer", title = "Zzz Wanderer" }))
|
||||
fs.files["carts/wanderer.g1rcart"] = CartManifest.encode(strayCart)
|
||||
local adopted = CartStore.list(fs)
|
||||
T.eq(#adopted, 2, "a cart file with no registry entry is still listed")
|
||||
T.eq(adopted[2].id, "wanderer", "the stray cart sorts in by title")
|
||||
T.eq(adopted[2].cartHash, CartManifest.hash(strayCart),
|
||||
"the stray cart is hashed from its own file")
|
||||
T.check(SaveData.loadOptions(fs).carts.wanderer ~= nil,
|
||||
"listing registers the stray cart it adopted")
|
||||
|
||||
fs.files["carts/readme.txt"] = "hello"
|
||||
fs.files["carts/half written.g1rcart"] = "return { }"
|
||||
T.eq(#CartStore.list(fs), 2, "junk in carts/ is ignored")
|
||||
|
||||
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,
|
||||
github = "ren/hard-mode",
|
||||
manifest = { id = "hard_mode", version = "2.0.0",
|
||||
github = "ren/hard-mode", sha256 = SHA } },
|
||||
{ id = "off_mode", name = "Off Mode", version = "1.0.0", enabled = false,
|
||||
github = "ren/off-mode",
|
||||
manifest = { id = "off_mode", version = "1.0.0",
|
||||
github = "ren/off-mode", sha256 = SHA2 } },
|
||||
{ id = "rare_soda", name = "Rare Soda", version = "0.4.1", enabled = true,
|
||||
github = "ren/rare-soda",
|
||||
manifest = { id = "rare_soda", version = "0.4.1",
|
||||
github = "ren/rare-soda" } },
|
||||
{ id = "sprite_pack", name = "Sprite Pack", version = "beta", enabled = true,
|
||||
manifest = { id = "sprite_pack", version = "beta" } },
|
||||
}
|
||||
end
|
||||
|
||||
local identity = { id = "my_cart", title = "My Cart", version = "0.1.0",
|
||||
author = "Ren", base = "red", shell = "#FF8800",
|
||||
seal = "open", summary = "Built in the launcher" }
|
||||
local modOptions = {
|
||||
rare_soda = { flavour = "grape", sweetness = 3, nested = { 1, 2 } },
|
||||
off_mode = { unused = true },
|
||||
}
|
||||
|
||||
local captured, unresolved = CartStore.capture(identity, rowSet(), modOptions)
|
||||
T.check(captured ~= nil, "capture builds a cart: " .. tostring(unresolved))
|
||||
T.eq(captured.id, "my_cart", "the captured cart keeps the identity id")
|
||||
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, 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], "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[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")
|
||||
T.eq(unresolved[1].id, "rare_soda", "the first unresolved mod is named")
|
||||
T.eq(unresolved[1].name, "Rare Soda", "the unresolved row carries the mod name")
|
||||
T.check(unresolved[1].reason:find("archive hash", 1, true) ~= nil,
|
||||
"a missing hash is the reason (got " .. tostring(unresolved[1].reason) .. ")")
|
||||
T.eq(unresolved[2].id, "sprite_pack", "the second unresolved mod is named")
|
||||
T.check(unresolved[2].reason:find("GitHub repo", 1, true) ~= nil,
|
||||
"a missing repo is a reason (got " .. tostring(unresolved[2].reason) .. ")")
|
||||
T.check(unresolved[2].reason:find("semantic version", 1, true) ~= nil,
|
||||
"an unpinnable version is a reason (got " .. tostring(unresolved[2].reason) .. ")")
|
||||
|
||||
local publishable, why = CartManifest.publishable(captured)
|
||||
T.eq(publishable, false, "a captured cart with local pins cannot be published")
|
||||
T.check(why:find("rare_soda", 1, true) ~= nil, "the reason names rare_soda")
|
||||
T.check(why:find("sprite_pack", 1, true) ~= nil, "the reason names sprite_pack")
|
||||
T.check(why:find("hard_mode", 1, true) == nil, "the reason leaves the pinned mod out")
|
||||
|
||||
local storeFs = memfs()
|
||||
local roundTrip, roundHash = CartStore.install(CartManifest.encode(captured), storeFs)
|
||||
T.check(roundTrip ~= nil, "a captured cart installs: " .. tostring(roundHash))
|
||||
T.same(roundTrip, captured, "a captured cart survives the file round trip")
|
||||
T.eq(roundHash, CartManifest.hash(captured), "a captured cart hashes the same on disk")
|
||||
|
||||
local pinned = rowSet()
|
||||
pinned[3].manifest.sha256 = SHA2
|
||||
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[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 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,
|
||||
"a capture with a bad identity is refused")
|
||||
T.eq(CartStore.capture(nil, rowSet(), modOptions), nil,
|
||||
"a capture with no identity is refused")
|
||||
|
||||
local PNG = CartManifest.PNG_SIGNATURE .. "\0\0\0\13IHDRa cart label"
|
||||
local ART_DATA = Base64.encode(PNG)
|
||||
|
||||
local function artedCart()
|
||||
local plain = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
|
||||
label = "label.png" }))
|
||||
plain.labelArt = { name = "label.png", encoding = "base64", bytes = #PNG,
|
||||
data = ART_DATA }
|
||||
return plain
|
||||
end
|
||||
|
||||
local artFs = memfs()
|
||||
local artFixture = artedCart()
|
||||
local packed = CartManifest.encode(artFixture)
|
||||
local artInstalled, artHash = CartStore.install(packed, artFs)
|
||||
T.check(artInstalled ~= nil, "a cart with label art installs: " .. tostring(artHash))
|
||||
T.eq(artInstalled.labelArt.data, ART_DATA, "install returns the cart with its art")
|
||||
T.check(artFs.files["carts/art_cart.g1rcart"]:find(ART_DATA, 1, true) ~= nil,
|
||||
"install writes the art payload to the cart file")
|
||||
T.same(CartStore.get("art_cart", artFs), artFixture,
|
||||
"the installed cart reads back with its art")
|
||||
|
||||
local artless = select(2, bytesOf({ id = "art_cart", title = "Art Cart",
|
||||
label = "label.png" }))
|
||||
T.eq(artHash, CartManifest.hash(artless),
|
||||
"the art is not part of the hash a save pins itself to")
|
||||
|
||||
local artExported, artExportHash = CartStore.export("art_cart", artFs)
|
||||
T.eq(artExportHash, artHash, "export reports the same hash for an arted cart")
|
||||
T.eq(artExported, packed, "export hands back the bytes that were packed")
|
||||
local reread = CartManifest.decode(artExported)
|
||||
T.same(reread, artFixture, "the exported bytes decode to the same cart and art")
|
||||
T.eq(reread.labelArt.data, ART_DATA, "the exported payload is byte identical")
|
||||
|
||||
local sharedFs = memfs()
|
||||
T.check(CartStore.install(artExported, sharedFs) ~= nil,
|
||||
"an exported cart installs somewhere else")
|
||||
T.same(CartStore.get("art_cart", sharedFs), artFixture,
|
||||
"pack, install, export and install again leaves the cart unchanged")
|
||||
|
||||
local shownBytes, shownName = CartStore.labelArt("art_cart", sharedFs)
|
||||
T.eq(shownBytes, PNG, "labelArt hands back the decoded PNG")
|
||||
T.eq(shownName, "label.png", "labelArt hands back the art name")
|
||||
T.eq(CartStore.labelArt("nothing_here", sharedFs), nil,
|
||||
"labelArt refuses an unknown id")
|
||||
T.eq(CartStore.labelArt("../etc/passwd", sharedFs), nil,
|
||||
"labelArt refuses a climbing id")
|
||||
|
||||
local plainFs = memfs()
|
||||
T.check(CartStore.install(CartManifest.encode(artless), plainFs) ~= nil,
|
||||
"a cart with no art still installs")
|
||||
T.eq(CartStore.labelArt("art_cart", plainFs), nil,
|
||||
"a cart with no art has no label art")
|
||||
T.same(CartStore.get("art_cart", plainFs), artless,
|
||||
"a cart with no art round trips exactly as before")
|
||||
|
||||
local tamperedBytes = (packed:gsub("bytes = " .. #PNG, "bytes = " .. (#PNG + 1), 1))
|
||||
T.neq(tamperedBytes, packed, "the tampered bundle really changed")
|
||||
local tamperedFs = memfs()
|
||||
local tampered, tamperedErr = CartStore.install(tamperedBytes, tamperedFs)
|
||||
T.check(tampered ~= nil, "bad art never fails the install: " .. tostring(tamperedErr))
|
||||
T.eq(tampered.labelArt, nil, "the bad art is dropped")
|
||||
T.eq(CartStore.labelArt("art_cart", tamperedFs), nil,
|
||||
"the installed cart shows no art")
|
||||
T.eq(tamperedFs.files["carts/art_cart.g1rcart"], CartManifest.encode(artless),
|
||||
"the bad art is not written back to disk")
|
||||
T.eq(#CartStore.list(tamperedFs), 1, "the cart still lists without its art")
|
||||
|
||||
T.finish("cart_store")
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Crystal's boot copyright card is white text on black: SplashScreen sets
|
||||
-- SCGB_GAMEFREAK_LOGO before calling Copyright, so the card runs on
|
||||
-- PREDEFPAL_GAMEFREAK_LOGO_BG rather than the default BGP Gold's DMG boot
|
||||
-- leaves up (../pokecrystal/engine/movie/splash.asm:21-30). The card used to
|
||||
-- hardcode a white fill and Crystal shipped no extracted splash at all, so the
|
||||
-- first screen of the game was blank white.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
|
||||
|
||||
local function colorsEq(got, want, message)
|
||||
T.eq(#got, 3, message .. " (three channels)")
|
||||
for index = 1, 3 do
|
||||
T.eq(got[index], want[index], message .. " channel " .. index)
|
||||
end
|
||||
end
|
||||
|
||||
local gold = CopyrightSplash.new({}, { title = {} })
|
||||
colorsEq(gold.backdrop, { 1, 1, 1 }, "Gold keeps the white card")
|
||||
colorsEq(gold.ink, { 0, 0, 0 }, "Gold keeps black text")
|
||||
|
||||
local crystal = CopyrightSplash.new({}, {
|
||||
title = {
|
||||
copyrightBackdrop = { 0, 0, 0 },
|
||||
copyrightInk = { 1, 1, 1 },
|
||||
},
|
||||
})
|
||||
colorsEq(crystal.backdrop, { 0, 0, 0 }, "Crystal's card is black")
|
||||
colorsEq(crystal.ink, { 1, 1, 1 }, "Crystal's text is white")
|
||||
|
||||
-- The extractor has to emit the image too, or the card is an empty backdrop.
|
||||
local CrystalMovie = require("src.import.CrystalMovie")
|
||||
T.eq(type(CrystalMovie.extractTitle), "function",
|
||||
"CrystalMovie still owns the Crystal title stage")
|
||||
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
local required = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.crystal
|
||||
local found = false
|
||||
for _, path in ipairs(required) do
|
||||
if path == "assets/generated/title/copyright_splash.png" then found = true end
|
||||
end
|
||||
T.eq(found, true, "a Crystal cache without the splash is rebuilt")
|
||||
|
||||
T.finish("copyright splash backdrop")
|
||||
@@ -0,0 +1,260 @@
|
||||
-- Crystal registration: VERSIONS row, engine lineage, ORDER slot, sha1
|
||||
-- routing, the importer's required-file override and the script dialect.
|
||||
-- luajit tests/engine/crystal_version_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("crystal version registration")
|
||||
local check = S.check
|
||||
local eq = S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Opcodes = require("src.script.gen2.Opcodes")
|
||||
|
||||
-- ------- 1. the VERSIONS row
|
||||
|
||||
local row = GameVersion.VERSIONS.crystal
|
||||
check(row ~= nil, "GameVersion.VERSIONS carries a crystal row")
|
||||
eq(row.id, "crystal", "row id")
|
||||
eq(row.label, "Crystal", "row label")
|
||||
eq(row.displayName, "Pokemon Crystal", "row display name")
|
||||
eq(row.sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133", "retail Crystal sha1")
|
||||
eq(row.manifest, "tools/rom_manifest_crystal.json", "row manifest path")
|
||||
eq(row.cachePrefix, "crystal/", "cache prefix")
|
||||
eq(row.saveSuffix, "_crystal", "save suffix")
|
||||
eq(GameVersion.cachePrefix("crystal"), "crystal/", "cachePrefix() agrees")
|
||||
eq(GameVersion.saveSuffix("crystal"), "_crystal", "saveSuffix() agrees")
|
||||
|
||||
local prefixes, suffixes = {}, {}
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
local info = GameVersion.info(id)
|
||||
eq(prefixes[info.cachePrefix], nil, id .. " cache prefix is unique")
|
||||
eq(suffixes[info.saveSuffix], nil, id .. " save suffix is unique")
|
||||
prefixes[info.cachePrefix] = id
|
||||
suffixes[info.saveSuffix] = id
|
||||
end
|
||||
|
||||
-- ------- 2. generation and engine lineage
|
||||
|
||||
eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
|
||||
eq(GameVersion.engine("crystal"), "crystal", "Crystal's engine lineage")
|
||||
eq(GameVersion.engine("gold"), "gs", "Gold is the gs lineage")
|
||||
eq(GameVersion.engine("silver"), "gs", "and so is Silver")
|
||||
eq(GameVersion.engine("red"), "gen1", "Red is gen1")
|
||||
eq(GameVersion.engine("blue"), "gen1", "Blue is gen1")
|
||||
eq(GameVersion.engine("yellow"), "gen1", "Yellow is gen1")
|
||||
|
||||
local LINEAGES = { gen1 = true, gs = true, crystal = true }
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
check(LINEAGES[GameVersion.engine(id)] == true,
|
||||
id .. " reports a known engine lineage")
|
||||
end
|
||||
|
||||
eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
|
||||
eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
|
||||
eq(GameVersion.generation("red"), 1, "Red is Gen 1")
|
||||
|
||||
-- ------- 3. launcher ORDER
|
||||
|
||||
local index
|
||||
for i, id in ipairs(GameVersion.ORDER) do
|
||||
if id == "crystal" then index = i end
|
||||
end
|
||||
eq(index, 6, "crystal is ORDER slot 6")
|
||||
eq(#GameVersion.ORDER, 6, "ORDER is six games")
|
||||
eq(GameVersion.ORDER[4], "gold", "gold keeps slot 4")
|
||||
eq(GameVersion.ORDER[5], "silver", "silver keeps slot 5")
|
||||
|
||||
-- ------- 4. sha1 routing
|
||||
|
||||
eq(GameVersion.forSha1("f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"), "crystal",
|
||||
"the retail Crystal 1.0 sha1 resolves to crystal")
|
||||
eq(GameVersion.forSha1("f2f52230b536214ef7c9924f483392993e226cfb"), "crystal",
|
||||
"the retail Crystal 1.1 sha1 also resolves to crystal")
|
||||
eq(GameVersion.forSha1("d8b8a3600a465308c9953dfa04f0081c05bdcb94"), "gold",
|
||||
"Gold's sha1 still resolves to gold")
|
||||
eq(GameVersion.forSha1("deadbeef"), nil, "an unknown ROM resolves to nothing")
|
||||
|
||||
-- ------- 4b. revisions / acceptsSha1 / revisionLabel
|
||||
|
||||
local crystalRevisions = GameVersion.revisions("crystal")
|
||||
eq(#crystalRevisions, 2, "crystal lists both accepted revisions")
|
||||
eq(crystalRevisions[1].sha1, "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133",
|
||||
"revision 1 is the 1.0 sha1")
|
||||
eq(crystalRevisions[1].label, "1.0", "revision 1 is labeled 1.0")
|
||||
eq(crystalRevisions[2].sha1, "f2f52230b536214ef7c9924f483392993e226cfb",
|
||||
"revision 2 is the 1.1 sha1")
|
||||
eq(crystalRevisions[2].label, "1.1", "revision 2 is labeled 1.1")
|
||||
|
||||
check(GameVersion.acceptsSha1("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"),
|
||||
"crystal accepts the 1.0 sha1")
|
||||
check(GameVersion.acceptsSha1("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"),
|
||||
"crystal accepts the 1.1 sha1")
|
||||
check(not GameVersion.acceptsSha1("crystal", "deadbeef"),
|
||||
"crystal rejects an unknown sha1")
|
||||
|
||||
eq(GameVersion.revisionLabel("crystal", "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"),
|
||||
"1.0", "revisionLabel resolves the 1.0 hash")
|
||||
eq(GameVersion.revisionLabel("crystal", "f2f52230b536214ef7c9924f483392993e226cfb"),
|
||||
"1.1", "revisionLabel resolves the 1.1 hash")
|
||||
eq(GameVersion.revisionLabel("crystal", "deadbeef"), nil,
|
||||
"revisionLabel is nil for an unrecognized hash")
|
||||
|
||||
local goldRevisions = GameVersion.revisions("gold")
|
||||
eq(#goldRevisions, 1, "gold synthesizes a single revision entry")
|
||||
eq(goldRevisions[1].sha1, GameVersion.info("gold").sha1,
|
||||
"the synthesized entry carries gold's canonical sha1")
|
||||
check(GameVersion.acceptsSha1("gold", GameVersion.info("gold").sha1),
|
||||
"gold accepts its own canonical sha1")
|
||||
check(not GameVersion.acceptsSha1("gold", "deadbeef"),
|
||||
"gold rejects an unknown sha1")
|
||||
eq(GameVersion.revisionLabel("gold", GameVersion.info("gold").sha1), nil,
|
||||
"gold's synthesized entry carries no label")
|
||||
|
||||
local redRevisions = GameVersion.revisions("red")
|
||||
eq(#redRevisions, 1, "red synthesizes a single revision entry")
|
||||
check(GameVersion.acceptsSha1("red", GameVersion.info("red").sha1),
|
||||
"red accepts its own canonical sha1")
|
||||
eq(GameVersion.forSha1(GameVersion.info("red").sha1), "red",
|
||||
"and forSha1 still resolves red through the synthesized entry")
|
||||
|
||||
-- ------- 5. set / get round trip
|
||||
|
||||
local savedCurrent = GameVersion.get()
|
||||
eq(GameVersion.set("crystal"), "crystal", "set('crystal') is accepted")
|
||||
eq(GameVersion.get(), "crystal", "and becomes current")
|
||||
eq(GameVersion.engine(), "crystal", "engine() with no argument reads current")
|
||||
check(not GameVersion.isGold(), "isGold() stays false on Crystal")
|
||||
GameVersion.set(savedCurrent)
|
||||
|
||||
-- ------- 6. the importer's required-file list
|
||||
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
|
||||
local function requiredSet(version)
|
||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||
local seen, count = {}, 0
|
||||
local function add(path)
|
||||
if not seen[prefix .. path] then count = count + 1 end
|
||||
seen[prefix .. path] = true
|
||||
end
|
||||
for _, path in ipairs(required) do add(path) end
|
||||
if not isOverride then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
add(path)
|
||||
end
|
||||
end
|
||||
return seen, count
|
||||
end
|
||||
|
||||
local crystalSeen, crystalCount = requiredSet("crystal")
|
||||
local redSeen = requiredSet("red")
|
||||
|
||||
check(select(2, CacheContract.requiredFilesFor("crystal")) == true,
|
||||
"crystal has its own required-file override, not the Gen 1 list")
|
||||
check(crystalCount > 20,
|
||||
("the crystal list is substantial (%d entries)"):format(crystalCount))
|
||||
for _, path in ipairs({
|
||||
"crystal/data/generated/encounters.lua",
|
||||
"crystal/data/generated/landmarks.lua",
|
||||
"crystal/data/generated/title.lua",
|
||||
"crystal/data/generated/intro.lua",
|
||||
"crystal/assets/generated/title/crystal_logo.png",
|
||||
"crystal/assets/generated/title/crystal_wordmark.png",
|
||||
"crystal/assets/generated/title/crystal_suicune.png",
|
||||
"crystal/assets/generated/splash/ditto.png",
|
||||
"crystal/assets/generated/intro/chris.png",
|
||||
"crystal/assets/generated/intro/kris.png",
|
||||
"crystal/assets/generated/battle/front/wooper.png",
|
||||
}) do
|
||||
check(crystalSeen[path] == true, "crystal requires " .. path)
|
||||
end
|
||||
|
||||
for _, path in ipairs({
|
||||
"crystal/assets/generated/battle/anims/move_anim_0.png",
|
||||
"crystal/assets/generated/battle/anims/move_anim_1.png",
|
||||
"crystal/data/generated/battle_anims.lua",
|
||||
"crystal/assets/generated/trade/game_boy.png",
|
||||
}) do
|
||||
eq(crystalSeen[path], nil, "crystal does not wait on " .. path)
|
||||
end
|
||||
|
||||
check(redSeen["assets/generated/battle/anims/move_anim_0.png"] == true,
|
||||
"red still requires the Gen 1 battle anim sheet")
|
||||
eq(redSeen["assets/generated/title/crystal_logo.png"], nil,
|
||||
"and none of Crystal's title art")
|
||||
|
||||
-- ------- 7. script dialect
|
||||
|
||||
local crystalTable = Opcodes.forEdition("crystal")
|
||||
local goldTable = Opcodes.forEdition("gold")
|
||||
check(crystalTable ~= goldTable,
|
||||
"forEdition('crystal') is not the Gold table")
|
||||
eq(Opcodes.forEdition("silver"), goldTable, "Silver shares Gold's table")
|
||||
eq(goldTable, Opcodes, "and Gold's table is the module itself")
|
||||
eq(Opcodes.forEdition(nil), Opcodes, "an absent edition falls back to Gold")
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:541
|
||||
eq(crystalTable[0x52] and crystalTable[0x52].name, "farjumptext",
|
||||
"Crystal $52 is farjumptext")
|
||||
eq(goldTable[0x52] and goldTable[0x52].name, "jumptext",
|
||||
"Gold $52 is jumptext")
|
||||
eq(crystalTable[0x53] and crystalTable[0x53].name, "jumptext",
|
||||
"and Crystal's jumptext moved to $53")
|
||||
|
||||
local function count(tbl)
|
||||
local n = 0
|
||||
for key in pairs(tbl) do if type(key) == "number" then n = n + 1 end end
|
||||
return n
|
||||
end
|
||||
eq(count(crystalTable), 170, "Crystal names 170 commands")
|
||||
eq(count(goldTable), 162, "Gold names 162")
|
||||
|
||||
local diverged
|
||||
for byte = 0x00, 0x51 do
|
||||
local a = crystalTable[byte] and crystalTable[byte].name
|
||||
local b = goldTable[byte] and goldTable[byte].name
|
||||
if a ~= b then diverged = byte; break end
|
||||
end
|
||||
eq(diverged, nil, "$00-$51 are the same commands in both dialects")
|
||||
|
||||
check(Opcodes.TERMINATORS.farjumptext == true,
|
||||
"farjumptext is a terminator")
|
||||
|
||||
-- ------- 8. the launcher reaches the crystal tab
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local visited = {}
|
||||
local fake = setmetatable({ tab = GameVersion.ORDER[1] }, RomImporter)
|
||||
fake._switchTab = function(self, id)
|
||||
self.tab = id
|
||||
visited[#visited + 1] = id
|
||||
end
|
||||
|
||||
for _ = 1, 12 do fake:_cycleTab(1) end
|
||||
local sawCrystal, sawMods, sawBug = false, false, false
|
||||
for _, id in ipairs(visited) do
|
||||
if id == "crystal" then sawCrystal = true end
|
||||
if id == "mods" then sawMods = true end
|
||||
if id == "bug" then sawBug = true end
|
||||
end
|
||||
check(sawCrystal, "cycling the launcher tabs reaches crystal")
|
||||
check(sawMods and sawBug, "and still reaches the mods and bug tabs")
|
||||
|
||||
local seen, cycle = {}, 0
|
||||
fake.tab = "crystal"
|
||||
repeat
|
||||
seen[fake.tab] = true
|
||||
fake:_cycleTab(1)
|
||||
cycle = cycle + 1
|
||||
until fake.tab == "crystal" or cycle > 40
|
||||
eq(cycle, #GameVersion.ORDER + 4,
|
||||
"the ring is the six games plus mods/find/skins/bug")
|
||||
|
||||
fake.tab = "crystal"
|
||||
fake:_cycleTab(-1)
|
||||
eq(fake.tab, "silver", "and stepping back off crystal lands on silver")
|
||||
|
||||
S.finish()
|
||||
@@ -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")
|
||||
|
||||
@@ -24,6 +24,7 @@ T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
|
||||
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
|
||||
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
|
||||
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
|
||||
T.eq(GameVersion.generation("crystal"), 2, "Crystal is Gen 2")
|
||||
|
||||
-- ------- 2. manifest: gen2compat is opt-in and defaults off
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- Chrome.printThrough (src/ui/gen2/Chrome.lua) used to run every string
|
||||
-- through the GbcPalette shade-remap shader whenever a palette was given,
|
||||
-- with no regard for whether the glyph it was about to draw came from a
|
||||
-- tile page or from a TTF. The shader recovers a shade by reading the
|
||||
-- RED CHANNEL of an already-rasterized 2bpp tile pixel (SHADER_SOURCE in
|
||||
-- src/render/GbcPalette.lua); a TTF glyph is LÖVE's own anti-aliased
|
||||
-- coverage mask, drawn as plain white with the current tint carrying the
|
||||
-- ink colour, which that same channel read always reports as shade 0 --
|
||||
-- painting every character the SAME colour as the paper rect printThrough
|
||||
-- had just drawn behind it, i.e. invisible. Reported against a real Gold
|
||||
-- build running a TTF translation mod: the naming screen's keyboard,
|
||||
-- Diploma and Pokegear text all vanish, since all three draw through this
|
||||
-- one routine (gen1recomp#1642).
|
||||
--
|
||||
-- The switch is per GLYPH, not per string: a TTF-mod build still keeps
|
||||
-- multi-byte charmap sequences (the naming screen's own <PK>/<MN> cells,
|
||||
-- the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on their ROM
|
||||
-- tiles (src/render/Font.lua's Font.split), so one call can mix both kinds
|
||||
-- of glyph and each must take its own path.
|
||||
--
|
||||
-- No real shader runs headless (love_stub does not stub newShader), so this
|
||||
-- cannot check a rendered pixel. Font.encode/drawCode/advanceOf/width are
|
||||
-- replaced with fakes that hand printThrough a fixed list of glyph codes,
|
||||
-- so what is checked is the two things that decide the outcome: which
|
||||
-- glyphs skip the shader, and what colour is active when each one draws.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
-- Palette arrays are 1-indexed shade 0..3, same order GbcPalette.useRaw
|
||||
-- reads (src/render/GbcPalette.lua channel()).
|
||||
local PALETTE = { { 200, 220, 255 }, { 150, 170, 220 }, { 90, 100, 160 }, { 10, 10, 30 } }
|
||||
|
||||
local TILE_CODE = 0x80
|
||||
local TTF_CODE = Font.TTF_BASE + 65 -- 'A', were it decoded
|
||||
|
||||
-- "text" is a plain list of glyph codes for this suite; Font.width/encode
|
||||
-- pass it straight through, matching how a real caller's string would
|
||||
-- decode to a list of codes.
|
||||
Font.width = function(codes) return #codes * 8 end
|
||||
Font.encode = function(codes) return codes end
|
||||
Font.advanceOf = function(_code) return 8 end
|
||||
|
||||
local drawn
|
||||
Font.drawCode = function(code, x, y)
|
||||
drawn[#drawn + 1] = { code = code, x = x, y = y, color = { love.graphics.getColor() } }
|
||||
end
|
||||
|
||||
local useRawCalls
|
||||
local realUseRaw = GbcPalette.useRaw
|
||||
GbcPalette.useRaw = function(...)
|
||||
useRawCalls = useRawCalls + 1
|
||||
return realUseRaw(...)
|
||||
end
|
||||
|
||||
GbcPalette.available = function() return true end
|
||||
|
||||
local function colorsEq(a, b)
|
||||
return math.abs(a[1] - b[1]) < 1e-9 and math.abs(a[2] - b[2]) < 1e-9
|
||||
and math.abs(a[3] - b[3]) < 1e-9
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- all tile glyphs
|
||||
do
|
||||
useRawCalls = 0
|
||||
drawn = {}
|
||||
Chrome.printThrough({ TILE_CODE, TILE_CODE }, 0, 0, PALETTE)
|
||||
T.eq(useRawCalls, 1, "one shaded run binds the shader once, not per glyph")
|
||||
T.eq(#drawn, 2, "both glyphs drew")
|
||||
for i, d in ipairs(drawn) do
|
||||
T.check(colorsEq(d.color, { 1, 1, 1, 1 }),
|
||||
("tile glyph %d is tinted white, letting the shader pick the colour"):format(i))
|
||||
end
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------- all TTF glyphs
|
||||
do
|
||||
useRawCalls = 0
|
||||
drawn = {}
|
||||
Chrome.printThrough({ TTF_CODE, TTF_CODE }, 0, 0, PALETTE)
|
||||
T.eq(useRawCalls, 0, "a TTF glyph never binds the shade-remap shader")
|
||||
T.eq(#drawn, 2, "both glyphs drew")
|
||||
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||
for i, d in ipairs(drawn) do
|
||||
T.check(colorsEq(d.color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||
("TTF glyph %d is tinted with the palette's own ink colour"):format(i))
|
||||
end
|
||||
end
|
||||
|
||||
-- --------------------------------------------- mixed: tile, TTF, then tile
|
||||
do
|
||||
useRawCalls = 0
|
||||
drawn = {}
|
||||
Chrome.printThrough({ TILE_CODE, TTF_CODE, TILE_CODE }, 0, 0, PALETTE)
|
||||
T.eq(useRawCalls, 2,
|
||||
"the shader re-binds once per return to a tile glyph, not once for the whole string")
|
||||
T.eq(#drawn, 3, "all three glyphs drew")
|
||||
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||
T.check(colorsEq(drawn[1].color, { 1, 1, 1, 1 }), "1st (tile) glyph: white/shaded")
|
||||
T.check(colorsEq(drawn[2].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||
"2nd (TTF) glyph, mid-string, still gets the ink tint")
|
||||
T.check(colorsEq(drawn[3].color, { 1, 1, 1, 1 }), "3rd (tile) glyph: shaded again")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------- inverted TTF ink
|
||||
do
|
||||
drawn = {}
|
||||
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE, true)
|
||||
local ink = Chrome.throughPalette(PALETTE, true)[4]
|
||||
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||
"an inverted call tints TTF ink with the inverted palette's own shade-3 entry")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- DMG mode's own TTF ink
|
||||
do
|
||||
GbcPalette.setMode("dmg")
|
||||
drawn = {}
|
||||
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE)
|
||||
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||
"DMG mode's own resolved palette (four grey hardware shades) still reaches the TTF ink")
|
||||
GbcPalette.setMode("gbc")
|
||||
end
|
||||
|
||||
T.finish("gen2_chrome_print_through_ttf_test")
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label
|
||||
-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the
|
||||
-- CONTINUE save-summary panel's labels (PLAYER <name>/BADGES/POKéDEX/TIME,
|
||||
-- or NO SAVE FILE) as bare literals, invisible to a translation mod's
|
||||
-- `strings` registry -- unlike the Gen 1 port's own title menu
|
||||
-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows
|
||||
-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a
|
||||
-- mod-loaded Strings catalog and checks the translated text reaches
|
||||
-- Font.draw, same technique as
|
||||
-- tests/engine/gen2_naming_screen_translation_test.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local drawn
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
width = function() return 0 end,
|
||||
}
|
||||
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function drawnAt(x, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == x and d.y == y then return d.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
|
||||
-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name
|
||||
-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER
|
||||
-- row at (5, 2).
|
||||
local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8
|
||||
local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8
|
||||
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
|
||||
|
||||
local SAVE = { player = { name = "GOLD" } }
|
||||
local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY
|
||||
|
||||
-- ---------------------------------------------- vanilla: no mod catalog
|
||||
do
|
||||
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE",
|
||||
"the title menu's first row draws in English with no mod loaded")
|
||||
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM",
|
||||
"and the clock box's AM/PM half")
|
||||
|
||||
drawn = {}
|
||||
menu:drawSavePanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
|
||||
"the CONTINUE save-summary panel too")
|
||||
|
||||
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
|
||||
drawn = {}
|
||||
noSaveMenu:drawSavePanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE",
|
||||
"and its no-summary fallback")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["CONTINUE"] = "CONTINUAR",
|
||||
["NEW GAME"] = "NUEVA PARTIDA",
|
||||
["OPTION"] = "OPCIÓN",
|
||||
["EXIT GAME"] = "SALIR",
|
||||
["PM"] = "PM_ES",
|
||||
["PLAYER %s"] = "JUGADOR %s",
|
||||
["BADGES"] = "MEDALLAS",
|
||||
["POKéDEX"] = "POKéDEX_ES",
|
||||
["TIME"] = "TIEMPO",
|
||||
["NO SAVE FILE"] = "SIN PARTIDA",
|
||||
},
|
||||
})
|
||||
|
||||
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR",
|
||||
"a mod catalog reaches the title menu's first row")
|
||||
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES",
|
||||
"and the clock box's AM/PM half")
|
||||
|
||||
drawn = {}
|
||||
menu:drawSavePanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD",
|
||||
"the save-summary panel's PLAYER row takes the mod's own word order")
|
||||
T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES")
|
||||
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX")
|
||||
T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME")
|
||||
|
||||
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
|
||||
drawn = {}
|
||||
noSaveMenu:drawSavePanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA",
|
||||
"and the no-summary fallback")
|
||||
|
||||
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||
-- leaving the catalog loaded past this point would still mistranslate
|
||||
-- every check below it in this file.
|
||||
Strings.load({})
|
||||
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||
end
|
||||
|
||||
T.finish("gen2_main_menu_translation_test")
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Gold's naming/keyboard screen (src/ui/gen2/NamingScreen.lua) had zero
|
||||
-- Strings() calls: every prompt (YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/
|
||||
-- BOX NAME?/NICKNAME?), the on-screen keyboard's own letters, and the
|
||||
-- lower/UPPER/DEL/END bottom-row labels were bare literals, invisible to a
|
||||
-- translation mod's `strings` registry (reported against a real Gold build,
|
||||
-- gen1recomp#1642). The Gen 1 naming screen (src/ui/NamingScreen.lua) already
|
||||
-- routes its title and every keyboard cell through Strings().
|
||||
--
|
||||
-- GbcPalette.available() is false headless (no real shader compiles), so
|
||||
-- Chrome.printThrough already falls back to the plain, unshaded Chrome.print
|
||||
-- -- this drives that path directly and checks the translated text reaches
|
||||
-- Font.draw, the same technique
|
||||
-- tests/engine/gen2_options_menu_translation_test.lua uses for the OPTION
|
||||
-- screen.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local drawn
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
}
|
||||
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function drawnAt(x, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == x and d.y == y then return d.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
|
||||
-- the prompt lands at tile (5, 2), the keyboard's first cell at (2, 8).
|
||||
local PROMPT_X, PROMPT_Y = 5 * 8, 2 * 8
|
||||
local FIRST_CELL_X, FIRST_CELL_Y = 2 * 8, 8 * 8
|
||||
|
||||
-- ---------------------------------------------- vanilla: no mod catalog
|
||||
do
|
||||
local screen = NamingScreen.new({}, { type = "player" })
|
||||
drawn = {}
|
||||
screen:drawPanel()
|
||||
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "YOUR NAME?",
|
||||
"the player-name prompt draws in English with no mod loaded")
|
||||
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "A",
|
||||
"and the keyboard's first cell too")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["YOUR NAME?"] = "TON NOM?",
|
||||
["A"] = "À",
|
||||
["lower"] = "minusc",
|
||||
["END"] = "FIN",
|
||||
["%s'S"] = "DE %s",
|
||||
["NICKNAME?"] = "SURNOM?",
|
||||
},
|
||||
})
|
||||
|
||||
local screen = NamingScreen.new({}, { type = "player" })
|
||||
drawn = {}
|
||||
screen:drawPanel()
|
||||
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "TON NOM?",
|
||||
"a mod catalog reaches the prompt")
|
||||
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "À",
|
||||
"and a keyboard cell")
|
||||
|
||||
-- The bottom row: lower/DEL/END at tile y = keyboardTop + bottom*2.
|
||||
local bottomY = (screen:keyboardTop() + screen:bottomRow() * 2) * 8
|
||||
T.eq(drawnAt(2 * 8, bottomY), "minusc", "the case-switch label is translated")
|
||||
T.eq(drawnAt(15 * 8, bottomY), "FIN", "and END, the way out of the screen")
|
||||
|
||||
-- The nickname header: two lines, the mon name folded into the first.
|
||||
local nickScreen = NamingScreen.new({}, { type = "nickname", monName = "BULBASAUR" })
|
||||
drawn = {}
|
||||
nickScreen:drawPanel()
|
||||
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "DE BULBASAUR",
|
||||
"the nickname header's first line takes the mod's own word order")
|
||||
T.eq(drawnAt(PROMPT_X, 4 * 8), "SURNOM?", "and its second line")
|
||||
|
||||
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||
-- leaving the catalog loaded past this point would still mistranslate
|
||||
-- every check below it in this file.
|
||||
Strings.load({})
|
||||
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||
end
|
||||
|
||||
T.finish("gen2_naming_screen_translation_test")
|
||||
@@ -0,0 +1,121 @@
|
||||
-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row
|
||||
-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF,
|
||||
-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS
|
||||
-- table, invisible to a translation mod's `strings` registry (reported
|
||||
-- against a real Gold build, gen1recomp#1642). This drives
|
||||
-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the
|
||||
-- translated text reaches Font.draw, for both a cart-original row (label +
|
||||
-- display value) and a port-added row (label only -- its value already
|
||||
-- comes pre-translated from the shared module it calls, same as the Gen 1
|
||||
-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the
|
||||
-- fallback is unchanged.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
-- Chrome.print (the only draw call this screen makes) goes straight to
|
||||
-- Font.draw, so recording that call is enough to see exactly what text
|
||||
-- reached the screen -- same technique as
|
||||
-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before
|
||||
-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's
|
||||
-- own `local Font = require(...)` captures this stub instead of the real
|
||||
-- module.
|
||||
local drawn
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
}
|
||||
|
||||
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function drawnAt(x, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == x and d.y == y then return d.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
|
||||
-- drawPanel puts labels at tile x=2 and values at tile x=11.
|
||||
local LABEL_X = 2 * 8
|
||||
local VALUE_X = 11 * 8
|
||||
|
||||
local function rowIndex(rows, id)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.id == id then return i end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- vanilla: no mod catalog
|
||||
do
|
||||
local menu = OptionsMenu.new({})
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED",
|
||||
"row 1's label draws in English with no mod loaded")
|
||||
-- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default.
|
||||
T.eq(drawnAt(VALUE_X, 3 * 8), "MID ",
|
||||
"and its cart-original display value too")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["TEXT SPEED"] = "VITESSE TEXTE",
|
||||
["MID "] = "MOY ",
|
||||
["CONTROLS"] = "COMMANDES",
|
||||
["CANCEL"] = "ANNULER",
|
||||
},
|
||||
})
|
||||
|
||||
local menu = OptionsMenu.new({})
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE",
|
||||
"a mod catalog reaches a cart-original row's label")
|
||||
T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ",
|
||||
"and its cart-original display value")
|
||||
|
||||
-- CONTROLS is the first port-added row; scroll to it so it lands in the
|
||||
-- VISIBLE_ROWS=7 window drawPanel actually draws.
|
||||
local index = rowIndex(menu.rows, "controls")
|
||||
T.check(index ~= nil, "CONTROLS is one of the rows")
|
||||
menu.index = index
|
||||
menu:ensureVisible()
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
local slot = index - menu.scroll
|
||||
T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES",
|
||||
"and a port-added row's label is translated too")
|
||||
|
||||
-- CANCEL is the last row, built into ROWS like any other -- there is no
|
||||
-- separate hook to fall through if this one row is missed.
|
||||
local cancelMenu = OptionsMenu.new({})
|
||||
local cancelIndex = #cancelMenu.rows
|
||||
T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL")
|
||||
cancelMenu.index = cancelIndex
|
||||
cancelMenu:ensureVisible()
|
||||
drawn = {}
|
||||
cancelMenu:drawPanel()
|
||||
local cancelSlot = cancelIndex - cancelMenu.scroll
|
||||
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER",
|
||||
"CANCEL, the way out of the menu, is translated too")
|
||||
|
||||
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||
-- leaving the catalog loaded past this point would still mistranslate
|
||||
-- every check below it in this file.
|
||||
Strings.load({})
|
||||
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||
end
|
||||
|
||||
T.finish("gen2_options_menu_translation_test")
|
||||
@@ -0,0 +1,159 @@
|
||||
-- The PC's CHANGE BOX save flow (src/ui/gen2/PcMenu.lua:savePrompt()) used to
|
||||
-- draw its overwrite/saving/done prompts and its YES/NO choice as bare
|
||||
-- literals, invisible to a translation mod's `strings` registry, even though
|
||||
-- the overwrite/saving prompts are the exact same two cart messages Gold's
|
||||
-- SAVE screen (src/ui/gen2/SaveMenu.lua) already routes through Strings().
|
||||
-- Same technique as tests/engine/gen2_save_menu_translation_test.lua: drives
|
||||
-- PcMenu:drawPanel() directly at each save phase with a mod-loaded Strings
|
||||
-- catalog and checks the translated text reaches Font.draw.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local drawn
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
width = function() return 0 end,
|
||||
}
|
||||
|
||||
local PcMenu = require("src.ui.gen2.PcMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function drawnAt(x, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == x and d.y == y then return d.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
|
||||
-- The save-prompt box sits at the same (0,12) origin SaveMenu.lua's does, so
|
||||
-- its two lines print at the same (1,14)/(1,16); PcMenu's own YESNO_X/Y
|
||||
-- (14,7) differ from SaveMenu's (0,7), so YES/NO print at (16,8)/(16,10).
|
||||
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
|
||||
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
|
||||
local YES_X, YES_Y = 16 * 8, 8 * 8
|
||||
local NO_X, NO_Y = 16 * 8, 10 * 8
|
||||
|
||||
-- One party mon so Boxes.canUsePc doesn't refuse to open the PC at all.
|
||||
local SAVE = { player = { name = "GOLD" }, party = { {} } }
|
||||
|
||||
local function newMenu()
|
||||
return PcMenu.new({}, {
|
||||
save = SAVE,
|
||||
saveExists = false,
|
||||
writer = function() return true end,
|
||||
})
|
||||
end
|
||||
|
||||
-- ---------------------------------------------- vanilla: no mod catalog
|
||||
do
|
||||
local menu = newMenu()
|
||||
menu.picking = true
|
||||
menu.pickIndex = 1
|
||||
menu.savePhase = "confirm"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "#MON BOX, data", "the confirm prompt draws in English with no mod loaded")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "will be saved. OK?", "and its second line")
|
||||
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
|
||||
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
|
||||
|
||||
menu.savePhase = "overwrite"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a",
|
||||
"the overwrite prompt, the same cart message SaveMenu.lua's SAVE screen shares")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
|
||||
|
||||
menu.savePhase = "saving"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
|
||||
|
||||
menu.savePhase, menu.saved = "done", true
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
|
||||
|
||||
menu.savePhase, menu.saved = "done", false
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
--
|
||||
-- Same catalog values as gen2_save_menu_translation_test.lua's own
|
||||
-- translated block: the overwrite/saving prompts, the saved/failed messages,
|
||||
-- and YES/NO are the exact same keys both screens read, so one translation
|
||||
-- covers both without a PcMenu-specific fork. Only the CHANGE BOX confirm
|
||||
-- prompt's key is new here.
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["YES"] = "OUI",
|
||||
["NO"] = "NON",
|
||||
["#MON BOX, data\nwill be saved. OK?"] = "Les donnees de la\nBOITE seront sauv.",
|
||||
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
|
||||
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
|
||||
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
|
||||
["Could not save."] = "Echec de sauvegarde.",
|
||||
},
|
||||
})
|
||||
|
||||
local menu = newMenu()
|
||||
menu.picking = true
|
||||
menu.pickIndex = 1
|
||||
menu.savePhase = "confirm"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Les donnees de la", "the confirm prompt")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "BOITE seront sauv.", "its second line")
|
||||
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
|
||||
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
|
||||
|
||||
menu.savePhase = "overwrite"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe",
|
||||
"the overwrite prompt, translated with no PcMenu-specific key")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
|
||||
|
||||
menu.savePhase = "saving"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
|
||||
|
||||
menu.savePhase, menu.saved = "done", true
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
|
||||
"the saved message folds the player name into the mod's own word order")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
|
||||
|
||||
menu.savePhase, menu.saved = "done", false
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
|
||||
|
||||
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||
-- leaving the catalog loaded past this point would still mistranslate
|
||||
-- every check below it in this file.
|
||||
Strings.load({})
|
||||
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||
end
|
||||
|
||||
T.finish("gen2_pcmenu_changebox_save_translation_test")
|
||||
@@ -0,0 +1,204 @@
|
||||
-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would
|
||||
-- you like to save the game?", the overwrite/saving/saved messages), the
|
||||
-- YES/NO choice, and the summary panel's labels (PLAYER <name>/BADGES/
|
||||
-- POKéDEX/TIME) as bare literals, invisible to a translation mod's
|
||||
-- `strings` registry -- unlike the Gen 1 port's own SAVE screen
|
||||
-- (src/ui/StartMenu.lua), which already routes the same rows through
|
||||
-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a
|
||||
-- mod-loaded Strings catalog and checks the translated text reaches
|
||||
-- Font.draw, same technique as
|
||||
-- tests/engine/gen2_naming_screen_translation_test.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
|
||||
local drawn
|
||||
package.loaded["src.render.Font"] = {
|
||||
draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||
end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
width = function() return 0 end,
|
||||
}
|
||||
|
||||
local SaveMenu = require("src.ui.gen2.SaveMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function drawnAt(x, y)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == x and d.y == y then return d.text end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
|
||||
-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at
|
||||
-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3).
|
||||
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
|
||||
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
|
||||
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
|
||||
local YES_X, YES_Y = 2 * 8, 8 * 8
|
||||
local NO_X, NO_Y = 2 * 8, 10 * 8
|
||||
|
||||
local SAVE = { player = { name = "GOLD" } }
|
||||
|
||||
-- ---------------------------------------------- vanilla: no mod catalog
|
||||
do
|
||||
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
|
||||
"the summary panel draws in English with no mod loaded")
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to",
|
||||
"and the confirm prompt's first line")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line")
|
||||
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
|
||||
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
|
||||
|
||||
menu.phase = "overwrite"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
|
||||
|
||||
menu.phase = "saving"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
|
||||
|
||||
menu.phase, menu.saved = "done", true
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
|
||||
|
||||
menu.phase, menu.saved = "done", false
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["PLAYER %s"] = "JOUEUR %s",
|
||||
["BADGES"] = "BADGES_FR",
|
||||
["POKéDEX"] = "POKéDEX_FR",
|
||||
["TIME"] = "TEMPS",
|
||||
["YES"] = "OUI",
|
||||
["NO"] = "NON",
|
||||
["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?",
|
||||
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
|
||||
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
|
||||
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
|
||||
["Could not save."] = "Echec de sauvegarde.",
|
||||
},
|
||||
})
|
||||
|
||||
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD",
|
||||
"the summary panel's PLAYER row takes the mod's own word order")
|
||||
T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES")
|
||||
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX")
|
||||
T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME")
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line")
|
||||
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
|
||||
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
|
||||
|
||||
menu.phase = "overwrite"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
|
||||
|
||||
menu.phase = "saving"
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
|
||||
|
||||
menu.phase, menu.saved = "done", true
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
|
||||
"the saved message folds the player name into the mod's own word order")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
|
||||
|
||||
menu.phase, menu.saved = "done", false
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
|
||||
|
||||
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||
-- leaving the catalog loaded past this point would still mistranslate
|
||||
-- every check below it in this file.
|
||||
Strings.load({})
|
||||
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||
end
|
||||
|
||||
-- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving
|
||||
-- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and
|
||||
-- SaveMenu.twoLines(), rather than duplicating them -- both exported below,
|
||||
-- both used by PcMenu's own translation test
|
||||
-- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked
|
||||
-- here that they stay callable the shape twoLines() expects: a table in,
|
||||
-- one \n-joined string out with the split back on load.
|
||||
do
|
||||
T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it",
|
||||
"OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text")
|
||||
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a",
|
||||
"and twoLines() splits its untranslated fallback back to the first line")
|
||||
T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it",
|
||||
"and its second line")
|
||||
T.eq(SaveMenu.SAVING_PROMPT_SOURCE, "SAVING… DON'T TURN\nOFF THE POWER.",
|
||||
"SAVING_PROMPT_SOURCE stays the cart's own \\n-joined text")
|
||||
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[1], "SAVING… DON'T TURN",
|
||||
"and twoLines() splits its untranslated fallback back to the first line")
|
||||
T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[2], "OFF THE POWER.",
|
||||
"and its second line")
|
||||
end
|
||||
|
||||
-- A translation with a THIRD line (a second embedded "\n") has nowhere on
|
||||
-- screen to go -- drawPanel's box has room for exactly two Chrome.print
|
||||
-- calls -- so it must not silently draw the literal newline byte as glyph
|
||||
-- garbage on the second line, and should warn so a translator notices.
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois",
|
||||
},
|
||||
})
|
||||
|
||||
local warned = {}
|
||||
require("src.core.Logger").warn = function(fmt, ...)
|
||||
warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
end
|
||||
|
||||
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box")
|
||||
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois",
|
||||
"the rest lands in the second slot rather than vanishing")
|
||||
T.check(#warned == 1, "and a single warning is logged")
|
||||
|
||||
drawn = {}
|
||||
menu:drawPanel()
|
||||
T.check(#warned == 1, "the warning does not repeat for the same text")
|
||||
|
||||
require("src.core.Logger").warn = function() end
|
||||
Strings.load({})
|
||||
end
|
||||
|
||||
T.finish("gen2_save_menu_translation_test")
|
||||
@@ -0,0 +1,292 @@
|
||||
-- Gen 2 script bytecode dialects: Gold/Silver vs Crystal.
|
||||
--
|
||||
-- Crystal inserts farjumptext at $52 and pushes every later opcode up by one
|
||||
-- (pokecrystal/macros/scripts/events.asm:541). Decoding a Crystal script with
|
||||
-- the Gold table is silent: a Crystal $53 `jumptext` (2 operand bytes) reads as
|
||||
-- Gold's `waitbutton` (0), the pointer walk desynchronises, and the extractor
|
||||
-- emits plausible garbage rather than an error. So the expected tables below
|
||||
-- are transcribed from the two macro files by hand and pinned here.
|
||||
-- luajit tests/engine/gen2_script_opcodes_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local Opcodes = require("src.script.gen2.Opcodes")
|
||||
|
||||
-- pokegold/macros/scripts/events.asm:1-1015, in const order from $00.
|
||||
-- Sizes are the macro body minus its own `db <name>_command`: db 1, dw 2,
|
||||
-- dba 3, bigdt 3, map_id 2 (pokegold/macros/scripts/maps.asm:1-6).
|
||||
-- `givepoke` is the one variable-length row: the macro emits 8 bytes when the
|
||||
-- trainer argument is non-zero (events.asm:352-365) and the table declares the
|
||||
-- 4-byte base, which src/import/RomExtractorGen2.lua re-measures per call site.
|
||||
local GOLD_EXPECTED = {
|
||||
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
|
||||
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
|
||||
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
|
||||
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
|
||||
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
|
||||
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
|
||||
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
|
||||
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
|
||||
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
|
||||
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
|
||||
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
|
||||
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
|
||||
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
|
||||
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
|
||||
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
|
||||
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
|
||||
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
|
||||
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
|
||||
"closewindow 0", "jumptextfaceplayer 2", "jumptext 2", "waitbutton 0",
|
||||
"promptbutton 0", "pokepic 1", "closepokepic 0", "_2dmenu 0",
|
||||
"verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
|
||||
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
|
||||
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
|
||||
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
|
||||
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
|
||||
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
|
||||
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
|
||||
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
|
||||
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
|
||||
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
|
||||
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
|
||||
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
|
||||
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
|
||||
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
|
||||
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
|
||||
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
|
||||
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
|
||||
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
|
||||
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2", "swarm 2",
|
||||
"halloffame 0", "credits 0", "warpfacing 5",
|
||||
}
|
||||
|
||||
-- pokecrystal/macros/scripts/events.asm:1-1068, same transcription rules.
|
||||
-- Cross-checked row for row against ScriptCommandTable
|
||||
-- (pokecrystal/engine/overworld/scripting.asm:64-237), which is the table the
|
||||
-- hardware actually indexes and therefore the authority over the macro file.
|
||||
local CRYSTAL_EXPECTED = {
|
||||
"scall 2", "farscall 3", "memcall 2", "sjump 2", "farsjump 3",
|
||||
"memjump 2", "ifequal 3", "ifnotequal 3", "iffalse 2", "iftrue 2",
|
||||
"ifgreater 3", "ifless 3", "jumpstd 2", "callstd 2", "callasm 3",
|
||||
"special 2", "memcallasm 2", "checkmapscene 2", "setmapscene 3",
|
||||
"checkscene 0", "setscene 1", "setval 1", "addval 1", "random 1",
|
||||
"checkver 0", "readmem 2", "writemem 2", "loadmem 3", "readvar 1",
|
||||
"writevar 1", "loadvar 2", "giveitem 2", "takeitem 2", "checkitem 1",
|
||||
"givemoney 4", "takemoney 4", "checkmoney 4", "givecoins 2",
|
||||
"takecoins 2", "checkcoins 2", "addcellnum 1", "delcellnum 1",
|
||||
"checkcellnum 1", "checktime 1", "checkpoke 1", "givepoke 4",
|
||||
"giveegg 2", "givepokemail 2", "checkpokemail 2", "checkevent 2",
|
||||
"clearevent 2", "setevent 2", "checkflag 2", "clearflag 2", "setflag 2",
|
||||
"wildon 0", "wildoff 0", "xycompare 2", "warpmod 3", "blackoutmod 2",
|
||||
"warp 4", "getmoney 2", "getcoins 1", "getnum 1", "getmonname 2",
|
||||
"getitemname 2", "getcurlandmarkname 1", "gettrainername 3",
|
||||
"getstring 3", "itemnotify 0", "pocketisfull 0", "opentext 0",
|
||||
"reanchormap 1", "closetext 0", "writeunusedbyte 1", "farwritetext 3",
|
||||
"writetext 2", "repeattext 2", "yesorno 0", "loadmenu 2",
|
||||
"closewindow 0", "jumptextfaceplayer 2", "farjumptext 3", "jumptext 2",
|
||||
"waitbutton 0", "promptbutton 0", "pokepic 1", "closepokepic 0",
|
||||
"_2dmenu 0", "verticalmenu 0", "loadpikachudata 0", "randomwildmon 0",
|
||||
"loadtemptrainer 0", "loadwildmon 2", "loadtrainer 2", "startbattle 0",
|
||||
"reloadmapafterbattle 0", "catchtutorial 1", "trainertext 1",
|
||||
"trainerflagaction 1", "winlosstext 4", "scripttalkafter 0",
|
||||
"endifjustbattled 0", "checkjustbattled 0", "setlasttalked 1",
|
||||
"applymovement 3", "applymovementlasttalked 2", "faceplayer 0",
|
||||
"faceobject 2", "variablesprite 2", "disappear 1", "appear 1",
|
||||
"follow 2", "stopfollow 0", "moveobject 3", "writeobjectxy 1",
|
||||
"loademote 1", "showemote 3", "turnobject 2", "follownotexact 2",
|
||||
"earthquake 1", "changemapblocks 3", "changeblock 3", "reloadmap 0",
|
||||
"refreshmap 0", "writecmdqueue 2", "delcmdqueue 1", "playmusic 2",
|
||||
"encountermusic 0", "musicfadeout 3", "playmapmusic 0",
|
||||
"dontrestartmapmusic 0", "cry 2", "playsound 2", "waitsfx 0",
|
||||
"warpsound 0", "specialsound 0", "autoinput 3", "newloadmap 1",
|
||||
"pause 1", "deactivatefacing 1", "sdefer 2", "warpcheck 0",
|
||||
"stopandsjump 2", "endcallback 0", "end 0", "reloadend 1", "endall 0",
|
||||
"pokemart 3", "elevator 2", "trade 1", "askforphonenumber 1",
|
||||
"phonecall 2", "hangup 0", "describedecoration 1", "fruittree 1",
|
||||
"specialphonecall 2", "checkphonecall 0", "verbosegiveitem 2",
|
||||
"verbosegiveitemvar 2", "swarm 3", "halloffame 0", "credits 0",
|
||||
"warpfacing 5", "battletowertext 1", "getlandmarkname 2",
|
||||
"gettrainerclassname 2", "getname 3", "wait 1", "checksave 0",
|
||||
}
|
||||
|
||||
local gold = Opcodes.forEdition("gold")
|
||||
local crystal = Opcodes.forEdition("crystal")
|
||||
|
||||
-- CT-1: gold and silver share one dialect, and Opcodes[byte] keeps answering.
|
||||
check(Opcodes.forEdition("silver") == gold, "silver resolves to the Gold table")
|
||||
check(gold == Opcodes, "the Gold table is the module itself (Opcodes[byte])")
|
||||
check(crystal ~= gold, "crystal resolves to a different table")
|
||||
check(Opcodes.forEdition(nil) == gold, "an unknown edition falls back to Gold")
|
||||
eq(Opcodes[0x52] and Opcodes[0x52].name, "jumptext",
|
||||
"Opcodes[0x52] is unchanged for existing callers")
|
||||
|
||||
local function auditTable(label, tbl, expected)
|
||||
local holes, wrong = {}, {}
|
||||
for i, want in ipairs(expected) do
|
||||
local byte = i - 1
|
||||
local name, size = want:match("^(%S+) (%d+)$")
|
||||
local row = tbl[byte]
|
||||
if not row then
|
||||
holes[#holes + 1] = ("$%02x"):format(byte)
|
||||
elseif row.name ~= name or row.size ~= tonumber(size) then
|
||||
wrong[#wrong + 1] = ("$%02x %s/%d wanted %s/%s")
|
||||
:format(byte, tostring(row.name), row.size or -1, name, size)
|
||||
end
|
||||
end
|
||||
eq(#holes, 0, label .. " has no missing opcode (" .. table.concat(holes, " ")
|
||||
.. ")")
|
||||
eq(#wrong, 0, label .. " matches events.asm (" .. table.concat(wrong, "; ")
|
||||
.. ")")
|
||||
local extra = {}
|
||||
for byte = 0x00, 0xff do
|
||||
if tbl[byte] and byte >= #expected then
|
||||
extra[#extra + 1] = ("$%02x %s"):format(byte, tbl[byte].name)
|
||||
end
|
||||
end
|
||||
eq(#extra, 0, label .. " declares nothing past the last command ("
|
||||
.. table.concat(extra, " ") .. ")")
|
||||
end
|
||||
|
||||
auditTable("gold", gold, GOLD_EXPECTED)
|
||||
auditTable("crystal", crystal, CRYSTAL_EXPECTED)
|
||||
|
||||
-- (a) $00-$51 is byte-identical between the dialects.
|
||||
local drift = {}
|
||||
for byte = 0x00, 0x51 do
|
||||
local g, c = gold[byte], crystal[byte]
|
||||
if not (g and c and g.name == c.name and g.size == c.size) then
|
||||
drift[#drift + 1] = ("$%02x"):format(byte)
|
||||
end
|
||||
end
|
||||
eq(#drift, 0, "$00-$51 is identical in both dialects ("
|
||||
.. table.concat(drift, " ") .. ")")
|
||||
|
||||
-- and the two dialects agree on NOTHING from $52 up, because the whole tail is
|
||||
-- shifted by one. farjumptext is the wedge.
|
||||
eq(crystal[0x52].name, "farjumptext", "$52 is farjumptext on Crystal")
|
||||
eq(crystal[0x52].size, 3, "farjumptext carries a dba, so 3 operand bytes")
|
||||
eq(crystal[0x53].name, "jumptext", "Gold's $52 jumptext moved to $53")
|
||||
eq(crystal[0xa0].size, 3,
|
||||
"Crystal swarm gained a leading flag byte (events.asm:1003-1008)")
|
||||
eq(gold[0x9e].size, 2, "Gold swarm is still a bare map_id")
|
||||
|
||||
-- (c) NUM_EVENT_COMMANDS, both as the declared constant and as the row count.
|
||||
local function rowCount(tbl)
|
||||
local n = 0
|
||||
for byte = 0x00, 0xff do if tbl[byte] then n = n + 1 end end
|
||||
return n
|
||||
end
|
||||
eq(Opcodes.NUM_EVENT_COMMANDS, 162,
|
||||
"pokegold events.asm:1015 NUM_EVENT_COMMANDS = $a2")
|
||||
eq(crystal.NUM_EVENT_COMMANDS, 170,
|
||||
"pokecrystal events.asm:1068 NUM_EVENT_COMMANDS = $aa")
|
||||
eq(rowCount(gold), Opcodes.NUM_EVENT_COMMANDS,
|
||||
"the Gold table is dense up to NUM_EVENT_COMMANDS")
|
||||
eq(rowCount(crystal), crystal.NUM_EVENT_COMMANDS,
|
||||
"the Crystal table is dense up to NUM_EVENT_COMMANDS")
|
||||
|
||||
-- (d) farjumptext ends the walk the same way jumptext does.
|
||||
check(Opcodes.TERMINATORS.farjumptext,
|
||||
"farjumptext is a TERMINATOR (jp ScriptJump, scripting.asm:318-327)")
|
||||
check(Opcodes.TERMINATORS.jumptext, "jumptext still is")
|
||||
check(crystal.TERMINATORS == Opcodes.TERMINATORS,
|
||||
"the Crystal table answers TERMINATORS too")
|
||||
eq(crystal.key, Opcodes.key, "and key(), so a resolved table is self-sufficient")
|
||||
|
||||
-- (e) MOD_COMMAND is reachable only by name: no byte in EITHER dialect decodes
|
||||
-- to it, so ROM data can never be mistaken for a mod verb.
|
||||
local collide = {}
|
||||
for byte = 0x00, 0xff do
|
||||
if gold[byte] and gold[byte].name == Opcodes.MOD_COMMAND then
|
||||
collide[#collide + 1] = ("gold $%02x"):format(byte)
|
||||
end
|
||||
if crystal[byte] and crystal[byte].name == Opcodes.MOD_COMMAND then
|
||||
collide[#collide + 1] = ("crystal $%02x"):format(byte)
|
||||
end
|
||||
end
|
||||
eq(#collide, 0, "MOD_COMMAND has no byte in either dialect ("
|
||||
.. table.concat(collide, " ") .. ")")
|
||||
check(type(Opcodes.MOD_COMMAND) == "string" and Opcodes.MOD_COMMAND ~= "",
|
||||
"MOD_COMMAND is a name, not a byte")
|
||||
|
||||
-- The Vm side of the dialect: every Crystal-only verb has a branch, and the
|
||||
-- shifted `swarm` reads its flag rather than its map group.
|
||||
love = require("tests.love_stub")
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
local Events = require("src.world.gen2.Events")
|
||||
|
||||
do
|
||||
local seen, swarmArgs = {}, nil
|
||||
local events = Events.new()
|
||||
local vm = Vm.new({
|
||||
generation = 2,
|
||||
["s:crystal"] = {
|
||||
-- pokecrystal/macros/scripts/events.asm:1003-1008: flag, then map_id.
|
||||
{ op = "swarm", args = { 1, 24, 3 } },
|
||||
{ op = "checksave" },
|
||||
{ op = "wait", args = { 2 } },
|
||||
{ op = "getlandmarkname", args = { 5, 3 } },
|
||||
{ op = "gettrainerclassname", args = { 9, 3 } },
|
||||
{ op = "getname", args = { 1, 152, 3 } },
|
||||
{ op = "verbosegiveitemvar", args = { 20, 7 } },
|
||||
{ op = "farjumptext", text = "t:far" },
|
||||
{ op = "setevent", event = 1 },
|
||||
},
|
||||
}, { ["t:far"] = "Far text." }, events, {
|
||||
setSwarm = function(group, mapNum, kind)
|
||||
swarmArgs = { group, mapNum, kind }
|
||||
end,
|
||||
checkSave = function() return true end,
|
||||
getLandmarkName = function(id) seen.landmark = id return "RUINS" end,
|
||||
getTrainerClassName = function(id) seen.class = id return "SAGE" end,
|
||||
getMonName = function(id) seen.mon = id return "CHIKORITA" end,
|
||||
readVar = function(id) seen.var = id return 4 end,
|
||||
giveItem = function(item, qty) seen.give = { item, qty } return true end,
|
||||
getItemName = function() return "REPEL" end,
|
||||
showText = function(body, onDone) seen.text = body onDone() end,
|
||||
})
|
||||
|
||||
check(vm:start("s:crystal"), "a Crystal-shaped script starts")
|
||||
for _ = 1, 40 do vm:update() end
|
||||
check(not vm:running(), "and runs to completion")
|
||||
|
||||
eq(swarmArgs and swarmArgs[1], 24, "swarm reads the map group from args[2]")
|
||||
eq(swarmArgs and swarmArgs[2], 3, "and the map number from args[3]")
|
||||
eq(swarmArgs and swarmArgs[3], 1, "and passes SWARM_YANMA through as the kind")
|
||||
eq(seen.landmark, 5, "getlandmarkname passes its landmark id to the hook")
|
||||
eq(seen.class, 9, "gettrainerclassname passes the trainer group")
|
||||
eq(seen.mon, 152, "getname with MON_NAME routes to the mon-name hook")
|
||||
eq(seen.var, 7, "verbosegiveitemvar reads the quantity out of a var")
|
||||
eq(seen.give and seen.give[1], 20, "and gives the item the first byte names")
|
||||
eq(seen.give and seen.give[2], 4, "at the quantity the var held")
|
||||
eq(seen.text, "Far text.", "farjumptext prints its text")
|
||||
eq(next(vm.unknownOps or {}), nil,
|
||||
"no Crystal verb in the script fell through to the unknown ledger")
|
||||
-- Script_farjumptext ends on `jp ScriptJump`, so the setevent after it never
|
||||
-- runs -- the same reading Opcodes.TERMINATORS encodes for the extractor.
|
||||
check(not events:get(1),
|
||||
"farjumptext ended the script before the setevent below it")
|
||||
events:set(1, true)
|
||||
check(events:get(1), "and the event really is observable when it is set")
|
||||
end
|
||||
|
||||
do
|
||||
-- Script_wait is SIX frames per unit (scripting.asm:2336-2347), not
|
||||
-- Script_pause's two.
|
||||
local vm = Vm.new({ generation = 2,
|
||||
["s:wait"] = { { op = "wait", args = { 3 } }, { op = "end" } },
|
||||
}, {}, nil, {})
|
||||
check(vm:start("s:wait"), "a lone `wait` starts")
|
||||
local frames = 0
|
||||
while vm:running() and frames < 100 do
|
||||
vm:update()
|
||||
frames = frames + 1
|
||||
end
|
||||
eq(frames, 18, "`wait 3` holds the script for 3 * 6 frames")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -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)")
|
||||
|
||||
@@ -39,7 +39,7 @@ end
|
||||
local edited = 0
|
||||
local hooks = { editTouchControls = function() edited = edited + 1 end }
|
||||
|
||||
for _, version in ipairs({ "gold", "silver" }) do
|
||||
for _, version in ipairs({ "gold", "silver", "crystal" }) do
|
||||
local model = LauncherSettings.open(hooks, version)
|
||||
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
|
||||
check(has(model, "VIBRATION"), version .. " and VIBRATION")
|
||||
@@ -82,6 +82,7 @@ for _, version in ipairs({ "gold", "silver" }) do
|
||||
version .. " leaving the flat Gen 1 key alone")
|
||||
eq(model.opts.silver, nil,
|
||||
version .. " and inventing no second Gen 2 block beside it")
|
||||
eq(model.opts.crystal, nil, version .. " nor a third")
|
||||
|
||||
local buzz = findRow(model, "VIBRATION")
|
||||
local buzzBefore = buzz.value()
|
||||
@@ -114,6 +115,8 @@ eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
|
||||
"no hook, no editor row on Gold")
|
||||
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
|
||||
"nor on Silver")
|
||||
eq(has(LauncherSettings.open(nil, "crystal"), "TOUCH CONTROLS"), false,
|
||||
"nor on Crystal")
|
||||
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
|
||||
"nor on Red")
|
||||
-- The Edit row hands the screen to the host, and the host has to know WHICH
|
||||
|
||||
@@ -75,7 +75,8 @@ end
|
||||
do
|
||||
local ri = launcher()
|
||||
check(ri:_findStats(entry("nulled", nil, "someone/nulled")) == nil,
|
||||
"a null count is not an answer; the repo is still consulted")
|
||||
"a null count is not an answer")
|
||||
ri:_requestFindStats(entry("nulled", nil, "someone/nulled"))
|
||||
eq(#fetched, 1, "which is the fetch the panel already made for dates")
|
||||
|
||||
local bare = launcher()
|
||||
@@ -83,7 +84,7 @@ do
|
||||
check(stats ~= nil and stats.total == nil,
|
||||
"a listing with neither counts nor a repo is resolved-but-unknown")
|
||||
check(stats.recent == nil, "and has nothing to trend on")
|
||||
eq(#fetched, 1, "and queues nothing of its own")
|
||||
eq(#fetched, 1, "and the explicit scheduler queues nothing without a repo")
|
||||
end
|
||||
|
||||
-- A real zero is not unknown: the index has seen the releases and counted
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Launcher navigation performance seams. MOD INDEX must not pay the full
|
||||
-- MODS validation pass, background index prefetch must not create a blocking
|
||||
-- overlay, and visible-row enrichment must be scheduled from update state
|
||||
-- rather than from immediate-mode draw calls.
|
||||
-- luajit tests/engine/launcher_navigation_perf.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
check(type(LauncherMods.installedVersions) == "function",
|
||||
"LauncherMods exposes a lightweight installed-version scan")
|
||||
|
||||
do
|
||||
local old = LauncherMods.installedVersions
|
||||
local called = 0
|
||||
LauncherMods.installedVersions = function()
|
||||
called = called + 1
|
||||
return { alpha = "1.2.3" }
|
||||
end
|
||||
local imp = setmetatable({}, RomImporter)
|
||||
local installed = imp:_findInstalledMap()
|
||||
eq(called, 1, "MOD INDEX asks for the lightweight scan when MODS is cold")
|
||||
eq(installed.alpha, "1.2.3",
|
||||
"the lightweight scan supplies installed versions")
|
||||
LauncherMods.installedVersions = old
|
||||
end
|
||||
|
||||
do
|
||||
local imp = setmetatable({ tab = "find", findLoaded = false }, RomImporter)
|
||||
imp._refreshFindSources = function(self)
|
||||
self.findSources = { { feed = "https://example.invalid/index.json" } }
|
||||
end
|
||||
local oldBegin = ModIndex.beginFetch
|
||||
ModIndex.beginFetch = function() return { test = true } end
|
||||
imp:_refreshFind(false)
|
||||
check(imp._findFetch ~= nil, "background index refresh starts asynchronously")
|
||||
eq(imp._busy, nil,
|
||||
"background index refresh does not block navigation with a loader")
|
||||
imp:_clearBusy()
|
||||
imp._findFetch = nil
|
||||
ModIndex.beginFetch = oldBegin
|
||||
end
|
||||
|
||||
do
|
||||
local requests = 0
|
||||
local imp = setmetatable({ tab = "find", findLoaded = true,
|
||||
_findVisibleEntries = {
|
||||
{ id = "one", thumbnail = "one.png", github = "a/one" },
|
||||
{ id = "two", thumbnail = "two.png", github = "a/two" },
|
||||
{ id = "three", thumbnail = "three.png", github = "a/three" },
|
||||
} }, RomImporter)
|
||||
imp._findThumb = function() return nil end
|
||||
imp._findThumbPending = function() return false end
|
||||
imp._findStatsCached = function() return nil end
|
||||
imp._startFindThumb = function() requests = requests + 1 end
|
||||
imp._requestFindStats = function() requests = requests + 1 end
|
||||
imp:_queueFindEnrichment()
|
||||
eq(requests, 4,
|
||||
"update schedules a bounded thumbnail and stats batch for visible rows")
|
||||
end
|
||||
|
||||
do
|
||||
local requests = 0
|
||||
local imp = setmetatable({}, RomImporter)
|
||||
imp._findStatsCached = function() return nil end
|
||||
imp._requestFindStats = function() requests = requests + 1 end
|
||||
imp:_findStats({ id = "draw-only", github = "a/draw-only" })
|
||||
eq(requests, 0, "reading row stats during draw never starts a request")
|
||||
end
|
||||
|
||||
do
|
||||
local f = assert(io.open("src/import/LauncherView.lua", "rb"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
local start = assert(src:find("local function buildFindPanel", 1, true))
|
||||
local finish = assert(src:find("\nlocal function ", start + 1, true))
|
||||
local panel = src:sub(start, finish - 1)
|
||||
check(not panel:find("imp:_ensureMods()", 1, true),
|
||||
"MOD INDEX panel does not force the full MODS list")
|
||||
end
|
||||
|
||||
T.finish("launcher_navigation_perf")
|
||||
@@ -1,5 +1,5 @@
|
||||
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
|
||||
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
|
||||
-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush.
|
||||
-- luajit tests/engine/launcher_session_teardown_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime")
|
||||
local Assets = require("src.render.Assets")
|
||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||
local Game = require("src.core.Game")
|
||||
local Game2 = require("src.core.Game2")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
-- ---- Game:reset drops instance state, keeps methods ----------------------
|
||||
do
|
||||
@@ -38,6 +42,35 @@ do
|
||||
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
|
||||
end
|
||||
|
||||
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
||||
do
|
||||
local game2 = Game2.new()
|
||||
local canvas = love.graphics.newCanvas(4, 4)
|
||||
game2.world = World.new({})
|
||||
game2.world.mapImages = { ["MAP|d|1"] = canvas }
|
||||
game2._canvases = { love.graphics.newCanvas(8, 8) }
|
||||
game2:reset()
|
||||
check(canvas.released == true, "Game2:reset releases World mapImages")
|
||||
check(game2.world == nil, "Game2:reset clears world reference")
|
||||
check(game2._canvases == nil, "Game2:reset clears _canvases")
|
||||
end
|
||||
|
||||
-- ---- World:release frees owned GPU caches --------------------------------
|
||||
do
|
||||
local world = World.new({})
|
||||
local bake = love.graphics.newCanvas(16, 16)
|
||||
local strip = love.graphics.newCanvas(8, 64)
|
||||
local tilt = love.graphics.newCanvas(160, 144)
|
||||
world.mapImages = { ["R1|DAY|1"] = bake }
|
||||
world.scrollStrips = { ["TS|1|0,0"] = strip }
|
||||
world.tiltCanvas = tilt
|
||||
world:release()
|
||||
check(bake.released == true, "World:release frees map bake canvases")
|
||||
check(strip.released == true, "World:release frees scroll strips")
|
||||
check(tilt.released == true, "World:release frees tiltCanvas")
|
||||
eq(next(world.mapImages), nil, "World:release clears mapImages table")
|
||||
end
|
||||
|
||||
-- ---- Renderer:init releases prior canvases before realloc ----------------
|
||||
do
|
||||
local first = love.graphics.newCanvas(16, 16)
|
||||
@@ -52,14 +85,13 @@ do
|
||||
"Renderer:init allocates a fresh primary canvas")
|
||||
check(Renderer.canvas.released ~= true,
|
||||
"the new primary canvas is not released")
|
||||
-- second init also releases the one just created
|
||||
local second = Renderer.canvas
|
||||
Renderer:init()
|
||||
check(second.released == true,
|
||||
"a second Renderer:init releases the canvas from the prior init")
|
||||
end
|
||||
|
||||
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
|
||||
-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) --
|
||||
do
|
||||
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
|
||||
Assets.installLoader({
|
||||
@@ -68,44 +100,79 @@ do
|
||||
})
|
||||
LegacyCompat.reports = { some_mod = { order = {} } }
|
||||
|
||||
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
Runtime.reset()
|
||||
Assets.installLoader(nil)
|
||||
LegacyCompat.reset()
|
||||
SessionLifecycle.endMountedSession(nil)
|
||||
|
||||
check(Runtime.errors == nil, "teardown clears Runtime.errors")
|
||||
check(Assets.loader == nil, "teardown clears Assets.loader")
|
||||
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
|
||||
check(Runtime.errors == nil, "endMountedSession clears Runtime.errors")
|
||||
check(Assets.loader == nil, "endMountedSession clears Assets.loader")
|
||||
eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports")
|
||||
end
|
||||
|
||||
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
|
||||
-- ---- releaseSession empties MapLoader via releaseAll, not flush -------------
|
||||
do
|
||||
local data = {
|
||||
maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1,
|
||||
blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } },
|
||||
tilesets = { TS = { id = "TS", image = "assets/generated/t.png",
|
||||
walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } },
|
||||
tilesPerRow = 1 } },
|
||||
}
|
||||
MapLoader.load(data, "T1")
|
||||
check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release")
|
||||
Assets.releaseSession()
|
||||
check(MapLoader.cached("T1") == nil,
|
||||
"releaseSession evicts MapLoader via releaseAll")
|
||||
end
|
||||
|
||||
-- ---- Editor package.loaded discovery flush via endEditorSession -----------
|
||||
do
|
||||
love.filesystem.write("tools/save-editor/App.lua", "return {}")
|
||||
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
|
||||
package.loaded["App"] = { stale = true }
|
||||
package.loaded["NewPanel"] = { stale = true }
|
||||
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
|
||||
package.loaded["src.core.Data"] = package.loaded["src.core.Data"]
|
||||
|
||||
local function isEditorFlat(name)
|
||||
if name:find("[./]") then return false end
|
||||
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||
end
|
||||
for k in pairs(package.loaded) do
|
||||
if type(k) == "string"
|
||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||
package.loaded[k] = nil
|
||||
end
|
||||
end
|
||||
SessionLifecycle.endEditorSession({ version = nil, app = nil })
|
||||
|
||||
check(package.loaded["App"] == nil, "discovery flush drops flat App")
|
||||
check(package.loaded["App"] == nil, "endEditorSession drops flat App")
|
||||
check(package.loaded["NewPanel"] == nil,
|
||||
"discovery flush drops a new panel without a hardcoded list")
|
||||
"endEditorSession drops a new panel without a hardcoded list")
|
||||
check(package.loaded["src.core.Data"] ~= nil,
|
||||
"discovery flush leaves engine modules alone")
|
||||
"endEditorSession leaves engine modules alone")
|
||||
love.filesystem.remove("tools/save-editor/App.lua")
|
||||
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
|
||||
end
|
||||
|
||||
-- ---- Fetch shutdown clears ready so Play-again can respawn workers ---------
|
||||
do
|
||||
local Fetch = require("src.net.Fetch")
|
||||
local spawnAttempts = 0
|
||||
love.thread = love.thread or {}
|
||||
local savedNewThread = love.thread.newThread
|
||||
local savedGetChannel = love.thread.getChannel
|
||||
love.thread.getChannel = function()
|
||||
return {
|
||||
clear = function() end,
|
||||
push = function() end,
|
||||
pop = function() return nil end,
|
||||
demand = function() end,
|
||||
}
|
||||
end
|
||||
love.thread.newThread = function()
|
||||
spawnAttempts = spawnAttempts + 1
|
||||
return {
|
||||
start = function() end,
|
||||
wait = function() end,
|
||||
getError = function() return nil end,
|
||||
}
|
||||
end
|
||||
Fetch.available()
|
||||
local afterFirst = spawnAttempts
|
||||
Fetch.shutdown()
|
||||
Fetch.available()
|
||||
check(spawnAttempts > afterFirst,
|
||||
"Fetch.available retries worker spawn after shutdown (ready=nil)")
|
||||
love.thread.newThread = savedNewThread
|
||||
love.thread.getChannel = savedGetChannel
|
||||
end
|
||||
|
||||
T.finish("launcher_session_teardown_test")
|
||||
|
||||
@@ -90,11 +90,23 @@ check(imp:find('if self.tab == "skins" then', 1, true) ~= nil,
|
||||
check(imp:find("_installSkinZip", 1, true) ~= nil, "skin zip installer exists")
|
||||
check(imp:find("_installMod", 1, true) ~= nil,
|
||||
"and a zip elsewhere still installs a mod")
|
||||
local cycle = imp:match("local order = %{(.-)%}")
|
||||
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local cycled, probe = {}, nil
|
||||
probe = setmetatable({ tab = GameVersion.ORDER[1] }, { __index = RomImporter })
|
||||
probe._switchTab = function(self, id) self.tab = id; cycled[#cycled + 1] = id end
|
||||
for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end
|
||||
local reached = " " .. table.concat(cycled, " ") .. " "
|
||||
check(reached:find(" skins ", 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the skins tab")
|
||||
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
|
||||
check(reached:find(" bug ", 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the bug tab")
|
||||
for _, id in ipairs(GameVersion.ORDER) do
|
||||
if id ~= GameVersion.ORDER[1] then
|
||||
check(reached:find(" " .. id .. " ", 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches " .. id)
|
||||
end
|
||||
end
|
||||
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
||||
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
||||
"switching to the tab re-reads the skin list")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Static privacy gate for the narrow Mew dock engine branch. It checks the
|
||||
-- Git publication set, not ignored local imports: user ROMs and progress
|
||||
-- saves may exist on a developer machine but must never become tracked files.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
local T = require("tests.harness")
|
||||
|
||||
local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null"))
|
||||
local paths = {}
|
||||
if pipe then
|
||||
for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end
|
||||
pipe:close()
|
||||
end
|
||||
|
||||
T.check(#paths > 100,
|
||||
"privacy gate inspects a real Git publication set instead of passing vacuously")
|
||||
|
||||
local forbiddenExtensions = {
|
||||
gb = true, gbc = true, gba = true, sav = true, srm = true,
|
||||
rom = true, z64 = true, v64 = true, n64 = true, nds = true,
|
||||
sfc = true, smc = true,
|
||||
}
|
||||
local forbiddenRuntimeRoots = {
|
||||
["save.lua"] = true,
|
||||
["save_blue.lua"] = true,
|
||||
["save_yellow.lua"] = true,
|
||||
["save_gold.lua"] = true,
|
||||
["options.lua"] = true,
|
||||
}
|
||||
|
||||
local binaryLeaks, runtimeLeaks = {}, {}
|
||||
for _, path in ipairs(paths) do
|
||||
local lower = path:lower()
|
||||
local ext = lower:match("%.([^./\\]+)$")
|
||||
if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end
|
||||
if forbiddenRuntimeRoots[lower]
|
||||
or lower:match("^saves/")
|
||||
or lower:match("^imports/")
|
||||
or lower:match("^mods%-data/") then
|
||||
runtimeLeaks[#runtimeLeaks + 1] = path
|
||||
end
|
||||
end
|
||||
|
||||
T.eq(#binaryLeaks, 0,
|
||||
"tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", "))
|
||||
T.eq(#runtimeLeaks, 0,
|
||||
"tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", "))
|
||||
|
||||
T.finish("mew dock private artifact gate")
|
||||
@@ -0,0 +1,428 @@
|
||||
-- Contract gate for the two narrow Gen 1 seams added for a composable
|
||||
-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the
|
||||
-- real hook bus and WorldAPI against hand-written maps and save snapshots.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
local oldHooks = Runtime.hooks
|
||||
local oldTextBox = package.loaded["src.render.TextBox"]
|
||||
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
}
|
||||
|
||||
local story = dofile("data/scripts/story3.lua")
|
||||
local VERSIONS = { "red", "blue", "yellow" }
|
||||
|
||||
local function newDock(version, wrappers)
|
||||
local blocks, pushes, warps = {}, {}, {}
|
||||
local rebuilds = 0
|
||||
local save = {
|
||||
version = version,
|
||||
flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}
|
||||
local map = {
|
||||
id = "VERMILION_DOCK",
|
||||
setBlock = function(_, bx, by, block)
|
||||
blocks[bx .. "," .. by] = block
|
||||
end,
|
||||
renderer = { rebuild = function() rebuilds = rebuilds + 1 end },
|
||||
}
|
||||
local game = {
|
||||
save = save,
|
||||
data = { text = {
|
||||
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||
} },
|
||||
stack = { push = function(_, value) pushes[#pushes + 1] = value end },
|
||||
}
|
||||
local ow = {
|
||||
map = map,
|
||||
player = { cellX = 14, cellY = 2, facing = "down" },
|
||||
startWarpTo = function(_, ...)
|
||||
warps[#warps + 1] = { ... }
|
||||
end,
|
||||
}
|
||||
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
for _, entry in ipairs(wrappers or {}) do
|
||||
hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0,
|
||||
entry.owner)
|
||||
end
|
||||
|
||||
story.VERMILION_DOCK.onEnter(game, ow)
|
||||
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||
return {
|
||||
blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds,
|
||||
save = save, map = map, game = game, ow = ow, hooks = hooks,
|
||||
}
|
||||
end
|
||||
|
||||
local function allowedWrapper(owner, inspect)
|
||||
return {
|
||||
owner = owner,
|
||||
fn = function(nextFn, game, ctx)
|
||||
if inspect then inspect(game, ctx) end
|
||||
local downstream = nextFn(game, ctx)
|
||||
local ownClaim = true
|
||||
return downstream == true or ownClaim == true
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- With no subscriber, the post-departure branch remains byte-for-byte
|
||||
-- vanilla in effect: erase the ship, show its line, and eject the player.
|
||||
do
|
||||
local run = newDock("red")
|
||||
T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock")
|
||||
T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull")
|
||||
T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull")
|
||||
T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line")
|
||||
T.eq(run.pushes[1].text, "The ship set sail.",
|
||||
"vanilla re-entry preserves its dialogue")
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"vanilla re-entry ejects to Vermilion City")
|
||||
end
|
||||
|
||||
-- The new permission seam is post-departure only. An ordinary HM01 exit
|
||||
-- must not invoke it or change the existing departure-script path.
|
||||
do
|
||||
local hookCalls, queued = 0, nil
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx)
|
||||
hookCalls = hookCalls + 1
|
||||
return nextFn(game, ctx)
|
||||
end, 0, "must_not_run")
|
||||
|
||||
local savedMusic = package.loaded["src.core.Music"]
|
||||
package.loaded["src.core.Music"] = {
|
||||
stop = function() end,
|
||||
play = function() end,
|
||||
}
|
||||
local game = {
|
||||
save = { version = "red", flags = { EVENT_GOT_HM01 = true } },
|
||||
data = {},
|
||||
}
|
||||
local ow = {
|
||||
player = { cellX = 14, cellY = 2 },
|
||||
startDustAnim = function(_, _, _, done) if done then done() end end,
|
||||
queueScript = function(_, rows) queued = rows end,
|
||||
}
|
||||
story.VERMILION_DOCK.onEnter(game, ow)
|
||||
package.loaded["src.core.Music"] = savedMusic
|
||||
|
||||
T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam")
|
||||
T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true,
|
||||
"normal HM01 departure still sets the vanilla event flag")
|
||||
T.check(type(queued) == "table" and #queued > 0,
|
||||
"normal HM01 departure still queues its sail-away script")
|
||||
end
|
||||
|
||||
-- One cooperative claimant may permit occupancy. The callback receives a
|
||||
-- detached data snapshot, not the live overworld, map, player, or save.
|
||||
do
|
||||
local seenGame, seenCtx
|
||||
local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx)
|
||||
seenGame = game
|
||||
seenCtx = {
|
||||
mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion,
|
||||
x = ctx.x, y = ctx.y,
|
||||
}
|
||||
ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1
|
||||
end) })
|
||||
T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly")
|
||||
T.same(seenCtx, {
|
||||
mapId = "VERMILION_DOCK", reason = "ss_anne_departed",
|
||||
gameVersion = "red", x = 14, y = 2,
|
||||
}, "occupancy context is the exact detached Red dock snapshot")
|
||||
T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map")
|
||||
T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X")
|
||||
T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y")
|
||||
T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save")
|
||||
T.same(run.save, {
|
||||
version = "red", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}, "permission check preserves the complete save snapshot")
|
||||
T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog")
|
||||
T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy")
|
||||
T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship")
|
||||
T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout")
|
||||
end
|
||||
|
||||
-- Standard hook composition also means a non-cooperative false/no-next
|
||||
-- wrapper can suppress downstream claims. This remains safe because false
|
||||
-- is denial; it cannot accidentally grant occupancy.
|
||||
do
|
||||
local downstreamCalls = 0
|
||||
local run = newDock("red", {
|
||||
{
|
||||
owner = "denier", priority = 10,
|
||||
fn = function() return false end,
|
||||
},
|
||||
{
|
||||
owner = "unreached_claimant", priority = 0,
|
||||
fn = function()
|
||||
downstreamCalls = downstreamCalls + 1
|
||||
return true
|
||||
end,
|
||||
},
|
||||
})
|
||||
T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics")
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"non-cooperative false remains fail-closed")
|
||||
end
|
||||
|
||||
-- Cooperative peers all run through next(). A lower-priority peer claim is
|
||||
-- preserved by a higher-priority peer that has no claim of its own.
|
||||
do
|
||||
local calls, contextIdentity = {}, nil
|
||||
local run = newDock("blue", {
|
||||
{
|
||||
owner = "peer_high", priority = 10,
|
||||
fn = function(nextFn, game, ctx)
|
||||
calls[#calls + 1] = "high-before"
|
||||
contextIdentity = ctx
|
||||
local allowed = nextFn(game, ctx)
|
||||
calls[#calls + 1] = "high-after"
|
||||
return allowed == true or false
|
||||
end,
|
||||
},
|
||||
{
|
||||
owner = "peer_low", priority = 0,
|
||||
fn = function(nextFn, game, ctx)
|
||||
calls[#calls + 1] = "low"
|
||||
T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance")
|
||||
local allowed = nextFn(game, ctx)
|
||||
local ownClaim = true
|
||||
return allowed == true or ownClaim == true
|
||||
end,
|
||||
},
|
||||
})
|
||||
T.same(calls, { "high-before", "low", "high-after" },
|
||||
"multiple peer handlers preserve hook-chain order")
|
||||
T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain")
|
||||
end
|
||||
|
||||
-- Absent, throwing, or malformed callbacks fail closed. Only boolean true
|
||||
-- can turn off ejection; truthy strings/tables/numbers do not grant access.
|
||||
do
|
||||
local malformed = {
|
||||
{ label = "nil", value = nil },
|
||||
{ label = "false", value = false },
|
||||
{ label = "string", value = "yes" },
|
||||
{ label = "number", value = 1 },
|
||||
{ label = "table", value = {} },
|
||||
}
|
||||
for _, case in ipairs(malformed) do
|
||||
local run = newDock("red", { {
|
||||
owner = "malformed_" .. case.label,
|
||||
fn = function() return case.value end,
|
||||
} })
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"malformed " .. case.label .. " permission fails closed")
|
||||
end
|
||||
|
||||
local beforeNext = newDock("red", { {
|
||||
owner = "throws_before_next",
|
||||
fn = function() error("fixture throws before next", 0) end,
|
||||
} })
|
||||
T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY",
|
||||
"throwing callback before next fails closed")
|
||||
|
||||
local afterNext = newDock("red", { {
|
||||
owner = "throws_after_next",
|
||||
fn = function(nextFn, game, ctx)
|
||||
nextFn(game, ctx)
|
||||
error("fixture throws after next", 0)
|
||||
end,
|
||||
} })
|
||||
T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY",
|
||||
"throwing callback after next keeps the downstream denial")
|
||||
end
|
||||
|
||||
-- The context carries one version only. A Red-only claimant must not leak
|
||||
-- access into Blue or Yellow, and each call gets its own snapshot.
|
||||
do
|
||||
local contexts = {}
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
local run = newDock(version, { {
|
||||
owner = "red_only",
|
||||
fn = function(nextFn, game, ctx)
|
||||
contexts[#contexts + 1] = ctx
|
||||
local downstream = nextFn(game, ctx)
|
||||
return downstream == true or ctx.gameVersion == "red"
|
||||
end,
|
||||
} })
|
||||
T.eq(#run.warps == 0, version == "red",
|
||||
version .. " occupancy is decided only by its own version context")
|
||||
end
|
||||
T.eq(contexts[1].gameVersion, "red", "Red context stays Red")
|
||||
T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue")
|
||||
T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow")
|
||||
T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3],
|
||||
"Red, Blue, and Yellow calls do not share context tables")
|
||||
end
|
||||
|
||||
-- Removing the owner is the engine's disable/uninstall path. It restores
|
||||
-- vanilla denial immediately and leaves no save flag or serialized state.
|
||||
do
|
||||
local run = newDock("yellow", { allowedWrapper("removable") })
|
||||
T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy")
|
||||
run.hooks:removeOwner("removable")
|
||||
local pushes, warps = {}, {}
|
||||
run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end
|
||||
run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end
|
||||
story.VERMILION_DOCK.onEnter(run.game, run.ow)
|
||||
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||
T.eq(warps[1] and warps[1][1], "VERMILION_CITY",
|
||||
"disabling the owner restores vanilla ejection")
|
||||
T.eq(run.hooks.chains["map.occupancy_allowed"], nil,
|
||||
"uninstall removes the occupancy chain itself")
|
||||
T.eq(run.save.marker, "save-must-not-change",
|
||||
"disable/uninstall writes no persistent permission state")
|
||||
T.same(run.save, {
|
||||
version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}, "disable/uninstall preserves the complete Yellow save snapshot")
|
||||
end
|
||||
|
||||
-- activeBlockAt is read-only and fail-closed. A successful call exposes
|
||||
-- only one scalar from the active runtime layout, never its backing table.
|
||||
local function blockApi(version, blockAt)
|
||||
local backing = { 4, 5, 6, 8, 9, 10 }
|
||||
local map = {
|
||||
id = "VERMILION_DOCK",
|
||||
def = { width = 3, height = 2, blocks = backing },
|
||||
blockAt = blockAt or function(_, bx, by)
|
||||
return backing[by * 3 + bx + 1]
|
||||
end,
|
||||
}
|
||||
local world = { isOverworld = true, map = map }
|
||||
local game = {
|
||||
save = { version = version },
|
||||
stack = { states = { world } },
|
||||
overworld = world,
|
||||
}
|
||||
return WorldAPI.new(game, "contract_fixture"), backing, game, map
|
||||
end
|
||||
|
||||
do
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
local api, backing = blockApi(version)
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0)
|
||||
T.eq(block, 5, version .. " reads its active dock block")
|
||||
T.eq(err, nil, version .. " valid active block has no error")
|
||||
block = 99
|
||||
T.eq(backing[2], 5, version .. " scalar result cannot mutate the map")
|
||||
end
|
||||
|
||||
local api = blockApi("red")
|
||||
local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0)
|
||||
T.eq(wrong, nil, "wrong map has no block result")
|
||||
T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly")
|
||||
|
||||
local invalid = {
|
||||
{ "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 },
|
||||
{ "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 },
|
||||
{ "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 },
|
||||
}
|
||||
for _, case in ipairs(invalid) do
|
||||
local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3])
|
||||
T.eq(value, nil, case[1] .. " returns no block")
|
||||
T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type")
|
||||
end
|
||||
for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do
|
||||
local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2])
|
||||
T.eq(value, nil, "out-of-bounds coordinate returns no block")
|
||||
T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local malformed = {
|
||||
{ label = "nil", get = function() return nil end },
|
||||
{ label = "negative", get = function() return -1 end },
|
||||
{ label = "fractional", get = function() return 1.5 end },
|
||||
{ label = "infinite", get = function() return math.huge end },
|
||||
{ label = "NaN", get = function() return 0 / 0 end },
|
||||
{ label = "string", get = function() return "4" end },
|
||||
{ label = "table", get = function() return {} end },
|
||||
{ label = "throwing", get = function() error("bad map", 0) end },
|
||||
}
|
||||
for _, case in ipairs(malformed) do
|
||||
local api = blockApi("red", case.get)
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed active block " .. case.label .. " returns no value")
|
||||
T.eq(err, "block unavailable",
|
||||
"malformed active block " .. case.label .. " fails closed")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Invalid map shapes are untrusted runtime data too. None may escape as a
|
||||
-- block or raise through the mod facade.
|
||||
do
|
||||
local badDefs = {
|
||||
{ label = "missing def", value = nil },
|
||||
{ label = "missing width", value = { height = 2, blocks = {} } },
|
||||
{ label = "string width", value = { width = "3", height = 2, blocks = {} } },
|
||||
{ label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } },
|
||||
{ label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } },
|
||||
{ label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } },
|
||||
{ label = "missing blocks", value = { width = 3, height = 2 } },
|
||||
{ label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } },
|
||||
}
|
||||
for _, case in ipairs(badDefs) do
|
||||
local api, _, _, map = blockApi("red")
|
||||
map.def = case.value
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, case.label .. " returns no block")
|
||||
T.eq(err, "block unavailable", case.label .. " fails closed")
|
||||
end
|
||||
|
||||
local api, _, _, map = blockApi("red")
|
||||
map.blockAt = nil
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "missing blockAt returns no block")
|
||||
T.eq(err, "block unavailable", "missing blockAt fails closed")
|
||||
map.blockAt = "not a function"
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed blockAt returns no block")
|
||||
T.eq(err, "block unavailable", "malformed blockAt fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red")
|
||||
map.def.blocks = {}
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "sparse stored block slot returns no block")
|
||||
T.eq(err, "block unavailable", "sparse stored block slot fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red")
|
||||
map.def.blocks[1] = "4"
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed stored block slot returns no block")
|
||||
T.eq(err, "block unavailable", "malformed stored block slot fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red", function() return 5 end)
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "stored/accessor mismatch returns no block")
|
||||
T.eq(err, "block unavailable", "stored/accessor mismatch fails closed")
|
||||
end
|
||||
|
||||
do
|
||||
local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture")
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "no-overworld lookup returns no block")
|
||||
T.eq(err, "no overworld", "no-overworld lookup reports its state")
|
||||
end
|
||||
|
||||
Runtime.hooks = oldHooks
|
||||
package.loaded["src.render.TextBox"] = oldTextBox
|
||||
T.finish("mew dock seam contract")
|
||||
@@ -0,0 +1,66 @@
|
||||
-- No-mod and API-v1 parity for the additive mod.developer surface.
|
||||
--
|
||||
-- The production break this catches is a developer-mode loader path that
|
||||
-- mutates vanilla data, creates mod state, or changes existing API-v1
|
||||
-- behavior merely because the new public signal exists.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local function pristine()
|
||||
return {
|
||||
pokemon = { KEEP = { hp = 7 } },
|
||||
moves = {},
|
||||
}
|
||||
end
|
||||
|
||||
for _, dev in ipairs({ false, true }) do
|
||||
local data = pristine()
|
||||
local files = {}
|
||||
local run = T.sdk.loadNone({
|
||||
data = data,
|
||||
fs = T.sdk.memfs(files),
|
||||
dev = dev,
|
||||
})
|
||||
T.eq(#run.errors, 0,
|
||||
"no-mod load stays clean with developer=" .. tostring(dev))
|
||||
T.eq(next(run.loader.mods), nil,
|
||||
"no-mod load discovers nothing with developer=" .. tostring(dev))
|
||||
T.eq(data.pokemon.KEEP.hp, 7,
|
||||
"no-mod load preserves vanilla data with developer=" .. tostring(dev))
|
||||
T.eq(next(files), nil,
|
||||
"no-mod load creates no files with developer=" .. tostring(dev))
|
||||
run.release()
|
||||
end
|
||||
|
||||
local V1 = {
|
||||
["mods/v1_probe/manifest.json"] = [[{
|
||||
"id": "v1_probe",
|
||||
"name": "V1 Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 1
|
||||
}]],
|
||||
["mods/v1_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.identity = mod.id .. "@" .. mod.version
|
||||
mod.exports.payload = mod:read("payload.txt")
|
||||
mod.options:define({ { key = "enabled", type = "toggle", default = true } })
|
||||
mod.exports.defaultOption = mod.options:get("enabled")
|
||||
]],
|
||||
["mods/v1_probe/payload.txt"] = "unchanged-v1",
|
||||
}
|
||||
|
||||
local legacy = T.sdk.loadMods({ "mods/v1_probe" }, {
|
||||
fs = T.sdk.memfs(V1),
|
||||
dev = false,
|
||||
})
|
||||
T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged")
|
||||
local out = legacy.loader.exports.v1_probe
|
||||
T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged")
|
||||
T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged")
|
||||
T.eq(out.defaultOption, true, "API-v1 options stay unchanged")
|
||||
legacy.release()
|
||||
|
||||
T.finish("mod developer mode parity")
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Public load-time developer-mode signal for sandboxed mods.
|
||||
--
|
||||
-- The production break this catches is a loader that computes dev mode but
|
||||
-- does not expose the same fixed answer to the public mod object before the
|
||||
-- entry chunk runs. It also protects the data-only contract: the public
|
||||
-- value is a boolean snapshot, not a live loader or environment handle.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FILES = {
|
||||
["mods/dev_probe/manifest.json"] = [[{
|
||||
"id": "dev_probe",
|
||||
"name": "Developer Mode Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["all"]
|
||||
}]],
|
||||
["mods/dev_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.seenAtLoad = mod.developer
|
||||
mod.exports.kind = type(mod.developer)
|
||||
if mod.developer then
|
||||
mod.commands:register("dev_probe:diagnostics", function() return true end)
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
local function load(dev, generation)
|
||||
return T.sdk.loadMods({ "mods/dev_probe" }, {
|
||||
fs = T.sdk.memfs(FILES),
|
||||
dev = dev,
|
||||
generation = generation,
|
||||
})
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true)
|
||||
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"sandboxed entry code sees developer mode at load time")
|
||||
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"entry code can register diagnostics only in developer mode")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(false)
|
||||
T.eq(#run.errors, 0, "production-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"sandboxed entry code sees production mode at load time")
|
||||
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"production load does not register developer diagnostics")
|
||||
run.release()
|
||||
end
|
||||
|
||||
for _, provided in ipairs({ "yes", 1 }) do
|
||||
local run = load(provided)
|
||||
T.eq(#run.errors, 0,
|
||||
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
|
||||
T.eq(out.kind, "boolean",
|
||||
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
|
||||
T.eq(run.loader.dev, false,
|
||||
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true, 2)
|
||||
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"Gen 2 entry code sees the same developer-mode answer")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"Gen 2 entry code can gate diagnostics on the same signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local saved = _G.POKEPORT_DEV_MODE
|
||||
_G.POKEPORT_DEV_MODE = true
|
||||
local ok, run = pcall(load, nil)
|
||||
_G.POKEPORT_DEV_MODE = saved
|
||||
if not ok then error(run, 0) end
|
||||
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
|
||||
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
|
||||
"the --developer boot decision reaches the public signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish("mod developer mode public API")
|
||||
@@ -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")
|
||||
|
||||
@@ -27,18 +27,25 @@ end
|
||||
|
||||
-- ------- tokens expand off GameVersion, never a literal list
|
||||
|
||||
-- "nonesuch" is the deliberate never-a-game token for every unknown-version
|
||||
-- case below. A real version id was used here twice ("gold", then "crystal")
|
||||
-- and both had to be swapped the day that game shipped; this one never will.
|
||||
local NO_SUCH_GAME = "nonesuch"
|
||||
|
||||
do
|
||||
eq(table.concat(ModTargets.expand("red"), ","), "red",
|
||||
"a version id names exactly that game")
|
||||
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
|
||||
"gen1 is every Gen 1 game, case-insensitive")
|
||||
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
|
||||
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver,crystal",
|
||||
"gen2 is every Gen 2 game")
|
||||
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
|
||||
"and each of them names itself")
|
||||
eq(table.concat(ModTargets.expand("crystal"), ","), "crystal",
|
||||
"Crystal included, the day its VERSIONS row landed")
|
||||
eq(table.concat(ModTargets.expand("all"), ","),
|
||||
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
|
||||
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
|
||||
eq(ModTargets.expand(NO_SUCH_GAME), nil, "a game this engine has no cache for")
|
||||
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
|
||||
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
|
||||
end
|
||||
@@ -48,9 +55,9 @@ do
|
||||
eq(table.concat(versions, ","), "red,gold",
|
||||
"normalize dedupes and sorts into GameVersion.ORDER")
|
||||
eq(#unknown, 0, "known tokens leave nothing unreported")
|
||||
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
|
||||
local _, bad = ModTargets.normalize({ NO_SUCH_GAME, "gen1" })
|
||||
eq(#bad, 1, "an unknown token comes back for the caller to report")
|
||||
eq(bad[1], "crystal", "by name")
|
||||
eq(bad[1], NO_SUCH_GAME, "by name")
|
||||
end
|
||||
|
||||
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
|
||||
@@ -58,7 +65,7 @@ end
|
||||
do
|
||||
eq(list(mf({})), "red,blue,yellow",
|
||||
"a manifest with no games key is Gen 1, which is what it was tested as")
|
||||
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
|
||||
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver,crystal",
|
||||
"gen2compat keeps Gen 1 and adds Gen 2")
|
||||
eq(mf({}).gen2compat, false, "and the derived flag agrees")
|
||||
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
|
||||
@@ -69,23 +76,23 @@ end
|
||||
|
||||
do
|
||||
local gen2 = mf({ games = { "gen2" } })
|
||||
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
|
||||
eq(list(gen2), "gold,silver,crystal", "games can name Gen 2 alone")
|
||||
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
|
||||
local both = mf({ games = { "gen1", "gen2" } })
|
||||
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
|
||||
eq(list(both), "red,blue,yellow,gold,silver,crystal", "or both generations")
|
||||
local one = mf({ games = { "blue" } })
|
||||
eq(list(one), "blue", "or one single game")
|
||||
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
|
||||
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
|
||||
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver,crystal",
|
||||
"an old gen2compat beside a new games list still adds its game")
|
||||
end
|
||||
|
||||
do
|
||||
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
|
||||
-- every other manifest vocabulary (Manifest.violation)
|
||||
local lenient = mf({ games = { "crystal", "red" } })
|
||||
local lenient = mf({ games = { NO_SUCH_GAME, "red" } })
|
||||
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
|
||||
check(not pcall(mf, { api = 2, games = { "crystal" } }),
|
||||
check(not pcall(mf, { api = 2, games = { NO_SUCH_GAME } }),
|
||||
"api 2 refuses a game it does not have")
|
||||
check(not pcall(mf, { games = "gen1" }),
|
||||
"games must be an array, not a bare string")
|
||||
@@ -226,12 +233,13 @@ do
|
||||
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
|
||||
format = "g1rmodlist", formatVersion = 1,
|
||||
profile = { name = "P", enabledByVersion = {
|
||||
gold = { a = true }, silver = { a = true }, crystal = { a = true },
|
||||
gold = { a = true }, silver = { a = true },
|
||||
[NO_SUCH_GAME] = { a = true },
|
||||
red = "nope" } },
|
||||
}))
|
||||
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
|
||||
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
|
||||
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
|
||||
eq(bad.enabledByVersion[NO_SUCH_GAME], nil, "an unknown game is dropped on read")
|
||||
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
|
||||
end
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
|
||||
local mainSrc = source("main.lua")
|
||||
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
|
||||
check(quitHook ~= nil, "love.quit is still a single top-level function")
|
||||
quitHook = quitHook or ""
|
||||
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
|
||||
"love.quit shuts the chip worker down")
|
||||
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
|
||||
"love.quit shuts the update worker down")
|
||||
check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||
"love.quit shuts workers down via SessionLifecycle.endProcess")
|
||||
|
||||
local lifecycleSrc = source("src/core/SessionLifecycle.lua")
|
||||
check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil,
|
||||
"SessionLifecycle exposes registerProcessShutdown")
|
||||
check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||
"SessionLifecycle.endProcess fans out registered hooks")
|
||||
check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil,
|
||||
"ChipAudio registers its shutdown hook at load")
|
||||
check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil,
|
||||
"Check registers its shutdown hook at load")
|
||||
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
||||
"Fetch registers its shutdown hook at load")
|
||||
|
||||
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
||||
-- so the quit event exits the process outright. It has to sit after the
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
-- The cache contract is the shared Lua-side publication boundary. A writer
|
||||
-- may stage outputs in any order, but readiness is published only after the
|
||||
-- version-specific required set exists.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local eq = T.eq
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
|
||||
local fs = { prefix = "initial/", files = {} }
|
||||
local writes = {}
|
||||
function fs.exists(path)
|
||||
return fs.files[fs.prefix .. path] ~= nil
|
||||
end
|
||||
function fs.read(path)
|
||||
return fs.files[fs.prefix .. path]
|
||||
end
|
||||
function fs.write(path, value)
|
||||
writes[#writes + 1] = fs.prefix .. path
|
||||
fs.files[fs.prefix .. path] = value
|
||||
return true
|
||||
end
|
||||
function fs.remove(path)
|
||||
fs.files[fs.prefix .. path] = nil
|
||||
end
|
||||
|
||||
local required, isOverride = CacheContract.requiredFilesFor("red")
|
||||
check(not isOverride, "Red uses the shared required-file list")
|
||||
check(#required > 0, "Red has required outputs")
|
||||
eq(CacheContract.markerFor("red"),
|
||||
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
"marker contains format and Red SHA-1")
|
||||
|
||||
for index = 1, #required - 1 do
|
||||
fs.files["red/" .. required[index]] = true
|
||||
end
|
||||
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
|
||||
check(not complete, "missing output keeps cache incomplete")
|
||||
eq(missing, required[#required], "missing output is reported")
|
||||
local published, publishError = CacheContract.publish("red", fs)
|
||||
check(not published, "incomplete cache is not published")
|
||||
check(publishError ~= nil, "incomplete publication explains the missing output")
|
||||
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
|
||||
"incomplete cache has no completion marker")
|
||||
|
||||
-- Publication must remove a stale marker left by an interrupted replacement,
|
||||
-- and must restore the caller prefix on both the success and failure paths.
|
||||
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
|
||||
local removedMarker = CacheContract.publish("red", fs)
|
||||
check(not removedMarker, "incomplete retry is still rejected")
|
||||
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
|
||||
"incomplete retry removes a stale completion marker")
|
||||
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
|
||||
|
||||
fs.files["red/" .. required[#required]] = true
|
||||
fs.prefix = "caller/prefix/"
|
||||
fs.files["red/" .. required[#required]] = true
|
||||
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
|
||||
published, publishError = CacheContract.publish("red", fs)
|
||||
check(published, "complete cache is published")
|
||||
eq(publishError, nil, "complete publication has no error")
|
||||
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
|
||||
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
|
||||
"marker is written under the version prefix")
|
||||
local marker = CacheContract.readMarker("red", fs)
|
||||
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
|
||||
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
|
||||
"the marker is the only publication write and comes last")
|
||||
|
||||
-- Every supported version gets its own marker and complete cache semantics;
|
||||
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
|
||||
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
|
||||
local versionFiles, override = CacheContract.requiredFilesFor(version)
|
||||
for _, path in ipairs(versionFiles) do
|
||||
fs.files[version .. "/" .. path] = true
|
||||
end
|
||||
if not override then
|
||||
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||
fs.files[version .. "/" .. path] = true
|
||||
end
|
||||
end
|
||||
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
|
||||
local didPublish = CacheContract.publish(version, fs)
|
||||
check(didPublish, version .. " complete cache publishes")
|
||||
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
|
||||
CacheContract.markerFor(version), version .. " marker is version-scoped")
|
||||
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
|
||||
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
|
||||
end
|
||||
|
||||
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
|
||||
check(goldOverride, "Gold uses a version-specific required set")
|
||||
local goldSet = {}
|
||||
for _, path in ipairs(gold) do goldSet[path] = true end
|
||||
check(goldSet["assets/generated/battle/hud/balls.png"],
|
||||
"Gold required set includes trainer HUD art")
|
||||
check(not goldSet["assets/generated/trade/game_boy.png"],
|
||||
"Gold required set excludes Gen 1 trade art")
|
||||
check(goldSet["data/generated/rom_text.lua"],
|
||||
"Gold required set includes the Gen 2 engine text table")
|
||||
local silver = CacheContract.requiredFilesFor("silver")
|
||||
local silverSet = {}
|
||||
for _, path in ipairs(silver) do silverSet[path] = true end
|
||||
check(silverSet["data/generated/rom_text.lua"],
|
||||
"Silver required set includes the Gen 2 engine text table")
|
||||
check(not silverSet["assets/generated/trade/game_boy.png"],
|
||||
"Silver required set excludes Gen 1 trade art")
|
||||
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
|
||||
"Yellow has version-specific required outputs")
|
||||
|
||||
-- Revisioned cache markers: Crystal accepts either the 1.0 or the 1.1 cart.
|
||||
local CRYSTAL_1_0 = "f4cd194bdee0d04ca4eac29e09b8e4e9d818c133"
|
||||
local CRYSTAL_1_1 = "f2f52230b536214ef7c9924f483392993e226cfb"
|
||||
|
||||
local CRYSTAL_FORMAT = CacheContract.formatFor("crystal")
|
||||
|
||||
eq(CacheContract.markerFor("crystal", CRYSTAL_1_1),
|
||||
CRYSTAL_FORMAT .. CRYSTAL_1_1,
|
||||
"markerFor with an explicit sha1 uses that sha1, not the canonical one")
|
||||
eq(CacheContract.markerFor("crystal"), CRYSTAL_FORMAT .. CRYSTAL_1_0,
|
||||
"markerFor with no sha1 still defaults to the canonical (1.0) sha1")
|
||||
|
||||
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_0),
|
||||
"a marker written from the 1.0 hash matches crystal")
|
||||
check(CacheContract.markerMatches("crystal", CRYSTAL_FORMAT .. CRYSTAL_1_1),
|
||||
"a marker written from the 1.1 hash also matches crystal")
|
||||
check(not CacheContract.markerMatches("crystal",
|
||||
CRYSTAL_FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"),
|
||||
"a marker written from Red's hash does not match crystal")
|
||||
check(not CacheContract.markerMatches("red", CRYSTAL_FORMAT .. CRYSTAL_1_1),
|
||||
"a marker written from Crystal's 1.1 hash does not match red")
|
||||
check(not CacheContract.markerMatches("crystal",
|
||||
CacheContract.FORMAT .. CRYSTAL_1_0),
|
||||
"a crystal marker written on the shared format no longer matches")
|
||||
|
||||
local crystalFs = { prefix = "crystal-marker-test/", files = {} }
|
||||
function crystalFs.exists(path) return crystalFs.files[crystalFs.prefix .. path] ~= nil end
|
||||
function crystalFs.read(path) return crystalFs.files[crystalFs.prefix .. path] end
|
||||
function crystalFs.write(path, value)
|
||||
crystalFs.files[crystalFs.prefix .. path] = value
|
||||
return true
|
||||
end
|
||||
function crystalFs.remove(path) crystalFs.files[crystalFs.prefix .. path] = nil end
|
||||
|
||||
local crystalRequired = CacheContract.requiredFilesFor("crystal")
|
||||
for _, path in ipairs(crystalRequired) do
|
||||
crystalFs.files["crystal/" .. path] = true
|
||||
end
|
||||
local published11 = CacheContract.publish("crystal", crystalFs, CRYSTAL_1_1)
|
||||
check(published11, "publishing with the 1.1 sha1 succeeds")
|
||||
eq(crystalFs.files["crystal/" .. CacheContract.MARKER_PATH],
|
||||
CRYSTAL_FORMAT .. CRYSTAL_1_1, "the marker records the 1.1 sha1")
|
||||
check(CacheContract.isReady("crystal", crystalFs),
|
||||
"a cache published with the 1.1 sha1 still reads ready for crystal")
|
||||
|
||||
crystalFs.files["crystal/" .. CacheContract.MARKER_PATH] =
|
||||
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
|
||||
check(not CacheContract.isReady("crystal", crystalFs),
|
||||
"a marker from another version's hash does not read ready for crystal")
|
||||
|
||||
check(CacheContract.markerMatches("blue", CacheContract.markerFor("blue")),
|
||||
"blue's own marker still matches blue")
|
||||
check(not CacheContract.markerMatches("blue", CacheContract.markerFor("red")),
|
||||
"red's marker still does not match blue")
|
||||
|
||||
-- A throwing adapter must not strand the process in its temporary prefix.
|
||||
local throwingFs = { prefix = "before/" }
|
||||
function throwingFs.exists() error("probe failed") end
|
||||
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
|
||||
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
|
||||
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
|
||||
function throwingFs.write() error("write failed") end
|
||||
function throwingFs.remove() end
|
||||
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
|
||||
throwingFs.files = throwingFs.files or {}
|
||||
throwingFs.files["blue/" .. path] = true
|
||||
end
|
||||
function throwingFs.exists(path)
|
||||
return throwingFs.files[throwingFs.prefix .. path] ~= nil
|
||||
end
|
||||
local wrote = CacheContract.publish("blue", throwingFs)
|
||||
check(not wrote, "write errors are returned")
|
||||
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
|
||||
|
||||
-- Source-tree readiness must use the same version lists and reject a cache
|
||||
-- when LÖVE cannot identify a real source directory.
|
||||
local oldLove = love
|
||||
love = nil
|
||||
check(not CacheContract.sourceTreeHasData("red"),
|
||||
"source-tree check is safe without LÖVE")
|
||||
local sourceFiles = {}
|
||||
love = {
|
||||
filesystem = {
|
||||
getRealDirectory = function(path) return sourceFiles[path] end,
|
||||
getSource = function() return "/source" end,
|
||||
getInfo = function(path)
|
||||
return sourceFiles[path] and { type = "file" } or nil
|
||||
end,
|
||||
},
|
||||
}
|
||||
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
|
||||
check(CacheContract.sourceTreeHasData("red"),
|
||||
"Red source tree uses the shared required set")
|
||||
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
|
||||
check(not CacheContract.sourceTreeHasData("red"),
|
||||
"source-tree readiness rejects a cache-overlaid required file")
|
||||
sourceFiles = {}
|
||||
local goldFiles = CacheContract.requiredFilesFor("gold")
|
||||
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
|
||||
check(CacheContract.sourceTreeHasData("gold"),
|
||||
"Gold source tree uses its override set")
|
||||
love = oldLove
|
||||
|
||||
-- Both importer completion paths must call the shared publication boundary.
|
||||
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
|
||||
local importerSource = importerFile:read("*a")
|
||||
importerFile:close()
|
||||
local completionCalls = 0
|
||||
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
|
||||
completionCalls = completionCalls + 1
|
||||
end
|
||||
eq(completionCalls, 1,
|
||||
"both thread and coroutine paths converge on one publishing helper")
|
||||
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
|
||||
~= nil, "coroutine completion uses the shared helper")
|
||||
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
|
||||
"thread completion uses the shared helper")
|
||||
|
||||
T.finish("rom cache contract")
|
||||
@@ -57,9 +57,10 @@ end
|
||||
package.loaded["src.core.Platform"] = nil
|
||||
package.loaded["src.import.RomImporter"] = nil
|
||||
RomImporter = require("src.import.RomImporter")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local function clearSavesInbox()
|
||||
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
|
||||
for _, ver in ipairs(GameVersion.ORDER) do
|
||||
local dir = "imports/saves/" .. ver
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||
love.filesystem.remove(dir .. "/" .. name)
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
-- Shared cache readiness uses the selected version's source-tree contract.
|
||||
-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
|
||||
-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
|
||||
-- tree look incomplete forever.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local present = {}
|
||||
for _, path in ipairs(CacheContract.requiredFiles("gold")) do
|
||||
present[GameVersion.cachePrefix("gold") .. path] = true
|
||||
end
|
||||
local fs = {
|
||||
read = function() return nil end,
|
||||
getInfo = function(path) return present[path] and { type = "file" } or nil end,
|
||||
getRealDirectory = function(path) return present[path] and "/source" or nil end,
|
||||
getSource = function() return "/source" end,
|
||||
}
|
||||
local f = assert(io.open("src/import/RomImporter.lua", "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
|
||||
local inspected = CacheContract.inspect("gold", fs, { allowSource = true })
|
||||
T.eq(inspected and inspected.kind, "source",
|
||||
"Gold source tree uses Gold's required-file contract")
|
||||
local readyStart = src:find("function RomImporter.isReady", 1, true)
|
||||
check(readyStart ~= nil, "isReady is defined")
|
||||
local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
|
||||
check(readyEnd ~= nil, "isReady ends before the next importer helper")
|
||||
local readyBody = src:sub(readyStart, readyEnd)
|
||||
|
||||
present["gold/assets/generated/battle/hud/balls.png"] = nil
|
||||
T.eq(CacheContract.inspect("gold", fs, { allowSource = true }), nil,
|
||||
"missing Gold-only trainer HUD asset makes the source tree incomplete")
|
||||
check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
|
||||
"isReady delegates source-tree and cache readiness to the contract")
|
||||
check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
|
||||
"isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
|
||||
|
||||
local required = {}
|
||||
for _, path in ipairs(CacheContract.requiredFiles("gold")) do required[path] = true end
|
||||
T.eq(required["data/generated/rom_text.lua"], true,
|
||||
"Gold requires generated ROM text")
|
||||
T.eq(required["assets/generated/pc/mail_item.png"], true,
|
||||
"Gold requires PC mail art")
|
||||
T.eq(required["assets/generated/trade/game_boy.png"], nil,
|
||||
"Gold does not inherit the Gen 1 trade-art contract")
|
||||
local required, isOverride = CacheContract.requiredFilesFor("gold")
|
||||
check(isOverride, "Gold uses the override required-file list")
|
||||
local requiredSet = {}
|
||||
for _, path in ipairs(required) do requiredSet[path] = true end
|
||||
check(requiredSet["assets/generated/battle/hud/balls.png"],
|
||||
"Gold caches require the trainer HUD ball sheet")
|
||||
check(not requiredSet["assets/generated/trade/game_boy.png"],
|
||||
"Gold does not inherit the Gen 1 trade-art requirement")
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -83,12 +83,16 @@ if extractor then
|
||||
"field.lua publishes tradeArt")
|
||||
end
|
||||
|
||||
-- a cache imported before #750 has none of the art; the shared readiness
|
||||
-- contract is what makes it re-import.
|
||||
local CacheContract = require("src.import.CacheContract")
|
||||
local required = {}
|
||||
for _, path in ipairs(CacheContract.requiredFiles("red")) do required[path] = true end
|
||||
T.eq(required["assets/generated/trade/game_boy.png"], true,
|
||||
"required-file contract makes pre-#750 caches re-import the trade art")
|
||||
-- a cache imported before #750 has none of the art; listing one of the
|
||||
-- files in the engine-owned cache contract is what makes it re-import
|
||||
local contract = readFile("src/import/CacheContract.lua")
|
||||
T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
|
||||
if contract then
|
||||
local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
|
||||
T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
|
||||
T.check(required ~= nil and required:find(
|
||||
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
|
||||
"cache contract makes pre-#750 caches re-import the trade art")
|
||||
end
|
||||
|
||||
T.finish("trade art import")
|
||||
|
||||
@@ -82,7 +82,7 @@ local function importer(ready)
|
||||
end
|
||||
|
||||
local allReady = importer({ red = true, blue = true, yellow = true, gold = true,
|
||||
silver = true })
|
||||
silver = true, crystal = true })
|
||||
allReady:_queueBaseRomScan()
|
||||
eq(allReady.baseRomScan.state, "done", "ready launcher skips discovery")
|
||||
eq(listings, 0, "ready launcher does not enumerate baseroms")
|
||||
@@ -125,7 +125,7 @@ missing:choose("red")
|
||||
eq(picks, 1, "the next import attempt falls back to the native picker")
|
||||
|
||||
local rescanned = importer({ red = true, blue = true, yellow = true, gold = true,
|
||||
silver = true })
|
||||
silver = true, crystal = true })
|
||||
rescanned.baseRoms.red = { path = "baseroms/z-red.gb", name = "z-red.gb" }
|
||||
rescanned:reimport("red")
|
||||
check(rescanned.baseRoms.red == nil, "re-import clears the detected ROM")
|
||||
|
||||
Reference in New Issue
Block a user