Merge branch 'dev' into spidercar2

This commit is contained in:
bryanthaboi
2026-08-24 08:48:24 -04:00
61 changed files with 4280 additions and 563 deletions
+7 -2
View File
@@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper,
local mainFile = assert(io.open("main.lua", "rb"))
local main = mainFile:read("*a")
mainFile:close()
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"returning from a game disables mod-owned secondary output")
check(main:find("SessionLifecycle.endGameSession", 1, true),
"returning from a game goes through SessionLifecycle.endGameSession")
local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb"))
local lifecycle = lifecycleFile:read("*a")
lifecycleFile:close()
check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
"endGameSession disables mod-owned secondary output")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
@@ -0,0 +1,336 @@
-- Validation and fail-closed behavior for battle.field_residual descriptors.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Events = require("src.mods.Events")
local GameMethods = require("src.core.Game")
local Hooks = require("src.mods.Hooks")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TypeChart = require("src.battle.TypeChart")
local data = T.fixtures.fresh()
TypeChart.load(data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
return battle
end
local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks,
Runtime.errors
local hooks = Hooks.new()
Runtime.install(savedEvents, hooks, {})
local battle = newBattle()
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local playerType = battle.player.curTypes[1]
local cyclic = { label = "cycle-data" }
cyclic.self = cyclic
local metatableData = setmetatable({ label = "plain-data" }, {
__index = { hidden = "metatable-data" },
})
battle.field.weather = {
id = "probe", turns = 3,
callback = function() end,
handle = io.stdout,
worker = coroutine.create(function() end),
cyclic = cyclic,
metatableData = metatableData,
}
battle.field.tokens[1] = { id = "nested", turns = 2,
state = { intensity = 4 } }
battle:enter()
hooks:wrap("battle.field_residual", function(next, context)
local vanilla = next(context)
T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list")
context.battlers.player.hp = 0
context.battlers.player.types[1] = "MUTATED"
T.eq(context.field.sides, nil,
"the field view has the checkpoint shape and no live side graph")
T.eq(context.field.weather.callback, nil,
"the field view omits executable values")
T.eq(context.field.weather.handle, nil,
"the field view omits userdata")
T.eq(context.field.weather.worker, nil,
"the field view omits threads")
T.eq(context.field.weather.cyclic.label, "cycle-data",
"the field view retains scalar data around a cycle")
T.eq(context.field.weather.cyclic.self, nil,
"the field view omits cyclic edges")
T.eq(getmetatable(context.field.weather.metatableData), nil,
"the field view carries no metatable")
T.eq(context.field.weather.metatableData.label, "plain-data",
"the field view retains raw data from a metatable-bearing table")
T.eq(context.field.weather.metatableData.hidden, nil,
"the field view does not expose metatable-provided values")
context.field.weather.turns = 0
context.field.tokens[1].state.intensity = 99
return {
false,
{ side = "unknown", amount = 20, message = "invalid side" },
{ side = "player", amount = "3", message = "numeric string" },
{ side = "player", amount = 0, message = "zero" },
{ side = "player", amount = -2, message = "negative" },
{ side = "player", amount = 1.5, message = "fractional" },
{ side = "player", amount = "not a number", message = "bad amount" },
{ side = "player", amount = 0 / 0, message = "not finite" },
{ side = "player", amount = math.huge, message = "non-finite" },
{ side = "player", amount = 2, message = function() end },
{ side = "enemy", amount = 3 },
}
end, 0, "validation_probe")
battle:applyFieldResiduals()
T.eq(battle.player.mon.hp, playerHp,
"a descriptor with a non-string message fails closed")
T.eq(battle.enemy.mon.hp, enemyHp - 3,
"a valid descriptor may omit its message")
T.eq(battle.player.curTypes[1], playerType,
"mutating the detached type view cannot mutate the live battler")
T.check(battle.player.mon.hp ~= 0,
"mutating detached HP cannot replace engine damage authority")
T.eq(battle.field.weather.turns, 3,
"mutating the detached weather view cannot mutate live field state")
T.eq(battle.field.tokens[1].state.intensity, 4,
"mutating nested detached token state cannot mutate live field state")
local guarded = newBattle()
local fieldReads, runtimeCalls = 0, 0
guarded.field = setmetatable({}, { __index = function()
fieldReads = fieldReads + 1
return nil
end })
local realRuntimeCall = Runtime.call
Runtime.call = function(...)
runtimeCalls = runtimeCalls + 1
return realRuntimeCall(...)
end
Runtime.install(savedEvents, Hooks.new(), {})
guarded:applyFieldResiduals()
Runtime.call = realRuntimeCall
T.eq(runtimeCalls, 0,
"a disabled field hook never enters Runtime.call")
T.eq(fieldReads, 0,
"a disabled field hook does not construct its field context")
local nilBattle = newBattle()
local nilHp = nilBattle.player.mon.hp
local nilHooks = Hooks.new()
Runtime.install(savedEvents, nilHooks, {})
nilHooks:wrap("battle.field_residual", function() return nil end,
0, "nil_probe")
nilBattle:applyFieldResiduals()
T.eq(nilBattle.player.mon.hp, nilHp,
"a non-table hook result fails closed")
local settled = newBattle()
local settledCalls = 0
local settledHooks = Hooks.new()
Runtime.install(savedEvents, settledHooks, {})
settledHooks:wrap("battle.field_residual", function(next, context)
settledCalls = settledCalls + 1
return next(context)
end, 0, "settled_probe")
settled.result = "win"
settled:endOfTurn()
T.eq(settledCalls, 0,
"a settled battle never invokes field residual policy")
local function drainQueue(battle)
local rows, guard = {}, 0
while battle.queue[1] and guard < 1000 do
guard = guard + 1
local row = table.remove(battle.queue, 1)
rows[#rows + 1] = row
if row.fn then
battle.nextInsert = 0
row.fn()
end
end
T.check(guard < 1000, "the simultaneous-faint queue completes")
return rows
end
local function simultaneousTerminal(order)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
double.phase, double.queue = "menu", {}
local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex
local startingExp = double.player.mon.exp
local events, doubleHooks = Events.new(), Hooks.new()
local awardCalls, expEvents, switches = 0, 0, 0
events:on("battle.exp_gained", function() expEvents = expEvents + 1 end,
0, "double_probe")
events:on("battle.battler_switched", function() switches = switches + 1 end,
0, "double_probe")
Runtime.install(events, doubleHooks, {})
doubleHooks:wrap("battle.field_residual", function(next, context)
local rows = next(context)
for _, side in ipairs(order) do
rows[#rows + 1] = {
side = side,
amount = context.battlers[side].hp,
}
end
return rows
end, 0, "double_probe")
doubleHooks:wrap("battle.exp_award", function(next, context)
awardCalls = awardCalls + 1
return next(context)
end, 0, "double_probe")
double:endOfTurn()
local queued = drainQueue(double)
T.eq(double.player.mon.hp, 0,
"simultaneous residuals settle the player side")
T.eq(double.enemy.mon.hp, 0,
"simultaneous residuals settle the enemy side")
T.eq(double.result, "lose",
"a simultaneous terminal residual resolves as player blackout")
T.eq(double.afterQueue, "finish",
"the completed simultaneous-faint queue closes the battle")
T.eq(double.player.faintQueued, true,
"the terminal hook batch queues player faint authority")
T.eq(double.enemy.faintQueued, nil,
"the terminal hook batch suppresses only its enemy faint authority")
T.eq(double.player.mon.exp, startingExp,
"a blackout does not award contradictory enemy-faint EXP")
T.eq(awardCalls, 0,
"a blackout never enters the enemy EXP-award policy")
T.eq(expEvents, 0,
"a blackout emits no contradictory EXP event")
T.eq(switches, 0,
"a blackout does not send the trainer's reserve into battle")
T.eq(double.enemyIndex, originalEnemyIndex,
"a blackout leaves the enemy roster position unchanged")
T.check(double.enemy == originalEnemy,
"a blackout queues no contradictory enemy replacement")
for _, row in ipairs(queued) do
T.eq(row.ui, nil,
"a simultaneous terminal residual queues no replacement UI")
end
end
simultaneousTerminal({ "player", "enemy" })
simultaneousTerminal({ "enemy", "player" })
local timing = newBattle()
local timingOrder = {}
timing.ruleset = require("src.battle.rulesets.modern_clean")
timing.player.mon.status = "PSN"
timing.field.tokens[1] = { id = "expires", turns = 1,
onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end }
local timingEvents, timingHooks = Events.new(), Hooks.new()
timingEvents:on("battle.turn_ended", function()
timingOrder[#timingOrder + 1] = "turn_ended"
end, 0, "timing_probe")
Runtime.install(timingEvents, timingHooks, {})
local preStatusHp = timing.player.mon.hp
timingHooks:wrap("battle.field_residual", function(next, context)
timingOrder[#timingOrder + 1] = "field_residual"
T.check(context.battlers.player.hp < preStatusHp,
"the hook snapshot observes completed vanilla status residuals")
return next(context)
end, 0, "timing_probe")
timing:endOfTurn()
T.same(timingOrder,
{ "field_residual", "token_expired", "turn_ended" },
"the hook runs before token expiry and battle.turn_ended")
local oldGetState, oldSetState = love.math.getRandomState,
love.math.setRandomState
local checkpointRng = "field-residual-rng"
love.math.getRandomState = function() return checkpointRng end
love.math.setRandomState = function(state) checkpointRng = state end
local function checkpointBattle()
local save = SaveData.newGame()
save.meta.playthroughId = "field-residual-checkpoint"
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
SaveData.validate(save, data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {},
scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
function overworld:restoreBattleContinuation(restored, origin)
restored.onFinish = function() end
return origin.kind == "wild_encounter" and origin.map == self.map.id
end
local game = setmetatable({ data = data, save = save, stack = stack,
overworld = overworld }, { __index = GameMethods })
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
battle.field.weather = { id = "checkpoint-weather", turns = 5 }
stack.states[2] = battle
return game, battle
end
local checkpointGame = checkpointBattle()
local checkpointHooks, restoredCalls = Hooks.new(), 0
Runtime.install(Events.new(), checkpointHooks, {})
checkpointHooks:wrap("battle.field_residual", function(next, context)
restoredCalls = restoredCalls + 1
T.same(context.field.weather,
{ id = "checkpoint-weather", turns = 5 },
"the enabled hook observes checkpointed field state after restore")
return next(context)
end, 0, "checkpoint_probe")
local snapshot, captureCode = Checkpoint.capture(checkpointGame)
T.check(snapshot ~= nil,
"an enabled process-local hook does not enter the checkpoint: "
.. tostring(captureCode))
if snapshot then
checkpointGame.stack:top().field.weather.turns = 1
local restored, restoreCode, restoreMessage =
Checkpoint.restore(checkpointGame, snapshot)
T.check(restored == true,
"field state reconstructs while the hook remains enabled: "
.. tostring(restoreCode or restoreMessage))
if restored then checkpointGame.stack:top():applyFieldResiduals() end
if restored then
T.same(Checkpoint.capture(checkpointGame), snapshot,
"enabled-hook field state completes capture/restore/capture round-trip")
end
end
T.eq(restoredCalls, 1,
"the process-local hook still runs after checkpoint reconstruction")
love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState
Runtime.install(savedEvents, savedHooks, savedErrors)
T.finish("battle.field_residual validation")
+2 -2
View File
@@ -100,8 +100,8 @@ local function probe(version)
end
return { type = "file" }
end
love.filesystem.getRealDirectory = function() return "/nowhere" end
love.filesystem.getSource = function() return "/elsewhere" end
love.filesystem.getRealDirectory = function() return "/source" end
love.filesystem.getSource = function() return "/source" end
RomImporter.isReady(version)
return seen, order
end
@@ -0,0 +1,133 @@
-- Chrome.printThrough (src/ui/gen2/Chrome.lua) used to run every string
-- through the GbcPalette shade-remap shader whenever a palette was given,
-- with no regard for whether the glyph it was about to draw came from a
-- tile page or from a TTF. The shader recovers a shade by reading the
-- RED CHANNEL of an already-rasterized 2bpp tile pixel (SHADER_SOURCE in
-- src/render/GbcPalette.lua); a TTF glyph is LÖVE's own anti-aliased
-- coverage mask, drawn as plain white with the current tint carrying the
-- ink colour, which that same channel read always reports as shade 0 --
-- painting every character the SAME colour as the paper rect printThrough
-- had just drawn behind it, i.e. invisible. Reported against a real Gold
-- build running a TTF translation mod: the naming screen's keyboard,
-- Diploma and Pokegear text all vanish, since all three draw through this
-- one routine (gen1recomp#1642).
--
-- The switch is per GLYPH, not per string: a TTF-mod build still keeps
-- multi-byte charmap sequences (the naming screen's own <PK>/<MN> cells,
-- the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on their ROM
-- tiles (src/render/Font.lua's Font.split), so one call can mix both kinds
-- of glyph and each must take its own path.
--
-- No real shader runs headless (love_stub does not stub newShader), so this
-- cannot check a rendered pixel. Font.encode/drawCode/advanceOf/width are
-- replaced with fakes that hand printThrough a fixed list of glyph codes,
-- so what is checked is the two things that decide the outcome: which
-- glyphs skip the shader, and what colour is active when each one draws.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local Font = require("src.render.Font")
-- Palette arrays are 1-indexed shade 0..3, same order GbcPalette.useRaw
-- reads (src/render/GbcPalette.lua channel()).
local PALETTE = { { 200, 220, 255 }, { 150, 170, 220 }, { 90, 100, 160 }, { 10, 10, 30 } }
local TILE_CODE = 0x80
local TTF_CODE = Font.TTF_BASE + 65 -- 'A', were it decoded
-- "text" is a plain list of glyph codes for this suite; Font.width/encode
-- pass it straight through, matching how a real caller's string would
-- decode to a list of codes.
Font.width = function(codes) return #codes * 8 end
Font.encode = function(codes) return codes end
Font.advanceOf = function(_code) return 8 end
local drawn
Font.drawCode = function(code, x, y)
drawn[#drawn + 1] = { code = code, x = x, y = y, color = { love.graphics.getColor() } }
end
local useRawCalls
local realUseRaw = GbcPalette.useRaw
GbcPalette.useRaw = function(...)
useRawCalls = useRawCalls + 1
return realUseRaw(...)
end
GbcPalette.available = function() return true end
local function colorsEq(a, b)
return math.abs(a[1] - b[1]) < 1e-9 and math.abs(a[2] - b[2]) < 1e-9
and math.abs(a[3] - b[3]) < 1e-9
end
-- ------------------------------------------------------- all tile glyphs
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TILE_CODE, TILE_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 1, "one shaded run binds the shader once, not per glyph")
T.eq(#drawn, 2, "both glyphs drew")
for i, d in ipairs(drawn) do
T.check(colorsEq(d.color, { 1, 1, 1, 1 }),
("tile glyph %d is tinted white, letting the shader pick the colour"):format(i))
end
end
-- -------------------------------------------------------- all TTF glyphs
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TTF_CODE, TTF_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 0, "a TTF glyph never binds the shade-remap shader")
T.eq(#drawn, 2, "both glyphs drew")
local ink = Chrome.throughPalette(PALETTE, false)[4]
for i, d in ipairs(drawn) do
T.check(colorsEq(d.color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
("TTF glyph %d is tinted with the palette's own ink colour"):format(i))
end
end
-- --------------------------------------------- mixed: tile, TTF, then tile
do
useRawCalls = 0
drawn = {}
Chrome.printThrough({ TILE_CODE, TTF_CODE, TILE_CODE }, 0, 0, PALETTE)
T.eq(useRawCalls, 2,
"the shader re-binds once per return to a tile glyph, not once for the whole string")
T.eq(#drawn, 3, "all three glyphs drew")
local ink = Chrome.throughPalette(PALETTE, false)[4]
T.check(colorsEq(drawn[1].color, { 1, 1, 1, 1 }), "1st (tile) glyph: white/shaded")
T.check(colorsEq(drawn[2].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"2nd (TTF) glyph, mid-string, still gets the ink tint")
T.check(colorsEq(drawn[3].color, { 1, 1, 1, 1 }), "3rd (tile) glyph: shaded again")
end
-- ---------------------------------------------------- inverted TTF ink
do
drawn = {}
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE, true)
local ink = Chrome.throughPalette(PALETTE, true)[4]
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"an inverted call tints TTF ink with the inverted palette's own shade-3 entry")
end
-- ---------------------------------------------- DMG mode's own TTF ink
do
GbcPalette.setMode("dmg")
drawn = {}
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE)
local ink = Chrome.throughPalette(PALETTE, false)[4]
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
"DMG mode's own resolved palette (four grey hardware shades) still reaches the TTF ink")
GbcPalette.setMode("gbc")
end
T.finish("gen2_chrome_print_through_ttf_test")
@@ -0,0 +1,119 @@
-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label
-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the
-- CONTINUE save-summary panel's labels (PLAYER <name>/BADGES/POKéDEX/TIME,
-- or NO SAVE FILE) as bare literals, invisible to a translation mod's
-- `strings` registry -- unlike the Gen 1 port's own title menu
-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows
-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a
-- mod-loaded Strings catalog and checks the translated text reaches
-- Font.draw, same technique as
-- tests/engine/gen2_naming_screen_translation_test.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
width = function() return 0 end,
}
local MainMenu = require("src.ui.gen2.MainMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name
-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER
-- row at (5, 2).
local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8
local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
local SAVE = { player = { name = "GOLD" } }
local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE",
"the title menu's first row draws in English with no mod loaded")
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM",
"and the clock box's AM/PM half")
drawn = {}
menu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
"the CONTINUE save-summary panel too")
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
drawn = {}
noSaveMenu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE",
"and its no-summary fallback")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["CONTINUE"] = "CONTINUAR",
["NEW GAME"] = "NUEVA PARTIDA",
["OPTION"] = "OPCIÓN",
["EXIT GAME"] = "SALIR",
["PM"] = "PM_ES",
["PLAYER %s"] = "JUGADOR %s",
["BADGES"] = "MEDALLAS",
["POKéDEX"] = "POKéDEX_ES",
["TIME"] = "TIEMPO",
["NO SAVE FILE"] = "SIN PARTIDA",
},
})
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR",
"a mod catalog reaches the title menu's first row")
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES",
"and the clock box's AM/PM half")
drawn = {}
menu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD",
"the save-summary panel's PLAYER row takes the mod's own word order")
T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES")
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX")
T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME")
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
drawn = {}
noSaveMenu:drawSavePanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA",
"and the no-summary fallback")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_main_menu_translation_test")
@@ -0,0 +1,100 @@
-- Gold's naming/keyboard screen (src/ui/gen2/NamingScreen.lua) had zero
-- Strings() calls: every prompt (YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/
-- BOX NAME?/NICKNAME?), the on-screen keyboard's own letters, and the
-- lower/UPPER/DEL/END bottom-row labels were bare literals, invisible to a
-- translation mod's `strings` registry (reported against a real Gold build,
-- gen1recomp#1642). The Gen 1 naming screen (src/ui/NamingScreen.lua) already
-- routes its title and every keyboard cell through Strings().
--
-- GbcPalette.available() is false headless (no real shader compiles), so
-- Chrome.printThrough already falls back to the plain, unshaded Chrome.print
-- -- this drives that path directly and checks the translated text reaches
-- Font.draw, the same technique
-- tests/engine/gen2_options_menu_translation_test.lua uses for the OPTION
-- screen.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
}
local NamingScreen = require("src.ui.gen2.NamingScreen")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
-- the prompt lands at tile (5, 2), the keyboard's first cell at (2, 8).
local PROMPT_X, PROMPT_Y = 5 * 8, 2 * 8
local FIRST_CELL_X, FIRST_CELL_Y = 2 * 8, 8 * 8
-- ---------------------------------------------- vanilla: no mod catalog
do
local screen = NamingScreen.new({}, { type = "player" })
drawn = {}
screen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "YOUR NAME?",
"the player-name prompt draws in English with no mod loaded")
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "A",
"and the keyboard's first cell too")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["YOUR NAME?"] = "TON NOM?",
["A"] = "À",
["lower"] = "minusc",
["END"] = "FIN",
["%s'S"] = "DE %s",
["NICKNAME?"] = "SURNOM?",
},
})
local screen = NamingScreen.new({}, { type = "player" })
drawn = {}
screen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "TON NOM?",
"a mod catalog reaches the prompt")
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "À",
"and a keyboard cell")
-- The bottom row: lower/DEL/END at tile y = keyboardTop + bottom*2.
local bottomY = (screen:keyboardTop() + screen:bottomRow() * 2) * 8
T.eq(drawnAt(2 * 8, bottomY), "minusc", "the case-switch label is translated")
T.eq(drawnAt(15 * 8, bottomY), "FIN", "and END, the way out of the screen")
-- The nickname header: two lines, the mon name folded into the first.
local nickScreen = NamingScreen.new({}, { type = "nickname", monName = "BULBASAUR" })
drawn = {}
nickScreen:drawPanel()
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "DE BULBASAUR",
"the nickname header's first line takes the mod's own word order")
T.eq(drawnAt(PROMPT_X, 4 * 8), "SURNOM?", "and its second line")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_naming_screen_translation_test")
@@ -0,0 +1,121 @@
-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row
-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF,
-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS
-- table, invisible to a translation mod's `strings` registry (reported
-- against a real Gold build, gen1recomp#1642). This drives
-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the
-- translated text reaches Font.draw, for both a cart-original row (label +
-- display value) and a port-added row (label only -- its value already
-- comes pre-translated from the shared module it calls, same as the Gen 1
-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the
-- fallback is unchanged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
-- Chrome.print (the only draw call this screen makes) goes straight to
-- Font.draw, so recording that call is enough to see exactly what text
-- reached the screen -- same technique as
-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before
-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's
-- own `local Font = require(...)` captures this stub instead of the real
-- module.
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
}
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
-- drawPanel puts labels at tile x=2 and values at tile x=11.
local LABEL_X = 2 * 8
local VALUE_X = 11 * 8
local function rowIndex(rows, id)
for i, row in ipairs(rows) do
if row.id == id then return i end
end
end
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = OptionsMenu.new({})
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED",
"row 1's label draws in English with no mod loaded")
-- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default.
T.eq(drawnAt(VALUE_X, 3 * 8), "MID ",
"and its cart-original display value too")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["TEXT SPEED"] = "VITESSE TEXTE",
["MID "] = "MOY ",
["CONTROLS"] = "COMMANDES",
["CANCEL"] = "ANNULER",
},
})
local menu = OptionsMenu.new({})
drawn = {}
menu:drawPanel()
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE",
"a mod catalog reaches a cart-original row's label")
T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ",
"and its cart-original display value")
-- CONTROLS is the first port-added row; scroll to it so it lands in the
-- VISIBLE_ROWS=7 window drawPanel actually draws.
local index = rowIndex(menu.rows, "controls")
T.check(index ~= nil, "CONTROLS is one of the rows")
menu.index = index
menu:ensureVisible()
drawn = {}
menu:drawPanel()
local slot = index - menu.scroll
T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES",
"and a port-added row's label is translated too")
-- CANCEL is the last row, built into ROWS like any other -- there is no
-- separate hook to fall through if this one row is missed.
local cancelMenu = OptionsMenu.new({})
local cancelIndex = #cancelMenu.rows
T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL")
cancelMenu.index = cancelIndex
cancelMenu:ensureVisible()
drawn = {}
cancelMenu:drawPanel()
local cancelSlot = cancelIndex - cancelMenu.scroll
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER",
"CANCEL, the way out of the menu, is translated too")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
T.finish("gen2_options_menu_translation_test")
@@ -0,0 +1,199 @@
-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would
-- you like to save the game?", the overwrite/saving/saved messages), the
-- YES/NO choice, and the summary panel's labels (PLAYER <name>/BADGES/
-- POKéDEX/TIME) as bare literals, invisible to a translation mod's
-- `strings` registry -- unlike the Gen 1 port's own SAVE screen
-- (src/ui/StartMenu.lua), which already routes the same rows through
-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a
-- mod-loaded Strings catalog and checks the translated text reaches
-- Font.draw, same technique as
-- tests/engine/gen2_naming_screen_translation_test.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local drawn
package.loaded["src.render.Font"] = {
draw = function(text, x, y)
drawn[#drawn + 1] = { text = text, x = x, y = y }
end,
drawCode = function() end,
drawBox = function() end,
width = function() return 0 end,
}
local SaveMenu = require("src.ui.gen2.SaveMenu")
local Strings = require("src.core.Strings")
local function drawnAt(x, y)
for _, d in ipairs(drawn) do
if d.x == x and d.y == y then return d.text end
end
return nil
end
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at
-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3).
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
local YES_X, YES_Y = 2 * 8, 8 * 8
local NO_X, NO_Y = 2 * 8, 10 * 8
local SAVE = { player = { name = "GOLD" } }
-- ---------------------------------------------- vanilla: no mod catalog
do
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
"the summary panel draws in English with no mod loaded")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to",
"and the confirm prompt's first line")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line")
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
menu.phase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
menu.phase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
menu.phase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
menu.phase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
end
-- ------------------------------------------------- a translation mod's turn
do
Strings.load({
strings = {
["PLAYER %s"] = "JOUEUR %s",
["BADGES"] = "BADGES_FR",
["POKéDEX"] = "POKéDEX_FR",
["TIME"] = "TEMPS",
["YES"] = "OUI",
["NO"] = "NON",
["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?",
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
["Could not save."] = "Echec de sauvegarde.",
},
})
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD",
"the summary panel's PLAYER row takes the mod's own word order")
T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES")
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX")
T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME")
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line")
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
menu.phase = "overwrite"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
menu.phase = "saving"
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
menu.phase, menu.saved = "done", true
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
"the saved message folds the player name into the mod's own word order")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
menu.phase, menu.saved = "done", false
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
-- Module state is process-global (see tests/gen2_clock_test.lua's own
-- note); this suite gets its own process from tests/tier_runner.lua, but
-- leaving the catalog loaded past this point would still mistranslate
-- every check below it in this file.
Strings.load({})
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
end
-- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/
-- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]`
-- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts).
-- Indexing a plain string with [1]/[2] returns nil, not characters, so this
-- shape is a cross-file contract: it caught a real regression during review,
-- where routing these through a single Strings.source()-wrapped string (to
-- translate SaveMenu's own screen) silently turned them into non-table
-- values and left PcMenu's overwrite/saving prompt blank.
do
T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua")
T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable")
T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line")
T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua")
T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable")
T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line")
end
-- A translation with a THIRD line (a second embedded "\n") has nowhere on
-- screen to go -- drawPanel's box has room for exactly two Chrome.print
-- calls -- so it must not silently draw the literal newline byte as glyph
-- garbage on the second line, and should warn so a translator notices.
do
Strings.load({
strings = {
["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois",
},
})
local warned = {}
require("src.core.Logger").warn = function(fmt, ...)
warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
end
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
drawn = {}
menu:drawPanel()
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box")
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois",
"the rest lands in the second slot rather than vanishing")
T.check(#warned == 1, "and a single warning is logged")
drawn = {}
menu:drawPanel()
T.check(#warned == 1, "the warning does not repeat for the same text")
require("src.core.Logger").warn = function() end
Strings.load({})
end
T.finish("gen2_save_menu_translation_test")
+94 -27
View File
@@ -1,5 +1,5 @@
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush.
-- luajit tests/engine/launcher_session_teardown_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime")
local Assets = require("src.render.Assets")
local LegacyCompat = require("src.mods.LegacyCompat")
local Game = require("src.core.Game")
local Game2 = require("src.core.Game2")
local Renderer = require("src.render.Renderer")
local StateStack = require("src.core.StateStack")
local SessionLifecycle = require("src.core.SessionLifecycle")
local MapLoader = require("src.world.MapLoader")
local World = require("src.world.gen2.World")
-- ---- Game:reset drops instance state, keeps methods ----------------------
do
@@ -38,6 +42,35 @@ do
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
end
-- ---- Game2:reset releases world GPU and present canvases ------------------
do
local game2 = Game2.new()
local canvas = love.graphics.newCanvas(4, 4)
game2.world = World.new({})
game2.world.mapImages = { ["MAP|d|1"] = canvas }
game2._canvases = { love.graphics.newCanvas(8, 8) }
game2:reset()
check(canvas.released == true, "Game2:reset releases World mapImages")
check(game2.world == nil, "Game2:reset clears world reference")
check(game2._canvases == nil, "Game2:reset clears _canvases")
end
-- ---- World:release frees owned GPU caches --------------------------------
do
local world = World.new({})
local bake = love.graphics.newCanvas(16, 16)
local strip = love.graphics.newCanvas(8, 64)
local tilt = love.graphics.newCanvas(160, 144)
world.mapImages = { ["R1|DAY|1"] = bake }
world.scrollStrips = { ["TS|1|0,0"] = strip }
world.tiltCanvas = tilt
world:release()
check(bake.released == true, "World:release frees map bake canvases")
check(strip.released == true, "World:release frees scroll strips")
check(tilt.released == true, "World:release frees tiltCanvas")
eq(next(world.mapImages), nil, "World:release clears mapImages table")
end
-- ---- Renderer:init releases prior canvases before realloc ----------------
do
local first = love.graphics.newCanvas(16, 16)
@@ -52,14 +85,13 @@ do
"Renderer:init allocates a fresh primary canvas")
check(Renderer.canvas.released ~= true,
"the new primary canvas is not released")
-- second init also releases the one just created
local second = Renderer.canvas
Renderer:init()
check(second.released == true,
"a second Renderer:init releases the canvas from the prior init")
end
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) --
do
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
Assets.installLoader({
@@ -68,44 +100,79 @@ do
})
LegacyCompat.reports = { some_mod = { order = {} } }
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
require("src.core.Data"):unloadGenerated()
Runtime.reset()
Assets.installLoader(nil)
LegacyCompat.reset()
SessionLifecycle.endMountedSession(nil)
check(Runtime.errors == nil, "teardown clears Runtime.errors")
check(Assets.loader == nil, "teardown clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
check(Runtime.errors == nil, "endMountedSession clears Runtime.errors")
check(Assets.loader == nil, "endMountedSession clears Assets.loader")
eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports")
end
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
-- ---- releaseSession empties MapLoader via releaseAll, not flush -------------
do
local data = {
maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1,
blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } },
tilesets = { TS = { id = "TS", image = "assets/generated/t.png",
walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } },
tilesPerRow = 1 } },
}
MapLoader.load(data, "T1")
check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release")
Assets.releaseSession()
check(MapLoader.cached("T1") == nil,
"releaseSession evicts MapLoader via releaseAll")
end
-- ---- Editor package.loaded discovery flush via endEditorSession -----------
do
love.filesystem.write("tools/save-editor/App.lua", "return {}")
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
package.loaded["App"] = { stale = true }
package.loaded["NewPanel"] = { stale = true }
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
package.loaded["src.core.Data"] = package.loaded["src.core.Data"]
local function isEditorFlat(name)
if name:find("[./]") then return false end
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
end
for k in pairs(package.loaded) do
if type(k) == "string"
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
package.loaded[k] = nil
end
end
SessionLifecycle.endEditorSession({ version = nil, app = nil })
check(package.loaded["App"] == nil, "discovery flush drops flat App")
check(package.loaded["App"] == nil, "endEditorSession drops flat App")
check(package.loaded["NewPanel"] == nil,
"discovery flush drops a new panel without a hardcoded list")
"endEditorSession drops a new panel without a hardcoded list")
check(package.loaded["src.core.Data"] ~= nil,
"discovery flush leaves engine modules alone")
"endEditorSession leaves engine modules alone")
love.filesystem.remove("tools/save-editor/App.lua")
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
end
-- ---- Fetch shutdown clears ready so Play-again can respawn workers ---------
do
local Fetch = require("src.net.Fetch")
local spawnAttempts = 0
love.thread = love.thread or {}
local savedNewThread = love.thread.newThread
local savedGetChannel = love.thread.getChannel
love.thread.getChannel = function()
return {
clear = function() end,
push = function() end,
pop = function() return nil end,
demand = function() end,
}
end
love.thread.newThread = function()
spawnAttempts = spawnAttempts + 1
return {
start = function() end,
wait = function() end,
getError = function() return nil end,
}
end
Fetch.available()
local afterFirst = spawnAttempts
Fetch.shutdown()
Fetch.available()
check(spawnAttempts > afterFirst,
"Fetch.available retries worker spawn after shutdown (ready=nil)")
love.thread.newThread = savedNewThread
love.thread.getChannel = savedGetChannel
end
T.finish("launcher_session_teardown_test")
@@ -0,0 +1,49 @@
-- Static privacy gate for the narrow Mew dock engine branch. It checks the
-- Git publication set, not ignored local imports: user ROMs and progress
-- saves may exist on a developer machine but must never become tracked files.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null"))
local paths = {}
if pipe then
for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end
pipe:close()
end
T.check(#paths > 100,
"privacy gate inspects a real Git publication set instead of passing vacuously")
local forbiddenExtensions = {
gb = true, gbc = true, gba = true, sav = true, srm = true,
rom = true, z64 = true, v64 = true, n64 = true, nds = true,
sfc = true, smc = true,
}
local forbiddenRuntimeRoots = {
["save.lua"] = true,
["save_blue.lua"] = true,
["save_yellow.lua"] = true,
["save_gold.lua"] = true,
["options.lua"] = true,
}
local binaryLeaks, runtimeLeaks = {}, {}
for _, path in ipairs(paths) do
local lower = path:lower()
local ext = lower:match("%.([^./\\]+)$")
if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end
if forbiddenRuntimeRoots[lower]
or lower:match("^saves/")
or lower:match("^imports/")
or lower:match("^mods%-data/") then
runtimeLeaks[#runtimeLeaks + 1] = path
end
end
T.eq(#binaryLeaks, 0,
"tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", "))
T.eq(#runtimeLeaks, 0,
"tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", "))
T.finish("mew dock private artifact gate")
+428
View File
@@ -0,0 +1,428 @@
-- Contract gate for the two narrow Gen 1 seams added for a composable
-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the
-- real hook bus and WorldAPI against hand-written maps and save snapshots.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local WorldAPI = require("src.world.WorldAPI")
local oldHooks = Runtime.hooks
local oldTextBox = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local story = dofile("data/scripts/story3.lua")
local VERSIONS = { "red", "blue", "yellow" }
local function newDock(version, wrappers)
local blocks, pushes, warps = {}, {}, {}
local rebuilds = 0
local save = {
version = version,
flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}
local map = {
id = "VERMILION_DOCK",
setBlock = function(_, bx, by, block)
blocks[bx .. "," .. by] = block
end,
renderer = { rebuild = function() rebuilds = rebuilds + 1 end },
}
local game = {
save = save,
data = { text = {
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
} },
stack = { push = function(_, value) pushes[#pushes + 1] = value end },
}
local ow = {
map = map,
player = { cellX = 14, cellY = 2, facing = "down" },
startWarpTo = function(_, ...)
warps[#warps + 1] = { ... }
end,
}
local hooks = Hooks.new()
Runtime.hooks = hooks
for _, entry in ipairs(wrappers or {}) do
hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0,
entry.owner)
end
story.VERMILION_DOCK.onEnter(game, ow)
if pushes[1] and pushes[1].done then pushes[1].done() end
return {
blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds,
save = save, map = map, game = game, ow = ow, hooks = hooks,
}
end
local function allowedWrapper(owner, inspect)
return {
owner = owner,
fn = function(nextFn, game, ctx)
if inspect then inspect(game, ctx) end
local downstream = nextFn(game, ctx)
local ownClaim = true
return downstream == true or ownClaim == true
end,
}
end
-- With no subscriber, the post-departure branch remains byte-for-byte
-- vanilla in effect: erase the ship, show its line, and eject the player.
do
local run = newDock("red")
T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock")
T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull")
T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull")
T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line")
T.eq(run.pushes[1].text, "The ship set sail.",
"vanilla re-entry preserves its dialogue")
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"vanilla re-entry ejects to Vermilion City")
end
-- The new permission seam is post-departure only. An ordinary HM01 exit
-- must not invoke it or change the existing departure-script path.
do
local hookCalls, queued = 0, nil
local hooks = Hooks.new()
Runtime.hooks = hooks
hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx)
hookCalls = hookCalls + 1
return nextFn(game, ctx)
end, 0, "must_not_run")
local savedMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
stop = function() end,
play = function() end,
}
local game = {
save = { version = "red", flags = { EVENT_GOT_HM01 = true } },
data = {},
}
local ow = {
player = { cellX = 14, cellY = 2 },
startDustAnim = function(_, _, _, done) if done then done() end end,
queueScript = function(_, rows) queued = rows end,
}
story.VERMILION_DOCK.onEnter(game, ow)
package.loaded["src.core.Music"] = savedMusic
T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam")
T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true,
"normal HM01 departure still sets the vanilla event flag")
T.check(type(queued) == "table" and #queued > 0,
"normal HM01 departure still queues its sail-away script")
end
-- One cooperative claimant may permit occupancy. The callback receives a
-- detached data snapshot, not the live overworld, map, player, or save.
do
local seenGame, seenCtx
local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx)
seenGame = game
seenCtx = {
mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion,
x = ctx.x, y = ctx.y,
}
ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1
end) })
T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly")
T.same(seenCtx, {
mapId = "VERMILION_DOCK", reason = "ss_anne_departed",
gameVersion = "red", x = 14, y = 2,
}, "occupancy context is the exact detached Red dock snapshot")
T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map")
T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X")
T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y")
T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save")
T.same(run.save, {
version = "red", flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}, "permission check preserves the complete save snapshot")
T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog")
T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy")
T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship")
T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout")
end
-- Standard hook composition also means a non-cooperative false/no-next
-- wrapper can suppress downstream claims. This remains safe because false
-- is denial; it cannot accidentally grant occupancy.
do
local downstreamCalls = 0
local run = newDock("red", {
{
owner = "denier", priority = 10,
fn = function() return false end,
},
{
owner = "unreached_claimant", priority = 0,
fn = function()
downstreamCalls = downstreamCalls + 1
return true
end,
},
})
T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics")
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"non-cooperative false remains fail-closed")
end
-- Cooperative peers all run through next(). A lower-priority peer claim is
-- preserved by a higher-priority peer that has no claim of its own.
do
local calls, contextIdentity = {}, nil
local run = newDock("blue", {
{
owner = "peer_high", priority = 10,
fn = function(nextFn, game, ctx)
calls[#calls + 1] = "high-before"
contextIdentity = ctx
local allowed = nextFn(game, ctx)
calls[#calls + 1] = "high-after"
return allowed == true or false
end,
},
{
owner = "peer_low", priority = 0,
fn = function(nextFn, game, ctx)
calls[#calls + 1] = "low"
T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance")
local allowed = nextFn(game, ctx)
local ownClaim = true
return allowed == true or ownClaim == true
end,
},
})
T.same(calls, { "high-before", "low", "high-after" },
"multiple peer handlers preserve hook-chain order")
T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain")
end
-- Absent, throwing, or malformed callbacks fail closed. Only boolean true
-- can turn off ejection; truthy strings/tables/numbers do not grant access.
do
local malformed = {
{ label = "nil", value = nil },
{ label = "false", value = false },
{ label = "string", value = "yes" },
{ label = "number", value = 1 },
{ label = "table", value = {} },
}
for _, case in ipairs(malformed) do
local run = newDock("red", { {
owner = "malformed_" .. case.label,
fn = function() return case.value end,
} })
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
"malformed " .. case.label .. " permission fails closed")
end
local beforeNext = newDock("red", { {
owner = "throws_before_next",
fn = function() error("fixture throws before next", 0) end,
} })
T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY",
"throwing callback before next fails closed")
local afterNext = newDock("red", { {
owner = "throws_after_next",
fn = function(nextFn, game, ctx)
nextFn(game, ctx)
error("fixture throws after next", 0)
end,
} })
T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY",
"throwing callback after next keeps the downstream denial")
end
-- The context carries one version only. A Red-only claimant must not leak
-- access into Blue or Yellow, and each call gets its own snapshot.
do
local contexts = {}
for _, version in ipairs(VERSIONS) do
local run = newDock(version, { {
owner = "red_only",
fn = function(nextFn, game, ctx)
contexts[#contexts + 1] = ctx
local downstream = nextFn(game, ctx)
return downstream == true or ctx.gameVersion == "red"
end,
} })
T.eq(#run.warps == 0, version == "red",
version .. " occupancy is decided only by its own version context")
end
T.eq(contexts[1].gameVersion, "red", "Red context stays Red")
T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue")
T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow")
T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3],
"Red, Blue, and Yellow calls do not share context tables")
end
-- Removing the owner is the engine's disable/uninstall path. It restores
-- vanilla denial immediately and leaves no save flag or serialized state.
do
local run = newDock("yellow", { allowedWrapper("removable") })
T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy")
run.hooks:removeOwner("removable")
local pushes, warps = {}, {}
run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end
run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end
story.VERMILION_DOCK.onEnter(run.game, run.ow)
if pushes[1] and pushes[1].done then pushes[1].done() end
T.eq(warps[1] and warps[1][1], "VERMILION_CITY",
"disabling the owner restores vanilla ejection")
T.eq(run.hooks.chains["map.occupancy_allowed"], nil,
"uninstall removes the occupancy chain itself")
T.eq(run.save.marker, "save-must-not-change",
"disable/uninstall writes no persistent permission state")
T.same(run.save, {
version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true },
marker = "save-must-not-change",
}, "disable/uninstall preserves the complete Yellow save snapshot")
end
-- activeBlockAt is read-only and fail-closed. A successful call exposes
-- only one scalar from the active runtime layout, never its backing table.
local function blockApi(version, blockAt)
local backing = { 4, 5, 6, 8, 9, 10 }
local map = {
id = "VERMILION_DOCK",
def = { width = 3, height = 2, blocks = backing },
blockAt = blockAt or function(_, bx, by)
return backing[by * 3 + bx + 1]
end,
}
local world = { isOverworld = true, map = map }
local game = {
save = { version = version },
stack = { states = { world } },
overworld = world,
}
return WorldAPI.new(game, "contract_fixture"), backing, game, map
end
do
for _, version in ipairs(VERSIONS) do
local api, backing = blockApi(version)
local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0)
T.eq(block, 5, version .. " reads its active dock block")
T.eq(err, nil, version .. " valid active block has no error")
block = 99
T.eq(backing[2], 5, version .. " scalar result cannot mutate the map")
end
local api = blockApi("red")
local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0)
T.eq(wrong, nil, "wrong map has no block result")
T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly")
local invalid = {
{ "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 },
{ "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 },
{ "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 },
}
for _, case in ipairs(invalid) do
local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3])
T.eq(value, nil, case[1] .. " returns no block")
T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type")
end
for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do
local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2])
T.eq(value, nil, "out-of-bounds coordinate returns no block")
T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly")
end
end
do
local malformed = {
{ label = "nil", get = function() return nil end },
{ label = "negative", get = function() return -1 end },
{ label = "fractional", get = function() return 1.5 end },
{ label = "infinite", get = function() return math.huge end },
{ label = "NaN", get = function() return 0 / 0 end },
{ label = "string", get = function() return "4" end },
{ label = "table", get = function() return {} end },
{ label = "throwing", get = function() error("bad map", 0) end },
}
for _, case in ipairs(malformed) do
local api = blockApi("red", case.get)
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed active block " .. case.label .. " returns no value")
T.eq(err, "block unavailable",
"malformed active block " .. case.label .. " fails closed")
end
end
-- Invalid map shapes are untrusted runtime data too. None may escape as a
-- block or raise through the mod facade.
do
local badDefs = {
{ label = "missing def", value = nil },
{ label = "missing width", value = { height = 2, blocks = {} } },
{ label = "string width", value = { width = "3", height = 2, blocks = {} } },
{ label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } },
{ label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } },
{ label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } },
{ label = "missing blocks", value = { width = 3, height = 2 } },
{ label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } },
}
for _, case in ipairs(badDefs) do
local api, _, _, map = blockApi("red")
map.def = case.value
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, case.label .. " returns no block")
T.eq(err, "block unavailable", case.label .. " fails closed")
end
local api, _, _, map = blockApi("red")
map.blockAt = nil
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "missing blockAt returns no block")
T.eq(err, "block unavailable", "missing blockAt fails closed")
map.blockAt = "not a function"
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed blockAt returns no block")
T.eq(err, "block unavailable", "malformed blockAt fails closed")
api, _, _, map = blockApi("red")
map.def.blocks = {}
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "sparse stored block slot returns no block")
T.eq(err, "block unavailable", "sparse stored block slot fails closed")
api, _, _, map = blockApi("red")
map.def.blocks[1] = "4"
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "malformed stored block slot returns no block")
T.eq(err, "block unavailable", "malformed stored block slot fails closed")
api, _, _, map = blockApi("red", function() return 5 end)
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "stored/accessor mismatch returns no block")
T.eq(err, "block unavailable", "stored/accessor mismatch fails closed")
end
do
local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture")
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
T.eq(block, nil, "no-overworld lookup returns no block")
T.eq(err, "no overworld", "no-overworld lookup reports its state")
end
Runtime.hooks = oldHooks
package.loaded["src.render.TextBox"] = oldTextBox
T.finish("mew dock seam contract")
@@ -0,0 +1,66 @@
-- No-mod and API-v1 parity for the additive mod.developer surface.
--
-- The production break this catches is a developer-mode loader path that
-- mutates vanilla data, creates mod state, or changes existing API-v1
-- behavior merely because the new public signal exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local function pristine()
return {
pokemon = { KEEP = { hp = 7 } },
moves = {},
}
end
for _, dev in ipairs({ false, true }) do
local data = pristine()
local files = {}
local run = T.sdk.loadNone({
data = data,
fs = T.sdk.memfs(files),
dev = dev,
})
T.eq(#run.errors, 0,
"no-mod load stays clean with developer=" .. tostring(dev))
T.eq(next(run.loader.mods), nil,
"no-mod load discovers nothing with developer=" .. tostring(dev))
T.eq(data.pokemon.KEEP.hp, 7,
"no-mod load preserves vanilla data with developer=" .. tostring(dev))
T.eq(next(files), nil,
"no-mod load creates no files with developer=" .. tostring(dev))
run.release()
end
local V1 = {
["mods/v1_probe/manifest.json"] = [[{
"id": "v1_probe",
"name": "V1 Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 1
}]],
["mods/v1_probe/main.lua"] = [[
local mod = ...
mod.exports.identity = mod.id .. "@" .. mod.version
mod.exports.payload = mod:read("payload.txt")
mod.options:define({ { key = "enabled", type = "toggle", default = true } })
mod.exports.defaultOption = mod.options:get("enabled")
]],
["mods/v1_probe/payload.txt"] = "unchanged-v1",
}
local legacy = T.sdk.loadMods({ "mods/v1_probe" }, {
fs = T.sdk.memfs(V1),
dev = false,
})
T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged")
local out = legacy.loader.exports.v1_probe
T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged")
T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged")
T.eq(out.defaultOption, true, "API-v1 options stay unchanged")
legacy.release()
T.finish("mod developer mode parity")
+102
View File
@@ -0,0 +1,102 @@
-- Public load-time developer-mode signal for sandboxed mods.
--
-- The production break this catches is a loader that computes dev mode but
-- does not expose the same fixed answer to the public mod object before the
-- entry chunk runs. It also protects the data-only contract: the public
-- value is a boolean snapshot, not a live loader or environment handle.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FILES = {
["mods/dev_probe/manifest.json"] = [[{
"id": "dev_probe",
"name": "Developer Mode Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2,
"games": ["all"]
}]],
["mods/dev_probe/main.lua"] = [[
local mod = ...
mod.exports.seenAtLoad = mod.developer
mod.exports.kind = type(mod.developer)
if mod.developer then
mod.commands:register("dev_probe:diagnostics", function() return true end)
end
]],
}
local function load(dev, generation)
return T.sdk.loadMods({ "mods/dev_probe" }, {
fs = T.sdk.memfs(FILES),
dev = dev,
generation = generation,
})
end
do
local run = load(true)
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, true,
"sandboxed entry code sees developer mode at load time")
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
"entry code can register diagnostics only in developer mode")
run.release()
end
do
local run = load(false)
T.eq(#run.errors, 0, "production-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, false,
"sandboxed entry code sees production mode at load time")
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
"production load does not register developer diagnostics")
run.release()
end
for _, provided in ipairs({ "yes", 1 }) do
local run = load(provided)
T.eq(#run.errors, 0,
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, false,
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
T.eq(out.kind, "boolean",
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
T.eq(run.loader.dev, false,
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
run.release()
end
do
local run = load(true, 2)
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
local out = run.loader.exports.dev_probe
T.eq(out.seenAtLoad, true,
"Gen 2 entry code sees the same developer-mode answer")
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
"Gen 2 entry code can gate diagnostics on the same signal")
run.release()
end
do
local saved = _G.POKEPORT_DEV_MODE
_G.POKEPORT_DEV_MODE = true
local ok, run = pcall(load, nil)
_G.POKEPORT_DEV_MODE = saved
if not ok then error(run, 0) end
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
"the --developer boot decision reaches the public signal")
run.release()
end
T.finish("mod developer mode public API")
+14 -5
View File
@@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
local mainSrc = source("main.lua")
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
check(quitHook ~= nil, "love.quit is still a single top-level function")
quitHook = quitHook or ""
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
"love.quit shuts the chip worker down")
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
"love.quit shuts the update worker down")
check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil,
"love.quit shuts workers down via SessionLifecycle.endProcess")
local lifecycleSrc = source("src/core/SessionLifecycle.lua")
check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil,
"SessionLifecycle exposes registerProcessShutdown")
check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil,
"SessionLifecycle.endProcess fans out registered hooks")
check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil,
"ChipAudio registers its shutdown hook at load")
check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil,
"Check registers its shutdown hook at load")
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
"Fetch registers its shutdown hook at load")
-- The Android half: LOVE keeps the JVM process after the native main returns,
-- so the quit event exits the process outright. It has to sit after the
+176
View File
@@ -0,0 +1,176 @@
-- The cache contract is the shared Lua-side publication boundary. A writer
-- may stage outputs in any order, but readiness is published only after the
-- version-specific required set exists.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local eq = T.eq
local CacheContract = require("src.import.CacheContract")
local fs = { prefix = "initial/", files = {} }
local writes = {}
function fs.exists(path)
return fs.files[fs.prefix .. path] ~= nil
end
function fs.read(path)
return fs.files[fs.prefix .. path]
end
function fs.write(path, value)
writes[#writes + 1] = fs.prefix .. path
fs.files[fs.prefix .. path] = value
return true
end
function fs.remove(path)
fs.files[fs.prefix .. path] = nil
end
local required, isOverride = CacheContract.requiredFilesFor("red")
check(not isOverride, "Red uses the shared required-file list")
check(#required > 0, "Red has required outputs")
eq(CacheContract.markerFor("red"),
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
"marker contains format and Red SHA-1")
for index = 1, #required - 1 do
fs.files["red/" .. required[index]] = true
end
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
check(not complete, "missing output keeps cache incomplete")
eq(missing, required[#required], "missing output is reported")
local published, publishError = CacheContract.publish("red", fs)
check(not published, "incomplete cache is not published")
check(publishError ~= nil, "incomplete publication explains the missing output")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete cache has no completion marker")
-- Publication must remove a stale marker left by an interrupted replacement,
-- and must restore the caller prefix on both the success and failure paths.
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
local removedMarker = CacheContract.publish("red", fs)
check(not removedMarker, "incomplete retry is still rejected")
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
"incomplete retry removes a stale completion marker")
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
fs.files["red/" .. required[#required]] = true
fs.prefix = "caller/prefix/"
fs.files["red/" .. required[#required]] = true
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
published, publishError = CacheContract.publish("red", fs)
check(published, "complete cache is published")
eq(publishError, nil, "complete publication has no error")
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
"marker is written under the version prefix")
local marker = CacheContract.readMarker("red", fs)
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
"the marker is the only publication write and comes last")
-- Every supported version gets its own marker and complete cache semantics;
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
local versionFiles, override = CacheContract.requiredFilesFor(version)
for _, path in ipairs(versionFiles) do
fs.files[version .. "/" .. path] = true
end
if not override then
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
fs.files[version .. "/" .. path] = true
end
end
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
local didPublish = CacheContract.publish(version, fs)
check(didPublish, version .. " complete cache publishes")
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
CacheContract.markerFor(version), version .. " marker is version-scoped")
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
end
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
check(goldOverride, "Gold uses a version-specific required set")
local goldSet = {}
for _, path in ipairs(gold) do goldSet[path] = true end
check(goldSet["assets/generated/battle/hud/balls.png"],
"Gold required set includes trainer HUD art")
check(not goldSet["assets/generated/trade/game_boy.png"],
"Gold required set excludes Gen 1 trade art")
check(goldSet["data/generated/rom_text.lua"],
"Gold required set includes the Gen 2 engine text table")
local silver = CacheContract.requiredFilesFor("silver")
local silverSet = {}
for _, path in ipairs(silver) do silverSet[path] = true end
check(silverSet["data/generated/rom_text.lua"],
"Silver required set includes the Gen 2 engine text table")
check(not silverSet["assets/generated/trade/game_boy.png"],
"Silver required set excludes Gen 1 trade art")
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
"Yellow has version-specific required outputs")
-- A throwing adapter must not strand the process in its temporary prefix.
local throwingFs = { prefix = "before/" }
function throwingFs.exists() error("probe failed") end
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
function throwingFs.write() error("write failed") end
function throwingFs.remove() end
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
throwingFs.files = throwingFs.files or {}
throwingFs.files["blue/" .. path] = true
end
function throwingFs.exists(path)
return throwingFs.files[throwingFs.prefix .. path] ~= nil
end
local wrote = CacheContract.publish("blue", throwingFs)
check(not wrote, "write errors are returned")
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
-- Source-tree readiness must use the same version lists and reject a cache
-- when LÖVE cannot identify a real source directory.
local oldLove = love
love = nil
check(not CacheContract.sourceTreeHasData("red"),
"source-tree check is safe without LÖVE")
local sourceFiles = {}
love = {
filesystem = {
getRealDirectory = function(path) return sourceFiles[path] end,
getSource = function() return "/source" end,
getInfo = function(path)
return sourceFiles[path] and { type = "file" } or nil
end,
},
}
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
check(CacheContract.sourceTreeHasData("red"),
"Red source tree uses the shared required set")
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
check(not CacheContract.sourceTreeHasData("red"),
"source-tree readiness rejects a cache-overlaid required file")
sourceFiles = {}
local goldFiles = CacheContract.requiredFilesFor("gold")
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
check(CacheContract.sourceTreeHasData("gold"),
"Gold source tree uses its override set")
love = oldLove
-- Both importer completion paths must call the shared publication boundary.
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
local importerSource = importerFile:read("*a")
importerFile:close()
local completionCalls = 0
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
completionCalls = completionCalls + 1
end
eq(completionCalls, 1,
"both thread and coroutine paths converge on one publishing helper")
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
~= nil, "coroutine completion uses the shared helper")
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
"thread completion uses the shared helper")
T.finish("rom cache contract")
+20 -18
View File
@@ -1,32 +1,34 @@
-- sourceTreeHasData must use each version's required-file list. Gold's
-- cache has no Gen 1 trade art / pikachu.png; validating it against
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
-- tree look incomplete forever.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
local CacheContract = require("src.import.CacheContract")
local f = assert(io.open("src/import/RomImporter.lua", "r"))
local src = f:read("*a")
f:close()
local start = src:find("local function sourceTreeHasData", 1, true)
check(start ~= nil, "sourceTreeHasData is defined")
local finish = src:find("\nfunction RomImporter.isReady", start, true)
check(finish ~= nil, "sourceTreeHasData ends before isReady")
local body = src:sub(start, finish)
local readyStart = src:find("function RomImporter.isReady", 1, true)
check(readyStart ~= nil, "isReady is defined")
local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
check(readyEnd ~= nil, "isReady ends before the next importer helper")
local readyBody = src:sub(readyStart, readyEnd)
check(body:find("requiredFilesFor", 1, true) ~= nil,
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
"isReady delegates source-tree and cache readiness to the contract")
check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
"isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
local helperStart = src:find("local function requiredFilesFor", 1, true)
check(helperStart ~= nil, "requiredFilesFor helper exists")
local helper = src:sub(helperStart, start)
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
local required, isOverride = CacheContract.requiredFilesFor("gold")
check(isOverride, "Gold uses the override required-file list")
local requiredSet = {}
for _, path in ipairs(required) do requiredSet[path] = true end
check(requiredSet["assets/generated/battle/hud/balls.png"],
"Gold caches require the trainer HUD ball sheet")
check(not requiredSet["assets/generated/trade/game_boy.png"],
"Gold does not inherit the Gen 1 trade-art requirement")
T.finish()
+7 -7
View File
@@ -84,15 +84,15 @@ if extractor then
end
-- a cache imported before #750 has none of the art; listing one of the
-- files in REQUIRED_FILES is what makes it re-import
local importer = readFile("src/import/RomImporter.lua")
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
if importer then
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "REQUIRED_FILES parses")
-- files in the engine-owned cache contract is what makes it re-import
local contract = readFile("src/import/CacheContract.lua")
T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
if contract then
local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
T.check(required ~= nil and required:find(
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
"cache contract makes pre-#750 caches re-import the trade art")
end
T.finish("trade art import")
+12
View File
@@ -216,8 +216,12 @@ for name, module in pairs({ Assets = Assets, TileRenderer = TileRenderer,
end
check(type(require("src.world.MapLoader").invalidateAll) == "function",
"MapLoader keeps its wave-1 invalidateAll")
check(type(require("src.world.MapLoader").releaseAll) == "function",
"MapLoader exposes releaseAll for session end")
check(type(require("src.core.Sound").invalidate) == "function",
"Sound keeps its wave-1 invalidate")
check(type(Assets.releaseSession) == "function",
"Assets exposes releaseSession for in-process session end")
-- the central cache hands back one image per resolved path, and flush
-- fans out to every registered downstream cache
@@ -238,6 +242,14 @@ Assets.register(function() reached = true end)
Assets.flush()
check(reached, "a throwing invalidator does not stop the fan-out")
-- flush/invalidate must not run release hooks (HotReload / live overworld safe)
local releaseCalls = 0
Assets.register({ release = function() releaseCalls = releaseCalls + 1 end })
Assets.flush()
check(releaseCalls == 0, "flush() does not call release hooks")
Assets.releaseSession()
check(releaseCalls == 1, "releaseSession() calls registered release hooks")
-- ------- animated tiles as tileset data
local overworld = TileRenderer.defaultAnimatedTiles(
@@ -0,0 +1,145 @@
-- A sandboxed mod can contribute data-only field residual damage while the
-- engine retains HP, queue, and faint authority. The case also proves that
-- no-mod battles allocate no hook context and remain byte-equivalent.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
local FIXTURE = {
["mods/field_residual_probe/manifest.json"] = [[{
"id": "field_residual_probe",
"name": "Field Residual Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/field_residual_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("battle.field_residual", function(next, context)
mod.exports.calls = (mod.exports.calls or 0) + 1
mod.exports.context = context
local callback = context.field.tokens[1]
and context.field.tokens[1].onExpire
mod.exports.callbackType = type(callback)
if callback then callback() end
local rows = next(context)
rows[#rows + 1] = {
side = "player", amount = 7,
message = context.battlers.player.name .. " is buffeted!",
}
rows[#rows + 1] = {
side = "enemy", amount = 999,
message = context.battlers.enemy.name .. " is buffeted!",
}
return rows
end)
]],
}
local function newBattle(data)
TypeChart.load(data)
local save = SaveData.newGame()
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
local game = {
data = data,
save = save,
stack = { top = function() return nil end, push = function() end },
}
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.phase, battle.queue = "menu", {}
battle.field.weather = { id = "sand", turns = 4, source = "probe" }
return battle
end
local vanilla = T.sdk.loadNone({})
local plain = newBattle(vanilla.data)
local plainPlayerHp, plainEnemyHp = plain.player.mon.hp, plain.enemy.mon.hp
plain:endOfTurn()
T.eq(plain.player.mon.hp, plainPlayerHp,
"no-mod end of turn preserves player HP")
T.eq(plain.enemy.mon.hp, plainEnemyHp,
"no-mod end of turn preserves enemy HP")
T.eq(plain.field.weather.turns, 4,
"no-mod path does not reinterpret an unknown data-only field extension")
vanilla.release()
local run = T.sdk.loadMods({ "mods/field_residual_probe" }, {
fs = T.sdk.memfs(FIXTURE),
})
T.eq(#run.errors, 0,
"the public field-residual probe loads cleanly")
local battle = newBattle(run.data)
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
local callbackInvocations = 0
battle.field.tokens[1] = {
id = "callback-bearing", turns = 2,
state = { intensity = 4 },
onExpire = function() callbackInvocations = callbackInvocations + 1 end,
}
battle.player.invulnerable = true
battle:endOfTurn()
local out = run.loader.exports.field_residual_probe or {}
T.eq(out.calls, 1, "the public hook runs exactly once at round end")
T.check(out.context.field ~= battle.field,
"the hook receives a detached checkpoint-shaped field view")
T.same(out.context.field.weather,
{ id = "sand", turns = 4, source = "probe" },
"the detached field view carries checkpointed weather state")
T.eq(out.context.field.sides, nil,
"the detached field view exposes no live battler aliases")
T.eq(out.callbackType, "nil",
"the sandboxed wrapper cannot obtain a live field callback")
T.eq(callbackInvocations, 0,
"the sandboxed wrapper cannot invoke the engine-owned callback")
T.same(out.context.field.tokens[1], {
id = "callback-bearing", turns = 2, state = { intensity = 4 },
}, "the public field view retains data while omitting the callback")
T.eq(out.context.turn, battle.turnCount or 0,
"the hook receives the current turn counter")
T.eq(out.context.battle, nil,
"the hook does not expose the live engine battle object")
T.eq(out.context.battlers.player.side, "player",
"the detached player snapshot identifies its side")
T.eq(out.context.battlers.enemy.side, "enemy",
"the detached enemy snapshot identifies its side")
T.eq(out.context.battlers.player.vanished, true,
"the detached view reports Gen1 semi-invulnerability")
T.eq(out.context.battlers.player.hp, playerHp,
"the player snapshot carries pre-residual HP")
T.eq(out.context.battlers.enemy.hp, enemyHp,
"the enemy snapshot carries pre-residual HP")
T.check(out.context.battlers.player ~= battle.player,
"the public battler view is detached from the engine wrapper")
T.check(out.context.battlers.player.types ~= battle.player.curTypes,
"the public type list is detached")
T.eq(battle.player.mon.hp, playerHp - 7,
"the engine applies the validated player residual amount")
T.eq(battle.enemy.mon.hp, 0,
"the engine clamps residual damage to current HP")
T.eq(battle.enemy.faintQueued, true,
"the engine, not the mod, owns residual faint orchestration")
local sawPlayerMessage, sawEnemyMessage, drains = false, false, 0
for _, row in ipairs(battle.queue) do
local text = row.text and tostring(row.text) or ""
if text:find("buffeted", 1, true) then
if text:find(battle.player.name, 1, true) then sawPlayerMessage = true end
if text:find(battle.enemy.name, 1, true) then sawEnemyMessage = true end
end
if row.drain then drains = drains + 1 end
end
T.check(sawPlayerMessage and sawEnemyMessage,
"validated public messages enter the normal battle queue")
T.check(drains >= 2,
"residual HP changes use normal engine drain rows")
run.release()
T.finish("battle.field_residual public seam")
+103
View File
@@ -0,0 +1,103 @@
-- A sandboxed mod can read the outcome of a link battle -- who won, and the
-- lockstep party copies -- through the public event surface.
--
-- The copies are the point. Cable rules leave the real party untouched, so
-- a mode built on link battles (a tournament ladder, a battle royale) has no
-- other way to learn what the fight cost; by the time the state unwinds the
-- battle object is gone.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local LinkState = require("src.link.LinkState")
local FIXTURE = {
["mods/outcome_probe/manifest.json"] = [[{
"id": "outcome_probe",
"name": "Outcome Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/outcome_probe/main.lua"] = [[
local mod = ...
mod.exports.seen = 0
mod.events:on("link.battle_ended", function(ev)
mod.exports.seen = mod.exports.seen + 1
mod.exports.result = ev.result
mod.exports.role = ev.role
mod.exports.peerName = ev.peerName
mod.exports.myLead = ev.myParty and ev.myParty[1]
mod.exports.theirLead = ev.theirParty and ev.theirParty[1]
end)
]],
}
-- a link session parked at the end of a battle: no transport, so update()
-- goes straight to the stage that reports the outcome
local function finishedSession(result, isHost)
local ls
ls = setmetatable({
stage = "battleRunning",
isHost = isHost,
peerName = "BLUE",
net = nil,
battle = {
result = result,
playerParty = { { species = "RATTATA", hp = 3 } },
enemyParty = { { species = "PIDGEY", hp = 0 } },
},
game = { input = {}, stack = { top = function() return ls end } },
}, { __index = LinkState })
-- the real exit unwinds the whole link stack; the event is what is under
-- test, so record the exit rather than run it
ls.exitWith = function() ls.exited = true end
return ls
end
-- ------- no mod: the battle still ends, nothing observes it
local Runtime = require("src.mods.Runtime")
local vanilla = T.sdk.loadNone({})
T.check(not Runtime.wants("link.battle_ended"),
"with nothing subscribed the event is not wanted, so no payload is built")
local quiet = finishedSession("win", true)
quiet:update(0)
T.check(quiet.exited, "with no mod loaded the finished battle still unwinds")
T.eq(quiet.battle, nil, "and lets go of the battle")
vanilla.release()
-- ------- a mod reads the outcome and both party copies
local run = T.sdk.loadMods({ "mods/outcome_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the public outcome probe loads clean (" .. tostring(run.errors[1]) .. ")")
local session = finishedSession("win", true)
session:update(0)
local out = run.loader.exports.outcome_probe or {}
T.eq(out.seen, 1, "a finished link battle raises the event once")
T.eq(out.result, "win", "the outcome is reported")
T.eq(out.role, "host", "so is which side of the cable we were")
T.eq(out.peerName, "BLUE", "and who we played")
T.eq(out.myLead and out.myLead.species, "RATTATA",
"the lockstep copy of our party comes with it")
T.eq(out.myLead and out.myLead.hp, 3,
"carrying the damage the real party never took")
T.eq(out.theirLead and out.theirLead.species, "PIDGEY",
"and the copy of theirs")
T.check(session.exited, "the state unwinds afterwards, as it always did")
T.eq(session.battle, nil, "and the battle is released")
-- the guest side says so
local guest = finishedSession("lose", false)
guest:update(0)
T.eq(run.loader.exports.outcome_probe.seen, 2, "the guest reports too")
T.eq(run.loader.exports.outcome_probe.role, "guest", "as the guest")
T.eq(run.loader.exports.outcome_probe.result, "lose", "with its own outcome")
run.release()
T.finish("link_battle_ended")
+100
View File
@@ -0,0 +1,100 @@
-- A sandboxed mod can claim the A press on an object it owns, using only
-- public mod surfaces, and an unhooked build still talks to it as before.
--
-- The seam exists because a runtime object (WorldAPI:spawnNpc) carries no
-- TEXT_* id: the vanilla talk path has nothing to say for one, so a mod that
-- spawned it has to be able to answer instead.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local OverworldState = require("src.world.OverworldController")
local FIXTURE = {
["mods/talk_probe/manifest.json"] = [[{
"id": "talk_probe",
"name": "Talk Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/talk_probe/main.lua"] = [[
local mod = ...
mod.hooks:wrap("world.talk", function(next, ow, target)
if target and target.claimedByMod then
mod.exports.claimed = target.id
return -- deliberately not calling next(): the mod answers instead
end
return next(ow, target)
end)
]],
}
-- enough of an overworld to reach the A press: a player facing one cell, an
-- object standing on it, and a map with no counter to talk across
local function fixtureOverworld(npc)
local ow
ow = setmetatable({
npcs = { npc },
player = {
facing = "up",
facingCell = function() return 4, 5 end,
},
map = {
id = "FIX_ROUTE",
isCounterCell = function() return false end,
},
talked = {},
}, { __index = OverworldState })
-- the vanilla destination, recorded rather than run: talkTo walks into the
-- map's text tables, which a fixture map does not have
ow.talkTo = function(_, target) ow.talked[#ow.talked + 1] = target.id end
return ow
end
local function objectAt(id, claimed)
return { id = id, cellX = 4, cellY = 5, targetX = 4, targetY = 5,
moving = false, claimedByMod = claimed or nil, def = {} }
end
-- ------- no mod: the A press lands in the vanilla talk path
local vanilla = T.sdk.loadNone({})
local plain = fixtureOverworld(objectAt("SIGNPOST_MAN"))
plain:interact()
T.eq(#plain.talked, 1, "with no mod loaded the A press reaches talkTo")
T.eq(plain.talked[1], "SIGNPOST_MAN", "and it is handed the object it faced")
vanilla.release()
-- ------- a mod that owns the object answers for it
local run = T.sdk.loadMods({ "mods/talk_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0,
"the public talk probe loads clean (" .. tostring(run.errors[1]) .. ")")
local owned = fixtureOverworld(objectAt("GHOST_PLAYER", true))
owned:interact()
local out = run.loader.exports.talk_probe or {}
T.eq(out.claimed, "GHOST_PLAYER", "a public hook sees the object it owns")
T.eq(#owned.talked, 0,
"and a hook that does not call next keeps the vanilla text path out of it")
-- ...while everything the mod does not own falls straight through
local other = fixtureOverworld(objectAt("NURSE_JOY"))
other:interact()
T.eq(#other.talked, 1, "an object the mod does not claim still reaches talkTo")
T.eq(other.talked[1], "NURSE_JOY", "unchanged")
-- an object mid-step is not talkable in either build (walk_npc.asm), so the
-- hook must not fire for one either
local walking = fixtureOverworld(objectAt("WALKER", true))
walking.npcs[1].moving = true
walking:interact()
T.eq(run.loader.exports.talk_probe.claimed, "GHOST_PLAYER",
"an object mid-step raises no talk hook")
T.eq(#walking.talked, 0, "and reaches no talk path at all")
run.release()
T.finish("world_talk")
+18
View File
@@ -22,6 +22,7 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local Runtime = require("src.mods.Runtime")
local S = require("tests.harness").suite("parity double faint")
local check, eq = S.check, S.eq
@@ -107,4 +108,21 @@ do
check(not saidBlackout(b), "and does not black out")
end
-- enemyMonFainted is also a native authority path used by move effects. A
-- field-residual hook must not change what that path does when no hook is
-- installed, even if both active mons are already at zero HP.
do
Runtime.reset()
local b = battleWith({ 0 }, nil)
b.player = { mon = b.game.save.party[1] }
b.enemy = { mon = { hp = 0 } }
b.awards = 0
b.awardExp = function(self) self.awards = self.awards + 1 end
BattleState.enemyMonFainted(b)
eq(b.awards, 1,
"no-hook simultaneous faint still enters native enemy EXP authority")
eq(b.result, "win",
"no-hook simultaneous faint preserves native enemy-faint resolution")
end
S.finish()