mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 07:21:22 +02:00
Merge branch 'dev' into spidercar2
This commit is contained in:
@@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper,
|
||||
local mainFile = assert(io.open("main.lua", "rb"))
|
||||
local main = mainFile:read("*a")
|
||||
mainFile:close()
|
||||
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||
"returning from a game disables mod-owned secondary output")
|
||||
check(main:find("SessionLifecycle.endGameSession", 1, true),
|
||||
"returning from a game goes through SessionLifecycle.endGameSession")
|
||||
local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb"))
|
||||
local lifecycle = lifecycleFile:read("*a")
|
||||
lifecycleFile:close()
|
||||
check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||
"endGameSession disables mod-owned secondary output")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,428 @@
|
||||
-- Contract gate for the two narrow Gen 1 seams added for a composable
|
||||
-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the
|
||||
-- real hook bus and WorldAPI against hand-written maps and save snapshots.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
local oldHooks = Runtime.hooks
|
||||
local oldTextBox = package.loaded["src.render.TextBox"]
|
||||
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
}
|
||||
|
||||
local story = dofile("data/scripts/story3.lua")
|
||||
local VERSIONS = { "red", "blue", "yellow" }
|
||||
|
||||
local function newDock(version, wrappers)
|
||||
local blocks, pushes, warps = {}, {}, {}
|
||||
local rebuilds = 0
|
||||
local save = {
|
||||
version = version,
|
||||
flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}
|
||||
local map = {
|
||||
id = "VERMILION_DOCK",
|
||||
setBlock = function(_, bx, by, block)
|
||||
blocks[bx .. "," .. by] = block
|
||||
end,
|
||||
renderer = { rebuild = function() rebuilds = rebuilds + 1 end },
|
||||
}
|
||||
local game = {
|
||||
save = save,
|
||||
data = { text = {
|
||||
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||
} },
|
||||
stack = { push = function(_, value) pushes[#pushes + 1] = value end },
|
||||
}
|
||||
local ow = {
|
||||
map = map,
|
||||
player = { cellX = 14, cellY = 2, facing = "down" },
|
||||
startWarpTo = function(_, ...)
|
||||
warps[#warps + 1] = { ... }
|
||||
end,
|
||||
}
|
||||
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
for _, entry in ipairs(wrappers or {}) do
|
||||
hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0,
|
||||
entry.owner)
|
||||
end
|
||||
|
||||
story.VERMILION_DOCK.onEnter(game, ow)
|
||||
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||
return {
|
||||
blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds,
|
||||
save = save, map = map, game = game, ow = ow, hooks = hooks,
|
||||
}
|
||||
end
|
||||
|
||||
local function allowedWrapper(owner, inspect)
|
||||
return {
|
||||
owner = owner,
|
||||
fn = function(nextFn, game, ctx)
|
||||
if inspect then inspect(game, ctx) end
|
||||
local downstream = nextFn(game, ctx)
|
||||
local ownClaim = true
|
||||
return downstream == true or ownClaim == true
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- With no subscriber, the post-departure branch remains byte-for-byte
|
||||
-- vanilla in effect: erase the ship, show its line, and eject the player.
|
||||
do
|
||||
local run = newDock("red")
|
||||
T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock")
|
||||
T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull")
|
||||
T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull")
|
||||
T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line")
|
||||
T.eq(run.pushes[1].text, "The ship set sail.",
|
||||
"vanilla re-entry preserves its dialogue")
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"vanilla re-entry ejects to Vermilion City")
|
||||
end
|
||||
|
||||
-- The new permission seam is post-departure only. An ordinary HM01 exit
|
||||
-- must not invoke it or change the existing departure-script path.
|
||||
do
|
||||
local hookCalls, queued = 0, nil
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx)
|
||||
hookCalls = hookCalls + 1
|
||||
return nextFn(game, ctx)
|
||||
end, 0, "must_not_run")
|
||||
|
||||
local savedMusic = package.loaded["src.core.Music"]
|
||||
package.loaded["src.core.Music"] = {
|
||||
stop = function() end,
|
||||
play = function() end,
|
||||
}
|
||||
local game = {
|
||||
save = { version = "red", flags = { EVENT_GOT_HM01 = true } },
|
||||
data = {},
|
||||
}
|
||||
local ow = {
|
||||
player = { cellX = 14, cellY = 2 },
|
||||
startDustAnim = function(_, _, _, done) if done then done() end end,
|
||||
queueScript = function(_, rows) queued = rows end,
|
||||
}
|
||||
story.VERMILION_DOCK.onEnter(game, ow)
|
||||
package.loaded["src.core.Music"] = savedMusic
|
||||
|
||||
T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam")
|
||||
T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true,
|
||||
"normal HM01 departure still sets the vanilla event flag")
|
||||
T.check(type(queued) == "table" and #queued > 0,
|
||||
"normal HM01 departure still queues its sail-away script")
|
||||
end
|
||||
|
||||
-- One cooperative claimant may permit occupancy. The callback receives a
|
||||
-- detached data snapshot, not the live overworld, map, player, or save.
|
||||
do
|
||||
local seenGame, seenCtx
|
||||
local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx)
|
||||
seenGame = game
|
||||
seenCtx = {
|
||||
mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion,
|
||||
x = ctx.x, y = ctx.y,
|
||||
}
|
||||
ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1
|
||||
end) })
|
||||
T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly")
|
||||
T.same(seenCtx, {
|
||||
mapId = "VERMILION_DOCK", reason = "ss_anne_departed",
|
||||
gameVersion = "red", x = 14, y = 2,
|
||||
}, "occupancy context is the exact detached Red dock snapshot")
|
||||
T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map")
|
||||
T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X")
|
||||
T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y")
|
||||
T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save")
|
||||
T.same(run.save, {
|
||||
version = "red", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}, "permission check preserves the complete save snapshot")
|
||||
T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog")
|
||||
T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy")
|
||||
T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship")
|
||||
T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout")
|
||||
end
|
||||
|
||||
-- Standard hook composition also means a non-cooperative false/no-next
|
||||
-- wrapper can suppress downstream claims. This remains safe because false
|
||||
-- is denial; it cannot accidentally grant occupancy.
|
||||
do
|
||||
local downstreamCalls = 0
|
||||
local run = newDock("red", {
|
||||
{
|
||||
owner = "denier", priority = 10,
|
||||
fn = function() return false end,
|
||||
},
|
||||
{
|
||||
owner = "unreached_claimant", priority = 0,
|
||||
fn = function()
|
||||
downstreamCalls = downstreamCalls + 1
|
||||
return true
|
||||
end,
|
||||
},
|
||||
})
|
||||
T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics")
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"non-cooperative false remains fail-closed")
|
||||
end
|
||||
|
||||
-- Cooperative peers all run through next(). A lower-priority peer claim is
|
||||
-- preserved by a higher-priority peer that has no claim of its own.
|
||||
do
|
||||
local calls, contextIdentity = {}, nil
|
||||
local run = newDock("blue", {
|
||||
{
|
||||
owner = "peer_high", priority = 10,
|
||||
fn = function(nextFn, game, ctx)
|
||||
calls[#calls + 1] = "high-before"
|
||||
contextIdentity = ctx
|
||||
local allowed = nextFn(game, ctx)
|
||||
calls[#calls + 1] = "high-after"
|
||||
return allowed == true or false
|
||||
end,
|
||||
},
|
||||
{
|
||||
owner = "peer_low", priority = 0,
|
||||
fn = function(nextFn, game, ctx)
|
||||
calls[#calls + 1] = "low"
|
||||
T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance")
|
||||
local allowed = nextFn(game, ctx)
|
||||
local ownClaim = true
|
||||
return allowed == true or ownClaim == true
|
||||
end,
|
||||
},
|
||||
})
|
||||
T.same(calls, { "high-before", "low", "high-after" },
|
||||
"multiple peer handlers preserve hook-chain order")
|
||||
T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain")
|
||||
end
|
||||
|
||||
-- Absent, throwing, or malformed callbacks fail closed. Only boolean true
|
||||
-- can turn off ejection; truthy strings/tables/numbers do not grant access.
|
||||
do
|
||||
local malformed = {
|
||||
{ label = "nil", value = nil },
|
||||
{ label = "false", value = false },
|
||||
{ label = "string", value = "yes" },
|
||||
{ label = "number", value = 1 },
|
||||
{ label = "table", value = {} },
|
||||
}
|
||||
for _, case in ipairs(malformed) do
|
||||
local run = newDock("red", { {
|
||||
owner = "malformed_" .. case.label,
|
||||
fn = function() return case.value end,
|
||||
} })
|
||||
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||
"malformed " .. case.label .. " permission fails closed")
|
||||
end
|
||||
|
||||
local beforeNext = newDock("red", { {
|
||||
owner = "throws_before_next",
|
||||
fn = function() error("fixture throws before next", 0) end,
|
||||
} })
|
||||
T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY",
|
||||
"throwing callback before next fails closed")
|
||||
|
||||
local afterNext = newDock("red", { {
|
||||
owner = "throws_after_next",
|
||||
fn = function(nextFn, game, ctx)
|
||||
nextFn(game, ctx)
|
||||
error("fixture throws after next", 0)
|
||||
end,
|
||||
} })
|
||||
T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY",
|
||||
"throwing callback after next keeps the downstream denial")
|
||||
end
|
||||
|
||||
-- The context carries one version only. A Red-only claimant must not leak
|
||||
-- access into Blue or Yellow, and each call gets its own snapshot.
|
||||
do
|
||||
local contexts = {}
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
local run = newDock(version, { {
|
||||
owner = "red_only",
|
||||
fn = function(nextFn, game, ctx)
|
||||
contexts[#contexts + 1] = ctx
|
||||
local downstream = nextFn(game, ctx)
|
||||
return downstream == true or ctx.gameVersion == "red"
|
||||
end,
|
||||
} })
|
||||
T.eq(#run.warps == 0, version == "red",
|
||||
version .. " occupancy is decided only by its own version context")
|
||||
end
|
||||
T.eq(contexts[1].gameVersion, "red", "Red context stays Red")
|
||||
T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue")
|
||||
T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow")
|
||||
T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3],
|
||||
"Red, Blue, and Yellow calls do not share context tables")
|
||||
end
|
||||
|
||||
-- Removing the owner is the engine's disable/uninstall path. It restores
|
||||
-- vanilla denial immediately and leaves no save flag or serialized state.
|
||||
do
|
||||
local run = newDock("yellow", { allowedWrapper("removable") })
|
||||
T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy")
|
||||
run.hooks:removeOwner("removable")
|
||||
local pushes, warps = {}, {}
|
||||
run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end
|
||||
run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end
|
||||
story.VERMILION_DOCK.onEnter(run.game, run.ow)
|
||||
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||
T.eq(warps[1] and warps[1][1], "VERMILION_CITY",
|
||||
"disabling the owner restores vanilla ejection")
|
||||
T.eq(run.hooks.chains["map.occupancy_allowed"], nil,
|
||||
"uninstall removes the occupancy chain itself")
|
||||
T.eq(run.save.marker, "save-must-not-change",
|
||||
"disable/uninstall writes no persistent permission state")
|
||||
T.same(run.save, {
|
||||
version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
marker = "save-must-not-change",
|
||||
}, "disable/uninstall preserves the complete Yellow save snapshot")
|
||||
end
|
||||
|
||||
-- activeBlockAt is read-only and fail-closed. A successful call exposes
|
||||
-- only one scalar from the active runtime layout, never its backing table.
|
||||
local function blockApi(version, blockAt)
|
||||
local backing = { 4, 5, 6, 8, 9, 10 }
|
||||
local map = {
|
||||
id = "VERMILION_DOCK",
|
||||
def = { width = 3, height = 2, blocks = backing },
|
||||
blockAt = blockAt or function(_, bx, by)
|
||||
return backing[by * 3 + bx + 1]
|
||||
end,
|
||||
}
|
||||
local world = { isOverworld = true, map = map }
|
||||
local game = {
|
||||
save = { version = version },
|
||||
stack = { states = { world } },
|
||||
overworld = world,
|
||||
}
|
||||
return WorldAPI.new(game, "contract_fixture"), backing, game, map
|
||||
end
|
||||
|
||||
do
|
||||
for _, version in ipairs(VERSIONS) do
|
||||
local api, backing = blockApi(version)
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0)
|
||||
T.eq(block, 5, version .. " reads its active dock block")
|
||||
T.eq(err, nil, version .. " valid active block has no error")
|
||||
block = 99
|
||||
T.eq(backing[2], 5, version .. " scalar result cannot mutate the map")
|
||||
end
|
||||
|
||||
local api = blockApi("red")
|
||||
local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0)
|
||||
T.eq(wrong, nil, "wrong map has no block result")
|
||||
T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly")
|
||||
|
||||
local invalid = {
|
||||
{ "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 },
|
||||
{ "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 },
|
||||
{ "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 },
|
||||
}
|
||||
for _, case in ipairs(invalid) do
|
||||
local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3])
|
||||
T.eq(value, nil, case[1] .. " returns no block")
|
||||
T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type")
|
||||
end
|
||||
for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do
|
||||
local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2])
|
||||
T.eq(value, nil, "out-of-bounds coordinate returns no block")
|
||||
T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local malformed = {
|
||||
{ label = "nil", get = function() return nil end },
|
||||
{ label = "negative", get = function() return -1 end },
|
||||
{ label = "fractional", get = function() return 1.5 end },
|
||||
{ label = "infinite", get = function() return math.huge end },
|
||||
{ label = "NaN", get = function() return 0 / 0 end },
|
||||
{ label = "string", get = function() return "4" end },
|
||||
{ label = "table", get = function() return {} end },
|
||||
{ label = "throwing", get = function() error("bad map", 0) end },
|
||||
}
|
||||
for _, case in ipairs(malformed) do
|
||||
local api = blockApi("red", case.get)
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed active block " .. case.label .. " returns no value")
|
||||
T.eq(err, "block unavailable",
|
||||
"malformed active block " .. case.label .. " fails closed")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Invalid map shapes are untrusted runtime data too. None may escape as a
|
||||
-- block or raise through the mod facade.
|
||||
do
|
||||
local badDefs = {
|
||||
{ label = "missing def", value = nil },
|
||||
{ label = "missing width", value = { height = 2, blocks = {} } },
|
||||
{ label = "string width", value = { width = "3", height = 2, blocks = {} } },
|
||||
{ label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } },
|
||||
{ label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } },
|
||||
{ label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } },
|
||||
{ label = "missing blocks", value = { width = 3, height = 2 } },
|
||||
{ label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } },
|
||||
}
|
||||
for _, case in ipairs(badDefs) do
|
||||
local api, _, _, map = blockApi("red")
|
||||
map.def = case.value
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, case.label .. " returns no block")
|
||||
T.eq(err, "block unavailable", case.label .. " fails closed")
|
||||
end
|
||||
|
||||
local api, _, _, map = blockApi("red")
|
||||
map.blockAt = nil
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "missing blockAt returns no block")
|
||||
T.eq(err, "block unavailable", "missing blockAt fails closed")
|
||||
map.blockAt = "not a function"
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed blockAt returns no block")
|
||||
T.eq(err, "block unavailable", "malformed blockAt fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red")
|
||||
map.def.blocks = {}
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "sparse stored block slot returns no block")
|
||||
T.eq(err, "block unavailable", "sparse stored block slot fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red")
|
||||
map.def.blocks[1] = "4"
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "malformed stored block slot returns no block")
|
||||
T.eq(err, "block unavailable", "malformed stored block slot fails closed")
|
||||
|
||||
api, _, _, map = blockApi("red", function() return 5 end)
|
||||
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "stored/accessor mismatch returns no block")
|
||||
T.eq(err, "block unavailable", "stored/accessor mismatch fails closed")
|
||||
end
|
||||
|
||||
do
|
||||
local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture")
|
||||
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||
T.eq(block, nil, "no-overworld lookup returns no block")
|
||||
T.eq(err, "no overworld", "no-overworld lookup reports its state")
|
||||
end
|
||||
|
||||
Runtime.hooks = oldHooks
|
||||
package.loaded["src.render.TextBox"] = oldTextBox
|
||||
T.finish("mew dock seam contract")
|
||||
@@ -0,0 +1,66 @@
|
||||
-- No-mod and API-v1 parity for the additive mod.developer surface.
|
||||
--
|
||||
-- The production break this catches is a developer-mode loader path that
|
||||
-- mutates vanilla data, creates mod state, or changes existing API-v1
|
||||
-- behavior merely because the new public signal exists.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local function pristine()
|
||||
return {
|
||||
pokemon = { KEEP = { hp = 7 } },
|
||||
moves = {},
|
||||
}
|
||||
end
|
||||
|
||||
for _, dev in ipairs({ false, true }) do
|
||||
local data = pristine()
|
||||
local files = {}
|
||||
local run = T.sdk.loadNone({
|
||||
data = data,
|
||||
fs = T.sdk.memfs(files),
|
||||
dev = dev,
|
||||
})
|
||||
T.eq(#run.errors, 0,
|
||||
"no-mod load stays clean with developer=" .. tostring(dev))
|
||||
T.eq(next(run.loader.mods), nil,
|
||||
"no-mod load discovers nothing with developer=" .. tostring(dev))
|
||||
T.eq(data.pokemon.KEEP.hp, 7,
|
||||
"no-mod load preserves vanilla data with developer=" .. tostring(dev))
|
||||
T.eq(next(files), nil,
|
||||
"no-mod load creates no files with developer=" .. tostring(dev))
|
||||
run.release()
|
||||
end
|
||||
|
||||
local V1 = {
|
||||
["mods/v1_probe/manifest.json"] = [[{
|
||||
"id": "v1_probe",
|
||||
"name": "V1 Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 1
|
||||
}]],
|
||||
["mods/v1_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.identity = mod.id .. "@" .. mod.version
|
||||
mod.exports.payload = mod:read("payload.txt")
|
||||
mod.options:define({ { key = "enabled", type = "toggle", default = true } })
|
||||
mod.exports.defaultOption = mod.options:get("enabled")
|
||||
]],
|
||||
["mods/v1_probe/payload.txt"] = "unchanged-v1",
|
||||
}
|
||||
|
||||
local legacy = T.sdk.loadMods({ "mods/v1_probe" }, {
|
||||
fs = T.sdk.memfs(V1),
|
||||
dev = false,
|
||||
})
|
||||
T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged")
|
||||
local out = legacy.loader.exports.v1_probe
|
||||
T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged")
|
||||
T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged")
|
||||
T.eq(out.defaultOption, true, "API-v1 options stay unchanged")
|
||||
legacy.release()
|
||||
|
||||
T.finish("mod developer mode parity")
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Public load-time developer-mode signal for sandboxed mods.
|
||||
--
|
||||
-- The production break this catches is a loader that computes dev mode but
|
||||
-- does not expose the same fixed answer to the public mod object before the
|
||||
-- entry chunk runs. It also protects the data-only contract: the public
|
||||
-- value is a boolean snapshot, not a live loader or environment handle.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FILES = {
|
||||
["mods/dev_probe/manifest.json"] = [[{
|
||||
"id": "dev_probe",
|
||||
"name": "Developer Mode Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["all"]
|
||||
}]],
|
||||
["mods/dev_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.seenAtLoad = mod.developer
|
||||
mod.exports.kind = type(mod.developer)
|
||||
if mod.developer then
|
||||
mod.commands:register("dev_probe:diagnostics", function() return true end)
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
local function load(dev, generation)
|
||||
return T.sdk.loadMods({ "mods/dev_probe" }, {
|
||||
fs = T.sdk.memfs(FILES),
|
||||
dev = dev,
|
||||
generation = generation,
|
||||
})
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true)
|
||||
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"sandboxed entry code sees developer mode at load time")
|
||||
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"entry code can register diagnostics only in developer mode")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(false)
|
||||
T.eq(#run.errors, 0, "production-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"sandboxed entry code sees production mode at load time")
|
||||
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"production load does not register developer diagnostics")
|
||||
run.release()
|
||||
end
|
||||
|
||||
for _, provided in ipairs({ "yes", 1 }) do
|
||||
local run = load(provided)
|
||||
T.eq(#run.errors, 0,
|
||||
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
|
||||
T.eq(out.kind, "boolean",
|
||||
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
|
||||
T.eq(run.loader.dev, false,
|
||||
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true, 2)
|
||||
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"Gen 2 entry code sees the same developer-mode answer")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"Gen 2 entry code can gate diagnostics on the same signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local saved = _G.POKEPORT_DEV_MODE
|
||||
_G.POKEPORT_DEV_MODE = true
|
||||
local ok, run = pcall(load, nil)
|
||||
_G.POKEPORT_DEV_MODE = saved
|
||||
if not ok then error(run, 0) end
|
||||
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
|
||||
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
|
||||
"the --developer boot decision reaches the public signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish("mod developer mode public API")
|
||||
@@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
|
||||
local mainSrc = source("main.lua")
|
||||
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
|
||||
check(quitHook ~= nil, "love.quit is still a single top-level function")
|
||||
quitHook = quitHook or ""
|
||||
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
|
||||
"love.quit shuts the chip worker down")
|
||||
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
|
||||
"love.quit shuts the update worker down")
|
||||
check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||
"love.quit shuts workers down via SessionLifecycle.endProcess")
|
||||
|
||||
local lifecycleSrc = source("src/core/SessionLifecycle.lua")
|
||||
check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil,
|
||||
"SessionLifecycle exposes registerProcessShutdown")
|
||||
check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||
"SessionLifecycle.endProcess fans out registered hooks")
|
||||
check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil,
|
||||
"ChipAudio registers its shutdown hook at load")
|
||||
check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil,
|
||||
"Check registers its shutdown hook at load")
|
||||
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
||||
"Fetch registers its shutdown hook at load")
|
||||
|
||||
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
||||
-- so the quit event exits the process outright. It has to sit after the
|
||||
|
||||
@@ -0,0 +1,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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user