-- 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", "performance", "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 (indices shifted +1 by the PERFORMANCE row spliced -- in ahead of COLORS) local Zoom = require("src.render.Zoom") local TileRenderer = require("src.render.TileRenderer") om.game.save.options.zoom = 0 Zoom.offset = 0 check(om.rows[13].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0") om.rows[13].step(om.game, 1) check(om.game.save.options.zoom == 1 and Zoom.offset == 1, "ZOOM row steps to IN1") om.rows[14].step(om.game, 1) check(om.game.save.options.voidFill == "water" and TileRenderer.voidFill == "water", "VOID FILL row cycles TREES → WATER") om.rows[14].step(om.game, 1) check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") om.rows[14].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[16].value(om.game) == "60", "MAX FPS row defaults to 60 with no saved cap") om.rows[16].step(om.game, 1) check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75") check(om.rows[16].value(om.game) == "75", "the MAX FPS row renders the cap") om.game.save.options.fpsCap = 160 om.rows[16].step(om.game, 1) check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") om.rows[16].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[18].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[19].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/D-UP" and bm.items[5].label == "A" and bm.items[5].right == "Z/A" and bm.items[7].label == "START" and bm.items[7].right == "ESC/START" and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK", "with no rebind the rows mirror the fixed map, key and pad both (#589)") 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") bm:onKeyReleased("j") -- a capture commits on the press's release (#589) check(cbGame.save.options.bindings.up.key == "j", "a captured key lands in options.bindings") check(bm.items[1].right == "J/D-UP", "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") bm:onGamepadReleased("y") check(cbGame.save.options.bindings.a.pad == "y", "a captured pad button lands beside the key slot") check(bm.items[5].right == "Z/Y", "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") -- #593: a profile carries mod options and the per-version save slot, and -- round-trips through the .g1rmodlist export local ModProfile = require("src.mods.ModProfile") ms:setOption("okmod", "hardcore", true) ms:saveCurrentAs() mgame.stack:top().onDone("SHARE") mgame.stack:pop() local shared = ms:findProfile("SHARE") check(shared.options.okmod.hardcore == true, "a profile snapshots per-mod options, not just the enable set") ms:setOption("okmod", "hardcore", false) ms:applyProfile(shared) check(loader.modOptions.okmod.hardcore == true, "applying a profile restores its mod options") local wire = ModProfile.encode(shared) local back = ModProfile.decode(wire) check(back and back.name == "SHARE" and back.options.okmod.hardcore == true, "a .g1rmodlist body round-trips through the data-only parser") check(ModProfile.decode("return {}") == nil, "a non-modlist file is refused") check(#ModProfile.missingIds({ enabled = { ghost = true } }, ms.byId) == 1, "a profile naming an uninstalled mod reports it missing") local seedOpts = { modProfiles = {} } ModProfile.ensureFirst(seedOpts, ms.status.available, {}) check(#seedOpts.modProfiles == 1 and seedOpts.modProfiles[1].name == "PROFILE 1" and seedOpts.modProfilesSeeded == true, "the pre-profiles setup migrates into PROFILE 1 once") seedOpts.modProfiles = {} ModProfile.ensureFirst(seedOpts, ms.status.available, {}) check(#seedOpts.modProfiles == 0, "seeding never runs twice") -- 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()