mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
1173 lines
47 KiB
Lua
1173 lines
47 KiB
Lua
-- UI extensibility (M8): the Screens factory and its cache invalidation,
|
|
-- StateStack screen events, the three menu-injection hooks with non-table
|
|
-- degrade, the mod.ui helper surface, theme defaults, branding reads from
|
|
-- field.*, and ManagerState v2 -- error surfacing, toggle resolution with
|
|
-- dependency dialogs, staged apply/discard, options auto-UI, profiles and
|
|
-- the safe-mode banner.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("mod ui")
|
|
local check = S.check
|
|
|
|
local love = _G.love or require("tests.love_stub")
|
|
_G.love = love
|
|
|
|
local Events = require("src.mods.Events")
|
|
local Hooks = require("src.mods.Hooks")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Logger = require("src.core.Logger")
|
|
local Assets = require("src.render.Assets")
|
|
local Screens = require("src.ui.Screens")
|
|
local StateStack = require("src.core.StateStack")
|
|
local ManagerState = require("src.mods.ManagerState")
|
|
local ModUI = require("src.ui.ModUI")
|
|
local Theme = require("src.ui.Theme")
|
|
local FrameCap = require("src.core.FrameCap")
|
|
|
|
local savedEvents, savedHooks, savedErrors =
|
|
Runtime.events, Runtime.hooks, Runtime.errors
|
|
local savedSafeMode = Runtime.safeMode
|
|
|
|
local function logged(fragment)
|
|
for _, line in ipairs(Logger.history) do
|
|
if line:find(fragment, 1, true) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- minimal stack/input doubles matching the StateStack and Input surfaces
|
|
local function newStack()
|
|
local stack = { states = {} }
|
|
function stack:push(state, ...)
|
|
table.insert(self.states, state)
|
|
if state.enter then state:enter(...) end
|
|
end
|
|
function stack:pop()
|
|
local state = table.remove(self.states)
|
|
if state and state.exit then state:exit() end
|
|
return state
|
|
end
|
|
function stack:top() return self.states[#self.states] end
|
|
return stack
|
|
end
|
|
|
|
local function newInput()
|
|
local input = { queue = {} }
|
|
function input:wasPressed(btn) return self.queue[btn] or false end
|
|
return input
|
|
end
|
|
|
|
local function press(state, btn)
|
|
state.game.input.queue = { [btn] = true }
|
|
state:update(1 / 60)
|
|
state.game.input.queue = {}
|
|
end
|
|
|
|
-- ------- Screens: resolution identity (parity)
|
|
Screens.invalidate()
|
|
local sgame = { data = {}, stack = newStack() }
|
|
for _, id in ipairs({ "TitleState", "IntroMovie", "OakSpeech", "NamingScreen",
|
|
"StartMenu", "PokedexMenu", "DexEntryMenu", "TownMap", "PartyMenu",
|
|
"BagMenu", "SummaryMenu", "TrainerCard", "OptionsMenu", "ShopMenu",
|
|
"BoxMenu", "PlayerPC", "MoveLearnMenu", "EvolutionState", "HallOfFame",
|
|
"Credits", "SlotMachine", "TradeAnim", "FlyMenu", "BindingsMenu" }) do
|
|
check(Screens.get(sgame, id) == require("src.ui." .. id),
|
|
"empty registry resolves the require module: " .. id)
|
|
end
|
|
check(Screens.get(sgame, "ManagerState") == ManagerState,
|
|
"ManagerState resolves from src.mods")
|
|
|
|
-- ------- Screens: override, screenId stamp, broken-factory fallback
|
|
Screens.invalidate()
|
|
sgame.data.screens = {
|
|
TitleState = { new = function(game) return { marker = true } end },
|
|
}
|
|
local inst = Screens.push(sgame, "TitleState")
|
|
check(inst.marker == true, "registry record wins over the builtin")
|
|
check(inst.screenId == "TitleState", "push stamps screenId")
|
|
check(sgame.stack:top() == inst, "push lands the instance on the stack")
|
|
|
|
Screens.invalidate()
|
|
sgame.data.screens = { TitleState = { new = function() error("boom") end } }
|
|
local fallback = Screens.push(sgame, "TitleState")
|
|
check(getmetatable(fallback) == require("src.ui.TitleState"),
|
|
"a throwing mod factory degrades to the builtin")
|
|
check(logged("mod screen 'TitleState' failed"),
|
|
"the failed factory is logged")
|
|
|
|
-- ------- Screens: cache flush rides the Assets invalidation fan-out
|
|
Screens.invalidate()
|
|
sgame.data.screens = nil
|
|
local cached = Screens.get(sgame, "TitleState")
|
|
sgame.data.screens = {
|
|
TitleState = { new = function() return { modded = true } end },
|
|
}
|
|
check(Screens.get(sgame, "TitleState") == cached,
|
|
"the factory cache holds between resolutions")
|
|
Assets.invalidate()
|
|
check(Screens.get(sgame, "TitleState") ~= cached,
|
|
"Assets.invalidate flushes the screens cache")
|
|
sgame.data.screens = nil
|
|
Screens.invalidate()
|
|
|
|
-- ------- StateStack events
|
|
local events, hooks = Events.new(), Hooks.new()
|
|
local errors = {}
|
|
Runtime.install(events, hooks, errors)
|
|
StateStack:init()
|
|
local order = {}
|
|
events:on("screen.pushed", function(e)
|
|
order[#order + 1] = "pushed:" .. tostring(e.state.entered)
|
|
end, 0, "t")
|
|
events:on("screen.popped", function(e)
|
|
order[#order + 1] = "popped:" .. tostring(e.state.exited)
|
|
end, 0, "t")
|
|
local probe = {}
|
|
function probe:enter() self.entered = true end
|
|
function probe:exit() self.exited = true end
|
|
StateStack:push(probe)
|
|
StateStack:pop()
|
|
check(order[1] == "pushed:true", "screen.pushed fires after enter")
|
|
check(order[2] == "popped:true", "screen.popped fires after exit")
|
|
|
|
local seenId
|
|
events:on("screen.pushed", function(e) seenId = e.state.screenId end, 0, "t")
|
|
Screens.push({ data = {}, stack = StateStack }, "TrainerCard")
|
|
check(seenId == "TrainerCard", "listeners match by screenId via Screens.push")
|
|
StateStack:pop()
|
|
events:removeOwner("t")
|
|
check(not Runtime.wants("screen.pushed"),
|
|
"no listeners left: the emit guard skips payload construction")
|
|
StateStack:push({}) -- no listener, no payload, no error
|
|
StateStack:pop()
|
|
|
|
-- ------- ui.start_menu.items
|
|
local StartMenu = require("src.ui.StartMenu")
|
|
local function startGame()
|
|
return {
|
|
data = {},
|
|
stack = newStack(),
|
|
input = newInput(),
|
|
save = { flags = { EVENT_GOT_POKEDEX = true }, party = { {} },
|
|
player = { name = "RED" }, options = {},
|
|
pokedex = { owned = {} } },
|
|
}
|
|
end
|
|
local VANILLA_START = { "POKéDEX", "POKéMON", "ITEM", "RED", "SAVE",
|
|
"OPTION", "LINK", "QUIT" }
|
|
local menu = StartMenu.new(startGame())
|
|
check(#menu.items == #VANILLA_START, "vanilla start menu row count")
|
|
for i, label in ipairs(VANILLA_START) do
|
|
check(menu.items[i].label == label, "vanilla start menu row " .. i)
|
|
end
|
|
check(menu.th == #menu.items * 2 + 2, "menu height derives from the item count")
|
|
|
|
local gated = startGame()
|
|
gated.modStatus = { available = { { id = "m" } } }
|
|
menu = StartMenu.new(gated)
|
|
check(menu.items[#menu.items - 1].label == "MODS",
|
|
"pause-menu MODS entry appears once a mod is discovered")
|
|
|
|
hooks:wrap("ui.start_menu.items", function(nextFn, game, items)
|
|
ModUI.insertBefore(items, "ITEM", { label = "QUESTS",
|
|
onSelect = function() end })
|
|
return nextFn(game, items)
|
|
end, 0, "fixture")
|
|
menu = StartMenu.new(startGame())
|
|
check(menu.items[3].label == "QUESTS" and menu.items[4].label == "ITEM",
|
|
"hook inserts a start-menu entry before its anchor")
|
|
hooks:removeOwner("fixture")
|
|
|
|
hooks:wrap("ui.start_menu.items", function() return 42 end, 0, "bad")
|
|
menu = StartMenu.new(startGame())
|
|
check(#menu.items == #VANILLA_START and menu.items[3].label == "ITEM",
|
|
"a non-table hook result degrades to the vanilla items")
|
|
check(logged("ui.start_menu.items returned"), "the degrade is logged")
|
|
hooks:removeOwner("bad")
|
|
|
|
-- ------- issue #270: B in a start submenu returns to the start menu
|
|
-- (RedisplayStartMenu), not to the overworld. The generic Menu pops the
|
|
-- start menu when a row is selected, so every vanilla submenu must carry
|
|
-- an onCancel that re-opens it.
|
|
local function gameForSubmenus()
|
|
local game = startGame()
|
|
game.save.inventory = {}
|
|
game.save.money = 0
|
|
game.save.pcItems = { POTION = 1 }
|
|
game.data.items = { POTION = { name = "POTION" } }
|
|
game.data.pokemon = {}
|
|
game.data.constants = {}
|
|
game.data.rulesets = {}
|
|
game.modStatus = { available = {} }
|
|
return game
|
|
end
|
|
|
|
local MenuClass = require("src.ui.Menu")
|
|
for _, rowLabel in ipairs({ "POKéDEX", "POKéMON", "ITEM", "RED", "OPTION" }) do
|
|
local game = gameForSubmenus()
|
|
local start = Screens.push(game, "StartMenu")
|
|
for i, item in ipairs(start.items) do
|
|
if item.label == rowLabel then start.index = i end
|
|
end
|
|
press(start, "a")
|
|
local sub = game.stack:top()
|
|
check(sub ~= start, rowLabel .. " opens a submenu over the overworld")
|
|
press(sub, "b")
|
|
local top = game.stack:top()
|
|
check(top ~= nil and top ~= sub and getmetatable(top) == MenuClass
|
|
and top.items[1].label == "POKéDEX",
|
|
"B from " .. rowLabel .. " re-opens the start menu")
|
|
check(#game.stack.states == 1,
|
|
"B from " .. rowLabel .. " leaves exactly the start menu on the stack")
|
|
check(top.index == start.index,
|
|
"the re-opened start menu restores the " .. rowLabel .. " cursor row")
|
|
end
|
|
|
|
-- the trainer card also dismisses on A, back to the start menu
|
|
local cardGame = gameForSubmenus()
|
|
local cardStart = Screens.push(cardGame, "StartMenu")
|
|
for i, item in ipairs(cardStart.items) do
|
|
if item.label == "RED" then cardStart.index = i end
|
|
end
|
|
press(cardStart, "a")
|
|
press(cardGame.stack:top(), "a")
|
|
check(getmetatable(cardGame.stack:top()) == MenuClass
|
|
and cardGame.stack:top().items[1].label == "POKéDEX",
|
|
"A on the trainer card also returns to the start menu")
|
|
|
|
-- the options CANCEL row takes the same path as B
|
|
local optCancelGame = gameForSubmenus()
|
|
local optStart = Screens.push(optCancelGame, "StartMenu")
|
|
for i, item in ipairs(optStart.items) do
|
|
if item.label == "OPTION" then optStart.index = i end
|
|
end
|
|
press(optStart, "a")
|
|
local optMenu = optCancelGame.stack:top()
|
|
optMenu.index = #optMenu.rows + 1 -- CANCEL
|
|
press(optMenu, "a")
|
|
check(getmetatable(optCancelGame.stack:top()) == MenuClass
|
|
and optCancelGame.stack:top().items[1].label == "POKéDEX",
|
|
"the options CANCEL row returns to the start menu")
|
|
|
|
-- the PC session (players_pc.asm): B in an item list returns to the PC's
|
|
-- root menu, which stays on the stack via keepOpen rows
|
|
local pcGame = gameForSubmenus()
|
|
local pcRoot = Screens.push(pcGame, "PlayerPC")
|
|
press(pcRoot, "a") -- WITHDRAW ITEM
|
|
check(#pcGame.stack.states == 2,
|
|
"WITHDRAW ITEM keeps the PC root menu underneath")
|
|
press(pcGame.stack:top(), "b")
|
|
check(pcGame.stack:top() == pcRoot,
|
|
"B in the withdraw list returns to the PC root menu")
|
|
press(pcRoot, "b")
|
|
check(#pcGame.stack.states == 0, "B on the PC root logs off to the overworld")
|
|
|
|
-- ------- ui.options.rows and the descriptor refactor
|
|
local OptionsMenu = require("src.ui.OptionsMenu")
|
|
local function optGame()
|
|
return {
|
|
data = {
|
|
rulesets = {
|
|
gen1_faithful = { name = "GEN 1" },
|
|
modern_clean = { name = "MODERN" },
|
|
secret = { name = "SECRET", hidden = true },
|
|
},
|
|
constants = {},
|
|
},
|
|
save = { options = {} },
|
|
stack = newStack(),
|
|
input = newInput(),
|
|
modStatus = { available = {} },
|
|
}
|
|
end
|
|
local om = OptionsMenu.new(optGame())
|
|
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
|
"ruleset", "musicVol", "sfxVol", "musicFilter", "colors",
|
|
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
|
|
"speed", "mods", "controls" }
|
|
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
|
for i, id in ipairs(WANT_IDS) do
|
|
check(om.rows[i].id == id, "options row order: " .. id)
|
|
end
|
|
|
|
-- ruleset row cycles the sorted non-hidden registry ids showing name
|
|
om.game.save.options.ruleset = "gen1_faithful"
|
|
check(om.rows[5].value(om.game) == "GEN 1", "ruleset row shows record.name")
|
|
om.rows[5].step(om.game, 1)
|
|
check(om.game.save.options.ruleset == "modern_clean",
|
|
"ruleset row cycles sorted registry ids")
|
|
om.rows[5].step(om.game, 1)
|
|
check(om.game.save.options.ruleset == "gen1_faithful",
|
|
"hidden rulesets are excluded from the cycle")
|
|
|
|
-- stepping parity with the old per-index ladder
|
|
om.rows[1].step(om.game, 1)
|
|
check(om.game.save.options.textSpeed == 5, "text speed MEDIUM steps to SLOW")
|
|
om.rows[1].step(om.game, 1)
|
|
check(om.game.save.options.textSpeed == 1, "then wraps to FAST")
|
|
om.rows[2].step(om.game, 1)
|
|
check(om.game.save.options.animations == false, "animations toggles off")
|
|
om.rows[3].step(om.game, 1)
|
|
check(om.game.save.options.battleStyle == "set", "battle style flips to SET")
|
|
check(om.rows[4].value(om.game) == "OG", "battle layout starts on the OG screen")
|
|
om.rows[4].step(om.game, 1)
|
|
check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE")
|
|
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
|
|
om.rows[4].step(om.game, 1)
|
|
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
|
|
om.rows[6].step(om.game, -1)
|
|
check(om.game.save.options.musicVol == 6, "music volume steps down")
|
|
for _ = 1, 10 do om.rows[6].step(om.game, -1) end
|
|
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
|
|
|
-- ZOOM / VOID FILL rows
|
|
local Zoom = require("src.render.Zoom")
|
|
local TileRenderer = require("src.render.TileRenderer")
|
|
om.game.save.options.zoom = 0
|
|
Zoom.offset = 0
|
|
check(om.rows[12].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
|
om.rows[12].step(om.game, 1)
|
|
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
|
"ZOOM row steps to IN1")
|
|
om.rows[13].step(om.game, 1)
|
|
check(om.game.save.options.voidFill == "water"
|
|
and TileRenderer.voidFill == "water",
|
|
"VOID FILL row cycles TREES → WATER")
|
|
om.rows[13].step(om.game, 1)
|
|
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
|
om.rows[13].step(om.game, 1)
|
|
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
|
|
|
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
|
om.game.save.options.fpsCap = nil
|
|
check(om.rows[15].value(om.game) == "60",
|
|
"MAX FPS row defaults to 60 with no saved cap")
|
|
om.rows[15].step(om.game, 1)
|
|
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
|
check(om.rows[15].value(om.game) == "75", "the MAX FPS row renders the cap")
|
|
om.game.save.options.fpsCap = 160
|
|
om.rows[15].step(om.game, 1)
|
|
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
|
om.rows[15].step(om.game, -1)
|
|
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
|
|
|
-- ------- FrameCap normalize / cycle (issue #88)
|
|
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
|
|
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
|
|
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
|
|
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
|
|
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
|
|
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
|
|
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
|
|
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
|
|
"FrameCap keeps the exact floor and ceiling")
|
|
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
|
|
"FrameCap.label renders the normalized cap as plain text")
|
|
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
|
|
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
|
|
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
|
|
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
|
|
check(FrameCap.cycle(nil, 1) == 75,
|
|
"FrameCap cycle normalizes a nil cap (60) before stepping")
|
|
-- apply drives the live value the run loop paces to; never touches love.timer
|
|
FrameCap.apply(144)
|
|
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
|
|
FrameCap.applyOptions({})
|
|
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
|
|
|
|
-- the MODS row is the manager's discoverable home
|
|
local mgGame = optGame()
|
|
om = OptionsMenu.new(mgGame)
|
|
om.rows[17].activate(mgGame)
|
|
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
|
"the MODS row opens the manager")
|
|
check(mgGame.stack:top().screenId == "ManagerState",
|
|
"the pushed manager carries its screen id")
|
|
|
|
-- ------- the CONTROLS row and BindingsMenu (gap C2's file-12 half)
|
|
local BindingsMenu = require("src.ui.BindingsMenu")
|
|
local cbGame = optGame()
|
|
om = OptionsMenu.new(cbGame)
|
|
om.rows[18].activate(cbGame)
|
|
local bm = cbGame.stack:top()
|
|
check(getmetatable(bm) == BindingsMenu,
|
|
"the CONTROLS row opens the rebind list")
|
|
check(bm.screenId == "BindingsMenu",
|
|
"the pushed rebind screen carries its screen id")
|
|
check(#bm.items == 8, "one row per logical button")
|
|
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
|
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
|
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
|
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
|
"with no rebind the rows mirror the fixed map")
|
|
check(cbGame.save.options.bindings == nil,
|
|
"opening the screen alone writes nothing")
|
|
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
|
"no raw-input claim until a capture is armed")
|
|
press(bm, "a")
|
|
check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
|
|
"A on a row arms the capture")
|
|
local wroteOptions = false
|
|
function cbGame:writeOptions() wroteOptions = true end
|
|
bm:onKeyPressed("j")
|
|
check(cbGame.save.options.bindings.up.key == "j",
|
|
"a captured key lands in options.bindings")
|
|
check(bm.items[1].right == "J", "the row shows the new key")
|
|
check(wroteOptions, "a rebind persists through writeOptions")
|
|
check(bm.capture == nil and bm.onKeyPressed == nil
|
|
and bm.onGamepadPressed == nil, "the capture disarms after one input")
|
|
bm.index = 5
|
|
press(bm, "a")
|
|
bm:onGamepadPressed("y")
|
|
check(cbGame.save.options.bindings.a.pad == "y",
|
|
"a captured pad button lands beside the key slot")
|
|
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
|
|
press(bm, "b")
|
|
check(#cbGame.stack.states == 0, "B closes the rebind screen")
|
|
|
|
-- Game routes pad buttons to a capturing top state and nowhere else
|
|
local Game = require("src.core.Game")
|
|
local Input = require("src.core.Input")
|
|
local gpGame = { stack = newStack() }
|
|
local sawPad
|
|
gpGame.stack:push({ onGamepadPressed = function(_, b) sawPad = b end })
|
|
Game.gamepadpressed(gpGame, nil, "y")
|
|
check(sawPad == "y", "pad buttons reach a capturing top state")
|
|
Input:init()
|
|
gpGame.stack:pop()
|
|
Game.gamepadpressed(gpGame, nil, "a")
|
|
Input:step()
|
|
check(Input:wasPressed("a"),
|
|
"without a capturing state pad input still feeds the mapped path")
|
|
|
|
local hookSawCancel = false
|
|
hooks:wrap("ui.options.rows", function(nextFn, game, rows)
|
|
for _, row in ipairs(rows) do
|
|
if row.label == "CANCEL" then hookSawCancel = true end
|
|
end
|
|
rows[#rows + 1] = { id = "quest_pace", label = "QUEST PACE",
|
|
value = function() return "OFF" end,
|
|
step = function() return true end }
|
|
return nextFn(game, rows)
|
|
end, 0, "fixture")
|
|
om = OptionsMenu.new(optGame())
|
|
check(om.rows[#om.rows].id == "quest_pace", "hook appends an options row")
|
|
check(not hookSawCancel, "CANCEL is appended after the hook, unreachable")
|
|
hooks:removeOwner("fixture")
|
|
|
|
hooks:wrap("ui.options.rows", function() return "nope" end, 0, "bad")
|
|
om = OptionsMenu.new(optGame())
|
|
check(#om.rows == #WANT_IDS, "a non-table rows result keeps the vanilla rows")
|
|
hooks:removeOwner("bad")
|
|
|
|
-- ------- ui.party.submenu
|
|
local PartyMenu = require("src.ui.PartyMenu")
|
|
local function partyGame()
|
|
return {
|
|
data = { pokemon = { PIKACHU = { name = "PIKACHU" } } },
|
|
save = {
|
|
party = { { species = "PIKACHU", hp = 10, stats = { hp = 10 },
|
|
level = 5, moves = { { id = "TACKLE" } } } },
|
|
inventory = {}, options = {},
|
|
},
|
|
stack = newStack(),
|
|
input = newInput(),
|
|
}
|
|
end
|
|
local pgame = partyGame()
|
|
local pm = PartyMenu.new(pgame)
|
|
pm.game = pgame
|
|
pgame.stack:push(pm)
|
|
press(pm, "a")
|
|
check(pm.submenu and #pm.subItems == 2
|
|
and pm.subItems[1].label == "STATS" and pm.subItems[2].label == "SWITCH",
|
|
"vanilla party submenu unchanged with no hooks")
|
|
pm.submenu = nil
|
|
|
|
local ranWith
|
|
hooks:wrap("ui.party.submenu", function(nextFn, game, items, mon, ctx)
|
|
table.insert(items, { label = "QUESTS",
|
|
onSelect = function(m) ranWith = m end })
|
|
return nextFn(game, items, mon, ctx)
|
|
end, 0, "fixture")
|
|
press(pm, "a")
|
|
check(#pm.subItems == 3 and pm.subItems[3].label == "QUESTS",
|
|
"hook appends a party submenu entry")
|
|
pm.subIndex = 3
|
|
press(pm, "a")
|
|
check(ranWith == pgame.save.party[1],
|
|
"an injected entry's onSelect runs with the focused mon")
|
|
check(not pm.submenu, "the submenu closes after an injected entry runs")
|
|
hooks:removeOwner("fixture")
|
|
|
|
hooks:wrap("ui.party.submenu", function() return nil end, 0, "bad")
|
|
press(pm, "a")
|
|
check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
|
|
hooks:removeOwner("bad")
|
|
pm.submenu = nil
|
|
|
|
-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180)
|
|
local switched
|
|
local bgame = partyGame()
|
|
local bpm = PartyMenu.new(bgame, {
|
|
battle = {},
|
|
onSwitch = function(mon) switched = mon end,
|
|
})
|
|
bpm.game = bgame
|
|
bgame.stack:push(bpm)
|
|
press(bpm, "a")
|
|
check(bpm.submenu and #bpm.subItems == 3
|
|
and bpm.subItems[1].label == "SWITCH"
|
|
and bpm.subItems[2].label == "STATS"
|
|
and bpm.subItems[3].label == "CANCEL",
|
|
"voluntary battle party opens SWITCH/STATS/CANCEL")
|
|
press(bpm, "a") -- SWITCH
|
|
check(switched == bgame.save.party[1], "battle SWITCH hands the mon to onSwitch")
|
|
check(bgame.stack:top() ~= bpm, "battle SWITCH closes the party menu")
|
|
|
|
local forced
|
|
local fgame = partyGame()
|
|
local fpm = PartyMenu.new(fgame, {
|
|
battle = {},
|
|
forceSwitch = true,
|
|
onSwitch = function(mon) forced = mon end,
|
|
})
|
|
fpm.game = fgame
|
|
fgame.stack:push(fpm)
|
|
press(fpm, "a")
|
|
check(not fpm.submenu and forced == fgame.save.party[1],
|
|
"forceSwitch still picks immediately (ChooseNextMon / SHIFT)")
|
|
|
|
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
|
do
|
|
local owStub = { strengthActive = false,
|
|
map = { def = { tileset = "OVERWORLD" } }, dark = false }
|
|
local sgame = partyGame()
|
|
sgame.overworld = owStub
|
|
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
|
sgame.save.inventory.RAINBOWBADGE = 1
|
|
sgame.save.party[1].moves = { { id = "STRENGTH" } }
|
|
local pm = PartyMenu.new(sgame)
|
|
pm.game = sgame
|
|
sgame.stack:push(pm)
|
|
press(pm, "a") -- open the submenu
|
|
check(pm.subItems[#pm.subItems].action == "strength",
|
|
"the strength row is listed with badge + move")
|
|
pm.subIndex = #pm.subItems
|
|
press(pm, "a") -- run STRENGTH
|
|
local states = sgame.stack.states
|
|
check(#states == 2 and states[1] == pm and states[2].pages ~= nil,
|
|
"the strength text sits over the still-open party menu")
|
|
check(owStub.strengthActive == true, "strength still activates")
|
|
end
|
|
|
|
-- ------- mod.ui helpers and theme defaults
|
|
local items = { { label = "A" }, { label = "B" } }
|
|
ModUI.insertAfter(items, "A", { label = "X" })
|
|
check(items[2].label == "X", "insertAfter lands behind its anchor")
|
|
ModUI.insertBefore(items, "A", { label = "Y" })
|
|
check(items[1].label == "Y", "insertBefore lands ahead of its anchor")
|
|
ModUI.removeLabel(items, "X")
|
|
check(#items == 3 and items[3].label == "B", "removeLabel drops the entry")
|
|
ModUI.insertBefore(items, "MISSING", { label = "Z" })
|
|
check(items[#items].label == "Z", "a missing anchor appends")
|
|
check(ModUI.Menu == require("src.ui.Menu"), "mod.ui exposes the widgets")
|
|
check(ModUI.TextBox == require("src.render.TextBox"),
|
|
"mod.ui exposes TextBox")
|
|
check(type(ModUI.push) == "function", "mod.ui.push opens screens")
|
|
|
|
check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC
|
|
and Theme.moreArrow == 0xEE, "theme defaults are the old literals")
|
|
check(Theme.choiceBox.tx == 14 and Theme.choiceBox.ty == 7
|
|
and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5,
|
|
"choice box geometry keeps its vanilla tiles (InitYesNoTextBoxParameters hlcoord 14,7)")
|
|
Theme.load({ field = { theme = { cursor = 0xAA } } })
|
|
check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph")
|
|
Theme.cursor = 0xED
|
|
|
|
-- ------- branding reads from field.*
|
|
local TitleState = require("src.ui.TitleState")
|
|
local tgame = { data = { field = { title = {
|
|
cycleSpecies = { "MEW" }, music = "My_Song", copyrightText = "HELLO",
|
|
} }, pokemon = { MEW = {} } } }
|
|
local title = TitleState.new(tgame, {})
|
|
check(#title.cycleSpecies == 1 and title.cycleSpecies[1] == "MEW",
|
|
"field.title.cycleSpecies replaces the literal list")
|
|
check(title.title.music == "My_Song", "field.title.music is read")
|
|
title = TitleState.new({ data = {} }, {})
|
|
check(#title.cycleSpecies == 16 and title.cycleSpecies[1] == "CHARMANDER",
|
|
"no data keeps the vanilla cycle list")
|
|
check(title.logo and title.logo.path == "assets/logo/pokemon_logo.png",
|
|
"no data keeps the shipped logo")
|
|
|
|
-- the importer seeds field.title with {path,width,height} descriptors
|
|
-- (data/generated/field.lua); they must load via their path, and the
|
|
-- file-12 plain-string shape must keep working
|
|
title = TitleState.new({ data = { field = { title = {
|
|
logo = { path = "assets/generated/title/pokemon_logo.png",
|
|
width = 128, height = 56 },
|
|
version = { path = "assets/generated/title/red_version.png",
|
|
width = 80, height = 8 },
|
|
} } } }, {})
|
|
check(title.logo and title.logo.path
|
|
== "assets/generated/title/pokemon_logo.png",
|
|
"a {path} logo descriptor loads its image")
|
|
check(title.version and title.version.path
|
|
== "assets/generated/title/red_version.png",
|
|
"the importer's version descriptor feeds the ribbon")
|
|
title = TitleState.new({ data = { field = { title = {
|
|
logo = "mods/x/logo.png", versionRibbon = "mods/x/ribbon.png",
|
|
} } } }, {})
|
|
check(title.logo and title.logo.path == "mods/x/logo.png",
|
|
"a plain-string logo path loads directly")
|
|
check(title.version and title.version.path == "mods/x/ribbon.png",
|
|
"versionRibbon wins as the file-12 patch key")
|
|
-- pin against the shipped data itself: a real boot must load the logo
|
|
-- art, never fall back to the ASCII placeholder
|
|
title = TitleState.new({ data = { field = dofile("data/generated/field.lua") } },
|
|
{})
|
|
check(title.logo and title.logo.path
|
|
== "assets/generated/title/pokemon_logo.png",
|
|
"the shipped field.title.logo loads its art")
|
|
check(title.version and title.version.path
|
|
== "assets/generated/title/red_version.png",
|
|
"the shipped version ribbon loads")
|
|
|
|
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
|
|
-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is
|
|
-- forced to pure white so ROM's {255,239,255} does not paint a pink band
|
|
-- against GBC-pack LOGO2/MEWMON whites.
|
|
do
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local prevVer, prevMode = GameVersion.get(), PaletteFX.mode
|
|
local blueLogo1 = {
|
|
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
|
|
}
|
|
local mewmon = {
|
|
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
|
|
}
|
|
local logo2 = {
|
|
{ 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 },
|
|
}
|
|
local game = { data = { palettes = { palettes = {
|
|
LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon,
|
|
} } } }
|
|
PaletteFX.setMode("redpp")
|
|
GameVersion.set("blue")
|
|
local zones = TitleState.sgbPalettes(title, game)
|
|
local ribbon = zones and zones[2] and zones[2].colors
|
|
check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239,
|
|
"Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++")
|
|
check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255
|
|
and ribbon[1][3] == 255,
|
|
"Blue title ribbon white is pure (no pink SGB band)")
|
|
GameVersion.set("red")
|
|
zones = TitleState.sgbPalettes(title, game)
|
|
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
|
|
ribbon = zones and zones[2] and zones[2].colors
|
|
check(ribbon and ribbon[4][1] == gbcLogo1[4][1]
|
|
and ribbon[4][2] == gbcLogo1[4][2]
|
|
and ribbon[4][3] == gbcLogo1[4][3],
|
|
"Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's")
|
|
GameVersion.set(prevVer)
|
|
PaletteFX.setMode(prevMode)
|
|
end
|
|
|
|
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
|
|
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
|
|
do
|
|
local logo2 = {
|
|
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
|
|
}
|
|
local logo1 = {
|
|
{ 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 },
|
|
}
|
|
local mewmon = {
|
|
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
|
|
}
|
|
local game = {
|
|
data = { palettes = { palettes = {
|
|
LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon,
|
|
} } },
|
|
stack = newStack(),
|
|
}
|
|
local bare = TitleState.sgbPalettes(title, game)
|
|
check(bare and #bare == 3 and bare[1].colors == logo2,
|
|
"bare title keeps three LOGO/MEWMON zones")
|
|
check(not bare[4], "bare title has no overlay trueColor zone")
|
|
|
|
game.stack:push(title)
|
|
-- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp
|
|
local Menu = require("src.ui.Menu")
|
|
local menu = Menu.new(game, { { label = "NEW GAME" } },
|
|
{ tx = 0, ty = 0, tw = 13, th = 4 })
|
|
menu.titleUiBox = { 0, 0, 12, 3 }
|
|
game.stack:push(menu)
|
|
local withMenu = TitleState.sgbPalettes(title, game)
|
|
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
|
|
"title menu adds a trueColor overlay zone")
|
|
check(withMenu[4].x == 0 and withMenu[4].y == 0
|
|
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
|
|
"menu overlay covers the CONTINUE/NEW GAME box")
|
|
|
|
game.stack:pop()
|
|
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
|
|
local withCont = TitleState.sgbPalettes(title, game)
|
|
check(withCont and #withCont == 4 and withCont[4].colors == false,
|
|
"continue-info overlay adds a trueColor zone")
|
|
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
|
|
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
|
|
"continue overlay matches DisplayContinueGameInfo's box")
|
|
|
|
-- openMenu itself must stamp titleUiBox on the real Menu it pushes
|
|
while game.stack:top() do game.stack:pop() end
|
|
title.game = game
|
|
title:openMenu()
|
|
local opened = game.stack:top()
|
|
check(opened and opened.titleUiBox
|
|
and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12,
|
|
"openMenu stamps titleUiBox on the pushed Menu")
|
|
end
|
|
|
|
-- Options / mod manager opened from the title must not inherit LOGO1
|
|
-- (third options box = rows 8-9 would otherwise tint pink).
|
|
do
|
|
local OptionsMenu = require("src.ui.OptionsMenu")
|
|
local ManagerState = require("src.mods.ManagerState")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local mewmon = {
|
|
{ 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 },
|
|
}
|
|
local game = { data = { palettes = { palettes = { MEWMON = mewmon } } },
|
|
save = { options = {} } }
|
|
local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game)
|
|
check(optZones and #optZones == 1 and optZones[1].colors == mewmon,
|
|
"OptionsMenu owns a whole-screen MEWMON zone")
|
|
local modZones = ManagerState.sgbPalettes(ManagerState, game)
|
|
check(modZones and #modZones == 1 and modZones[1].colors == mewmon,
|
|
"ManagerState owns a whole-screen MEWMON zone")
|
|
-- stack walk: with Options on top of Title, Game would pick Options
|
|
local titleZones = TitleState.sgbPalettes(title, {
|
|
data = { palettes = { palettes = {
|
|
LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } },
|
|
LOGO2 = mewmon, MEWMON = mewmon,
|
|
} } },
|
|
})
|
|
check(titleZones and titleZones[2].colors[1][2] == 255,
|
|
"title LOGO1 sanitize still pure-white with pink ROM input")
|
|
check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus")
|
|
end
|
|
|
|
local OakSpeech = require("src.ui.OakSpeech")
|
|
local ogame = { data = {
|
|
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
|
|
pokemon = { PIKACHU = {} }, trainers = {},
|
|
constants = { playerNameLength = 10 },
|
|
} }
|
|
local oak = OakSpeech.new(ogame, nil)
|
|
check(oak.demoSpecies == "PIKACHU", "field.oakSpeech.demoSpecies is read")
|
|
check(oak.nameLen == 10, "constants.playerNameLength caps the naming screen")
|
|
check(oak.cfg.music == "X_Song", "field.oakSpeech.music is read")
|
|
oak = OakSpeech.new({ data = {} }, nil)
|
|
check(oak.demoSpecies == "NIDORINO" and oak.nameLen == 7,
|
|
"no data keeps the vanilla speech values")
|
|
|
|
-- ------- intro.oak_speech.build
|
|
local vanillaSteps = OakSpeech.defaultSteps(oak)
|
|
check(#vanillaSteps == 9, "vanilla speech has nine steps")
|
|
check(vanillaSteps[1].id == "oak_welcome" and vanillaSteps[9].id == "shrink",
|
|
"vanilla speech anchors start and end")
|
|
|
|
hooks:wrap("intro.oak_speech.build", function(nextFn, steps, speech)
|
|
steps = nextFn(steps, speech)
|
|
ModUI.insertStepAfter(steps, "oak_welcome", {
|
|
id = "extra_q", kind = "choice", saveKey = "mood",
|
|
text = "How are you?", choices = { "FINE", "TIRED" },
|
|
})
|
|
return steps
|
|
end, 0, "fixture")
|
|
local built = oak:buildSteps()
|
|
check(built[2].id == "extra_q" and built[2].kind == "choice",
|
|
"intro.oak_speech.build can insert a choice after oak_welcome")
|
|
check(built[3].id == "demo_mon", "later vanilla steps shift down")
|
|
hooks:removeOwner("fixture")
|
|
|
|
hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
|
|
built = oak:buildSteps()
|
|
check(#built == 9 and built[1].id == "oak_welcome",
|
|
"a non-table intro.oak_speech.build result degrades to vanilla")
|
|
check(logged("intro.oak_speech.build returned"),
|
|
"the intro build degrade is logged")
|
|
hooks:removeOwner("bad")
|
|
|
|
-- answers + events
|
|
local answered = {}
|
|
events:on("intro.oak_speech.answered", function(ev)
|
|
answered[ev.saveKey] = ev.value
|
|
end, 0, "fixture")
|
|
oak.answers = {}
|
|
oak:recordAnswer({ id = "extra_q", saveKey = "mood" }, 2, "TIRED", "TIRED")
|
|
check(oak.answers.mood == "TIRED" and answered.mood == "TIRED",
|
|
"recordAnswer stores and emits intro.oak_speech.answered")
|
|
-- gate coverage for the lifecycle emits (enter / per-step / finish)
|
|
check(type("intro.oak_speech.started") == "string"
|
|
and type("intro.oak_speech.step") == "string"
|
|
and type("intro.oak_speech.finished") == "string",
|
|
"intro.oak_speech lifecycle event names are stable")
|
|
events:removeOwner("fixture")
|
|
|
|
-- issue #308: finish runs once even when a finished-listener pushes a
|
|
-- screen (the pop in finish() then removes that screen instead of the
|
|
-- speech, and a live shrink re-fires the event every frame)
|
|
do
|
|
local fgame = { data = {}, stack = newStack(), input = newInput(),
|
|
save = { player = {} } }
|
|
local fspeech = OakSpeech.new(fgame, nil)
|
|
fspeech.shrink = { frame = 103 } -- past the shrink timeline's end
|
|
fgame.stack:push(fspeech)
|
|
fspeech.picReveal = nil -- skip the intro fade so update reaches shrink
|
|
local finishes = 0
|
|
events:on("intro.oak_speech.finished", function()
|
|
finishes = finishes + 1
|
|
fgame.stack:push({ pushedByListener = true }) -- the #308 trap
|
|
end, 0, "t308")
|
|
for _ = 1, 3 do fspeech:update(1 / 60) end
|
|
events:removeOwner("t308")
|
|
check(finishes == 1,
|
|
"finished fires once when a listener pushes a screen")
|
|
end
|
|
|
|
-- ModUI step helpers
|
|
local tiny = { { id = "a" }, { id = "c" } }
|
|
ModUI.insertStepAfter(tiny, "a", { id = "b" })
|
|
check(tiny[2].id == "b", "ModUI.insertStepAfter anchors on step id")
|
|
ModUI.insertStepBefore(tiny, "c", { id = "b2" })
|
|
check(tiny[3].id == "b2", "ModUI.insertStepBefore anchors on step id")
|
|
ModUI.removeStep(tiny, "b")
|
|
check(tiny[2].id == "b2", "ModUI.removeStep drops by id")
|
|
|
|
local IntroMovie = require("src.ui.IntroMovie")
|
|
local introDone = false
|
|
local igame = { data = { field = { intro = {
|
|
studio = { card = "MY STUDIO", credit = "ME" }, skip = true,
|
|
music = "Alt_Battle",
|
|
} } }, stack = newStack(), input = newInput() }
|
|
local movie = IntroMovie.new(igame, function() introDone = true end)
|
|
check(movie.studio.card == "MY STUDIO" and movie.studio.credit == "ME",
|
|
"field.intro.studio strings are read")
|
|
check(movie.introCfg.music == "Alt_Battle", "field.intro.music is read")
|
|
igame.stack:push(movie)
|
|
movie:update(1 / 60)
|
|
check(introDone and #igame.stack.states == 0,
|
|
"field.intro.skip jumps straight past the movie")
|
|
|
|
local Credits = require("src.ui.Credits")
|
|
local credits = Credits.new({ data = { field = {
|
|
credits = { music = "My_Credits" } } } }, nil, nil)
|
|
check(credits.music == "My_Credits", "field.credits.music is read")
|
|
credits = Credits.new({ data = {} }, nil, nil)
|
|
check(credits.music == "Music_Credits", "no data keeps the vanilla song")
|
|
|
|
-- ------- ManagerState v2
|
|
check(ManagerState.onKeyPressed == nil,
|
|
"the manager reads mapped input, not raw keys")
|
|
check(ManagerState.screenId == "ManagerState",
|
|
"the manager carries its screen id for the F10 toggle")
|
|
|
|
local function manifest(id, over)
|
|
local m = { id = id, name = id:upper(), version = "1.0.0",
|
|
category = "OTHER", state = "loaded", enabled = true,
|
|
dependencySpecs = {}, conflictSpecs = {}, permissions = {},
|
|
description = "a mod" }
|
|
for k, v in pairs(over or {}) do m[k] = v end
|
|
return m
|
|
end
|
|
|
|
local function fakeLoader(available, loadErrors)
|
|
local loader = { optionSchemas = {}, modOptions = {},
|
|
events = Events.new(), errors = loadErrors or {} }
|
|
function loader:status()
|
|
return { available = available, loaded = {}, errors = self.errors,
|
|
order = {} }
|
|
end
|
|
function loader:setEnabled(id, enabled)
|
|
for _, m in ipairs(available) do
|
|
if m.id == id then m.enabled = enabled end
|
|
end
|
|
return true
|
|
end
|
|
return loader
|
|
end
|
|
|
|
local function managerGame(loader)
|
|
return { data = {}, stack = newStack(), input = newInput(),
|
|
save = { options = { mods = {} } }, mods = loader,
|
|
modStatus = loader:status() }
|
|
end
|
|
|
|
-- resolveToggle: the table-driven dependency cases
|
|
local RT = ManagerState.resolveToggle
|
|
local rtMods = {
|
|
base = manifest("base"),
|
|
addon = manifest("addon", { dependencySpecs = { { id = "base" } } }),
|
|
rival = manifest("rival", { conflictSpecs = { { id = "base" } } }),
|
|
old = manifest("old", { game_version = ">=99.0.0" }),
|
|
strict = manifest("strict",
|
|
{ dependencySpecs = { { id = "base", range = ">=2.0.0" } } }),
|
|
ghostly = manifest("ghostly", { dependencySpecs = { { id = "ghost" } } }),
|
|
}
|
|
local r = RT(rtMods, "base", false, { base = true })
|
|
check(r.apply.base == false and #r.alsoDisable == 0 and #r.missing == 0,
|
|
"clean flip: no cascade")
|
|
r = RT(rtMods, "base", false, { base = true, addon = true })
|
|
check(r.apply.base == false and r.apply.addon == false
|
|
and r.alsoDisable[1] == "addon", "disabling a dep cascades to dependents")
|
|
r = RT(rtMods, "addon", true, {})
|
|
check(r.apply.addon == true and r.apply.base == true
|
|
and r.alsoEnable[1] == "base", "enabling pulls hard deps in")
|
|
r = RT(rtMods, "ghostly", true, {})
|
|
check(r.missing[1] == "ghost", "a missing dep blocks")
|
|
r = RT(rtMods, "rival", true, { base = true })
|
|
check(r.conflicts[1] == "base", "a co-enabled conflict blocks")
|
|
r = RT(rtMods, "old", true, {})
|
|
check(#r.badVersion == 1 and r.badVersion[1].engine,
|
|
"an engine version mismatch blocks")
|
|
r = RT(rtMods, "strict", true, { base = true })
|
|
check(#r.badVersion == 1 and r.badVersion[1].id == "base",
|
|
"a dep range mismatch blocks")
|
|
|
|
-- errors are visible: glyph on the roster, message on the errors screen
|
|
local avail = {
|
|
manifest("badmod", { state = "failed", error = "boom" }),
|
|
manifest("okmod"),
|
|
}
|
|
local loader = fakeLoader(avail, { "badmod: boom" })
|
|
local mgame = managerGame(loader)
|
|
-- production wiring: the runtime error feed is the loader's error list
|
|
Runtime.errors = loader.errors
|
|
local ms = ManagerState.new(mgame)
|
|
mgame.stack:push(ms)
|
|
local rows = ms:modRows()
|
|
check(rows[1].header and rows[1].label == "OTHER",
|
|
"categories are section headers")
|
|
check(rows[2].mod.id == "badmod" and rows[2].glyph == "!",
|
|
"an errored mod carries the ! glyph")
|
|
check(rows[3].mod.id == "okmod" and rows[3].glyph == " ",
|
|
"a healthy mod has a clear gutter")
|
|
local lines = ms:errorLines(nil)
|
|
check(lines[1]:find("badmod: boom", 1, true) ~= nil,
|
|
"loader errors finally render in the manager")
|
|
check(ms:errorLines(avail[1])[1]:find("FAILED: boom", 1, true) ~= nil,
|
|
"the per-mod error leads its own view")
|
|
|
|
-- select stages a clean toggle; discard reverts it
|
|
check(ms.cursor == 2, "the cursor skips the category header")
|
|
press(ms, "select")
|
|
check(avail[1].enabled == false, "SELECT quick-toggles the focused mod")
|
|
check(ms:isStaged(avail[1]), "a flip against boot state is staged")
|
|
check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
|
|
check(mgame.save.options.mods.badmod == false,
|
|
"the live options table mirrors the flip")
|
|
check(ms.restartPending, "staged changes arm the apply screen")
|
|
ms:discardChanges()
|
|
check(avail[1].enabled == true and not ms.restartPending,
|
|
"discard restores the boot enable set")
|
|
|
|
-- cascade dialog: disabling a dep asks before flipping both
|
|
local avail2 = {
|
|
manifest("base"),
|
|
manifest("addon", { dependencySpecs = { { id = "base" } } }),
|
|
}
|
|
local mgame2 = managerGame(fakeLoader(avail2))
|
|
local ms2 = ManagerState.new(mgame2)
|
|
mgame2.stack:push(ms2)
|
|
ms2:beginToggle(ms2.byId.base)
|
|
check(ms2.overlay and ms2.overlay.kind == "confirm",
|
|
"a cascading toggle opens the consent dialog")
|
|
check(avail2[1].enabled and avail2[2].enabled,
|
|
"nothing flips before consent")
|
|
press(ms2, "a") -- YES
|
|
check(avail2[1].enabled == false and avail2[2].enabled == false,
|
|
"consent flips the whole closure")
|
|
|
|
-- blocked dialog: a missing dep explains and refuses
|
|
local avail3 = { manifest("lonely", { enabled = false, state = "disabled",
|
|
dependencySpecs = { { id = "ghost" } } }) }
|
|
local mgame3 = managerGame(fakeLoader(avail3))
|
|
local ms3 = ManagerState.new(mgame3)
|
|
mgame3.stack:push(ms3)
|
|
ms3:beginToggle(ms3.byId.lonely)
|
|
check(ms3.overlay and ms3.overlay.kind == "ok", "a blocked toggle explains")
|
|
check(ms3.overlay.lines[1] == "NEEDS ghost", "the dialog names the dep")
|
|
check(avail3[1].enabled == false, "a blocked toggle never flips")
|
|
press(ms3, "a")
|
|
check(ms3.overlay == nil, "the blocked dialog dismisses")
|
|
|
|
-- options auto-UI: schema rows edit, persist, emit, reset
|
|
local schema = {
|
|
{ key = "hardcore", label = "NUZLOCKE", type = "toggle", default = false },
|
|
{ key = "odds", label = "ODDS", type = "choice",
|
|
choices = { { "STD", "std" }, { "BOOST", "boosted" } }, default = "std" },
|
|
{ key = "startMoney", label = "START", type = "number",
|
|
min = 0, max = 9000, step = 1000, default = 3000 },
|
|
{ key = "tag", label = "RIVAL", type = "text", maxLen = 7,
|
|
default = "BLUE" },
|
|
{ bad = "row" },
|
|
}
|
|
loader.optionSchemas.okmod = schema
|
|
local heardOpt
|
|
loader.events:on("mod.options_changed", function(e) heardOpt = e end, 0, "t")
|
|
ms.currentMod = ms.byId.okmod
|
|
ms:openOptions(ms.byId.okmod)
|
|
check(ms.screen == "options", "OPTIONS.. routes to the options screen")
|
|
check(#ms.optionRows == 5, "four typed rows plus RESET; malformed skipped")
|
|
check(loader.errors[#loader.errors]:find("options row skipped", 1, true),
|
|
"the malformed row lands in the error feed")
|
|
ms.optionRows[1].step(mgame, 1)
|
|
check(mgame.save.options.modOptions.okmod.hardcore == true,
|
|
"a toggle edit persists to options.modOptions")
|
|
check(loader.modOptions.okmod.hardcore == true,
|
|
"the live value is visible to mod.options:get")
|
|
check(heardOpt and heardOpt.mod == "okmod" and heardOpt.key == "hardcore"
|
|
and heardOpt.value == true, "mod.options_changed fires on edit")
|
|
check(ms.optionRows[1].value(mgame) == "ON", "the toggle renders its state")
|
|
ms.optionRows[2].step(mgame, 1)
|
|
check(loader.modOptions.okmod.odds == "boosted"
|
|
and ms.optionRows[2].value(mgame) == "BOOST",
|
|
"a choice edit cycles and renders its label")
|
|
ms.optionRows[3].step(mgame, -1)
|
|
check(loader.modOptions.okmod.startMoney == 2000, "a number edit steps")
|
|
for _ = 1, 5 do ms.optionRows[3].step(mgame, -1) end
|
|
check(loader.modOptions.okmod.startMoney == 0, "number edits clamp at min")
|
|
ms.optionRows[4].activate()
|
|
check(getmetatable(mgame.stack:top()) == require("src.ui.NamingScreen"),
|
|
"a text row opens the naming screen")
|
|
mgame.stack:top().onDone("REDD")
|
|
mgame.stack:pop()
|
|
check(loader.modOptions.okmod.tag == "REDD", "the typed text persists")
|
|
ms.optionRows[5].activate()
|
|
check(loader.modOptions.okmod.hardcore == false
|
|
and loader.modOptions.okmod.odds == "std"
|
|
and loader.modOptions.okmod.startMoney == 3000
|
|
and loader.modOptions.okmod.tag == "BLUE",
|
|
"RESET DEFAULTS restores every schema default")
|
|
press(ms, "b")
|
|
check(ms.screen == "list", "B leaves the options screen")
|
|
|
|
-- profiles: save, drift to ad-hoc, apply, rename, delete
|
|
ms.tab = 2
|
|
ms:saveCurrentAs()
|
|
mgame.stack:top().onDone("EASY")
|
|
mgame.stack:pop()
|
|
local easy = ms:findProfile("EASY")
|
|
check(easy ~= nil and easy.enabled.badmod == true,
|
|
"SAVE CURRENT AS snapshots the enable set")
|
|
check(ms:optionsTable().activeProfile == "EASY", "the new profile is active")
|
|
ms:commitToggle({ okmod = false })
|
|
check(ms:optionsTable().activeProfile == nil,
|
|
"an off-profile toggle reverts to the ad-hoc set")
|
|
ms:applyProfile(easy)
|
|
check(ms.byId.okmod.enabled == true
|
|
and ms:optionsTable().activeProfile == "EASY",
|
|
"applying a profile stages the flips back")
|
|
ms:renameProfile(easy)
|
|
mgame.stack:top().onDone("HARD")
|
|
mgame.stack:pop()
|
|
check(easy.name == "HARD" and ms:optionsTable().activeProfile == "HARD",
|
|
"rename keeps the active pointer")
|
|
ms:deleteProfile(easy)
|
|
check(ms:findProfile("HARD") == nil
|
|
and ms:optionsTable().activeProfile == nil, "delete clears the profile")
|
|
|
|
-- permissions rows
|
|
local permy = manifest("permy", { permissions = { "network" } })
|
|
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
|
|
msP.game.stack:push(msP)
|
|
local prows = msP:permissionRows(permy)
|
|
check(prows[1].glyph == "!" and prows[1].label == "USES THE NETWORK",
|
|
"declared permissions render with risk glyphs")
|
|
check(msP:permissionRows(manifest("pure"))[1].label == "DATA & API ONLY",
|
|
"no permissions shows the synthetic clean row")
|
|
|
|
-- safe mode: the banner rides Runtime.safeMode, never an option
|
|
Runtime.safeMode = true
|
|
local msS = ManagerState.new(mgame)
|
|
msS:enter()
|
|
check(msS.banner == "SAFE MODE - ALL MODS OFF",
|
|
"safe mode shows the recovery banner")
|
|
Runtime.safeMode = nil
|
|
local msN = ManagerState.new(mgame)
|
|
msN:enter()
|
|
check(msN.banner == nil, "no safe mode, no banner")
|
|
|
|
-- empty roster shows the empty state and B closes the manager
|
|
local msE = ManagerState.new(managerGame(fakeLoader({})))
|
|
msE.game.stack:push(msE)
|
|
check(msE:modRows()[1].label == "NO MODS INSTALLED", "empty-state row")
|
|
press(msE, "b")
|
|
check(#msE.game.stack.states == 0, "B on the roster closes the manager")
|
|
|
|
-- ------- mod.ui through a loader-built api
|
|
-- the worked example in 12 6 does mod.ui.insertBefore / mod.ui.push /
|
|
-- mod.ui.Theme on the api the loader hands the entry chunk, so the facade
|
|
-- has to arrive wired there, not just exist as a module
|
|
local Loader = require("src.mods.Loader")
|
|
local uiFiles = {
|
|
["mods/uikit/manifest.json"] =
|
|
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
|
|
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
|
|
}
|
|
local uiFs = {
|
|
read = function(path) return uiFiles[path] end,
|
|
getInfo = function(path)
|
|
if uiFiles[path] then return { type = "file" } end
|
|
local prefix = path .. "/"
|
|
for key in pairs(uiFiles) do
|
|
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
|
end
|
|
return nil
|
|
end,
|
|
load = function(path)
|
|
if not uiFiles[path] then return nil, "no file: " .. path end
|
|
return load(uiFiles[path], path)
|
|
end,
|
|
getDirectoryItems = function(path)
|
|
local seen, names = {}, {}
|
|
local prefix = path .. "/"
|
|
for key in pairs(uiFiles) 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
|
|
names[#names + 1] = child
|
|
end
|
|
end
|
|
end
|
|
table.sort(names)
|
|
return names
|
|
end,
|
|
}
|
|
local uiLoader = Loader.new({ fs = uiFs })
|
|
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
|
|
local uiApi = _G.MOD_UI_API
|
|
_G.MOD_UI_API = nil
|
|
check(uiApi ~= nil, "the entry chunk received its api")
|
|
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
|
|
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
|
|
check(uiApi.ui.Menu == require("src.ui.Menu"),
|
|
"mod.ui widgets resolve through the loader-built api")
|
|
local uiItems = { { label = "ITEM" } }
|
|
uiApi.ui.insertBefore(uiItems, "ITEM", { label = "QUESTS" })
|
|
check(uiItems[1].label == "QUESTS" and uiItems[2].label == "ITEM",
|
|
"mod.ui.insertBefore works as documented")
|
|
local uiGame = { data = {}, stack = newStack() }
|
|
local uiPushed = uiApi.ui.push(uiGame, "TrainerCard")
|
|
check(uiGame.stack:top() == uiPushed and uiPushed.screenId == "TrainerCard",
|
|
"mod.ui.push opens a screen from a loader-built api")
|
|
|
|
Runtime.safeMode = savedSafeMode
|
|
Runtime.install(savedEvents, savedHooks, savedErrors)
|
|
|
|
S.finish()
|