mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Merge remote-tracking branch 'origin/dev' into feat/battle-menu-auxiliary
# Conflicts: # src/core/Checkpoint.lua
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/date_probe/manifest.json"] = [[{
|
||||
"id": "date_probe",
|
||||
"name": "Date Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/date_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.datetime = mod.datetime
|
||||
]],
|
||||
}
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/date_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0, "fixture mod loads cleanly")
|
||||
local datetime = run.loader.exports.date_probe.datetime
|
||||
T.eq(type(datetime), "table", "mod object exposes datetime formatting")
|
||||
T.eq(type(datetime.date), "function", "public formatter exposes date")
|
||||
T.eq(type(datetime.time), "function", "public formatter exposes time")
|
||||
T.eq(type(datetime.dateTime), "function", "public formatter exposes date-time")
|
||||
|
||||
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
|
||||
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
|
||||
T.eq(datetime:date(game, stamp), os.date("%d-%m-%Y", stamp),
|
||||
"mod date follows current global engine preference")
|
||||
T.eq(datetime:time(game, stamp), os.date("%H:%M", stamp),
|
||||
"mod time follows current global engine preference")
|
||||
T.eq(datetime:dateTime(game, stamp), os.date("%d-%m-%Y %H:%M", stamp),
|
||||
"mod date-time composes the same preference")
|
||||
|
||||
run.release()
|
||||
T.finish("date_time")
|
||||
@@ -0,0 +1,239 @@
|
||||
-- Gold's intro seams: the Oak speech under Gen 1's names, and the GS boot
|
||||
-- cinema under its own.
|
||||
--
|
||||
-- Two different claims are being made here, and they are deliberately not the
|
||||
-- same claim:
|
||||
--
|
||||
-- * src/ui/gen2/OakSpeech.lua reuses `intro.oak_speech.build` and the four
|
||||
-- `intro.oak_speech.*` events VERBATIM -- same names, same payload keys,
|
||||
-- same moments -- because Gold has a real Oak speech and a mod written for
|
||||
-- Red's must land on Gold's without a second listener.
|
||||
-- * the copyright card, the GAME FREAK splash, the attract movie and the
|
||||
-- Ho-Oh title have NO Gen 1 counterpart, so they take new `intro.boot.*`
|
||||
-- names rather than borrowing one that would then mean two things.
|
||||
--
|
||||
-- Both halves are also the parity case the no-mod gate wants: with nobody
|
||||
-- subscribed every one of these sites must be inert.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Events = require("src.mods.Events")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
|
||||
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
|
||||
local GoldSilverIntro = require("src.ui.gen2.GoldSilverIntro")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
local TitleState = require("src.ui.gen2.TitleState")
|
||||
|
||||
-- The shared names, spelled out so a rename has to come through this file.
|
||||
local SPEECH_EVENTS = {
|
||||
"intro.oak_speech.started",
|
||||
"intro.oak_speech.step",
|
||||
"intro.oak_speech.answered",
|
||||
"intro.oak_speech.finished",
|
||||
}
|
||||
-- ...and the Gen 2-only ones.
|
||||
local BOOT_EVENTS = {
|
||||
"intro.boot.copyright",
|
||||
"intro.boot.gamefreak",
|
||||
"intro.boot.movie",
|
||||
"intro.boot.movie_ended",
|
||||
"intro.boot.title",
|
||||
}
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function logged(fragment)
|
||||
for _, line in ipairs(Logger.history or {}) do
|
||||
if line:find(fragment, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function fakeGame()
|
||||
return { data = {}, save = { player = {} } }
|
||||
end
|
||||
|
||||
-- ------- 1. no-mod parity: every site cold
|
||||
|
||||
do
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
for _, name in ipairs(SPEECH_EVENTS) do
|
||||
T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold")
|
||||
end
|
||||
for _, name in ipairs(BOOT_EVENTS) do
|
||||
T.eq(Runtime.wants(name), false, "no subscriber leaves " .. name .. " cold")
|
||||
end
|
||||
T.eq(Runtime.wantsHook("intro.oak_speech.build"), false,
|
||||
"no wrapper leaves intro.oak_speech.build cold")
|
||||
|
||||
-- the four boot cards still run their moment with nobody listening
|
||||
CopyrightSplash.new(fakeGame(), {}):enter()
|
||||
GameFreakPresents.new(fakeGame(), {}):enter()
|
||||
local movie = GoldSilverIntro.new(fakeGame(), {})
|
||||
movie:enter()
|
||||
movie:finish()
|
||||
TitleState.new(fakeGame(), {}):enter()
|
||||
T.check(true, "the boot cinema runs unsubscribed")
|
||||
end
|
||||
|
||||
-- ------- 2. the Oak speech step list
|
||||
|
||||
do
|
||||
local speech = OakSpeech.new(fakeGame(), {})
|
||||
local steps = OakSpeech.defaultSteps(speech)
|
||||
T.eq(#steps, 9, "Gold's vanilla speech has nine beats")
|
||||
T.eq(steps[1].id, "init_clock",
|
||||
"the speech opens on the farcall InitClock beat")
|
||||
T.eq(steps[#steps].id, "shrink", "and ends on ShrinkPlayer")
|
||||
|
||||
-- the anchors a Gen 1 mod already knows how to aim at
|
||||
local byId = {}
|
||||
for index, step in ipairs(steps) do byId[step.id] = index end
|
||||
for _, id in ipairs({ "oak_welcome", "demo_mon", "world_spiel",
|
||||
"ask_player_name", "name_player", "legend",
|
||||
"shrink" }) do
|
||||
T.check(byId[id] ~= nil,
|
||||
"Gold keeps Gen 1's step anchor `" .. id .. "`")
|
||||
end
|
||||
T.check(byId.oak_welcome < byId.demo_mon
|
||||
and byId.demo_mon < byId.name_player
|
||||
and byId.name_player < byId.shrink,
|
||||
"and keeps them in Gen 1's order")
|
||||
end
|
||||
|
||||
-- ------- 3. intro.oak_speech.build, the hook Gen 1 already ships
|
||||
|
||||
do
|
||||
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
|
||||
local speech = OakSpeech.new(fakeGame(), {})
|
||||
|
||||
Runtime.hooks:wrap("intro.oak_speech.build", function(nextFn, steps, sp)
|
||||
steps = nextFn(steps, sp)
|
||||
table.insert(steps, 3, { id = "extra_q", kind = "choice",
|
||||
saveKey = "mood", choices = { "FINE", "TIRED" } })
|
||||
return steps
|
||||
end, 0, "fixture")
|
||||
local built = speech:buildSteps()
|
||||
T.eq(built[3].id, "extra_q",
|
||||
"intro.oak_speech.build can insert a beat into Gold's speech")
|
||||
T.eq(built[4].id, "demo_mon", "later vanilla beats shift down")
|
||||
Runtime.hooks:removeOwner("fixture")
|
||||
|
||||
Runtime.hooks:wrap("intro.oak_speech.build", function() return 42 end, 0, "bad")
|
||||
built = speech:buildSteps()
|
||||
T.eq(built[1].id, "init_clock",
|
||||
"a non-table intro.oak_speech.build result degrades to vanilla")
|
||||
T.check(logged("intro.oak_speech.build returned"),
|
||||
"and the degrade is logged, as it is under Gen 1")
|
||||
Runtime.hooks:removeOwner("bad")
|
||||
end
|
||||
|
||||
-- ------- 4. the four lifecycle events, with the Gen 1 payload keys
|
||||
|
||||
do
|
||||
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
|
||||
local speech = OakSpeech.new(fakeGame(), {})
|
||||
|
||||
-- Replace the beats with two that need no stack, so the lifecycle can be
|
||||
-- driven headlessly: `fn` is the escape hatch a build wrapper gets.
|
||||
Runtime.hooks:wrap("intro.oak_speech.build", function()
|
||||
return {
|
||||
{ id = "probe_one", kind = "fn",
|
||||
run = function(sp, done) sp:recordAnswer({ id = "probe_one",
|
||||
saveKey = "mood" }, 2, "TIRED", "TIRED") done() end },
|
||||
{ id = "probe_two", kind = "fn", run = function(_, done) done() end },
|
||||
}
|
||||
end, 0, "fixture")
|
||||
|
||||
local started, stepped, answered, finished = nil, {}, nil, nil
|
||||
Runtime.events:on("intro.oak_speech.started", function(ev)
|
||||
started = ev
|
||||
end, 0, "fixture")
|
||||
Runtime.events:on("intro.oak_speech.step", function(ev)
|
||||
stepped[#stepped + 1] = ev
|
||||
end, 0, "fixture")
|
||||
Runtime.events:on("intro.oak_speech.answered", function(ev)
|
||||
answered = ev
|
||||
end, 0, "fixture")
|
||||
Runtime.events:on("intro.oak_speech.finished", function(ev)
|
||||
finished = ev
|
||||
end, 0, "fixture")
|
||||
|
||||
speech:enter()
|
||||
|
||||
T.check(started ~= nil and started.speech == speech
|
||||
and type(started.steps) == "table",
|
||||
"intro.oak_speech.started carries { speech, steps }")
|
||||
T.eq(#stepped, 2, "intro.oak_speech.step fires once per beat")
|
||||
T.eq(stepped[1].index, 1, "and carries the 1-based index")
|
||||
T.eq(stepped[1].step.id, "probe_one", "and the step itself")
|
||||
T.check(answered ~= nil and answered.saveKey == "mood"
|
||||
and answered.value == "TIRED" and answered.index == 2
|
||||
and answered.label == "TIRED" and answered.speech == speech,
|
||||
"intro.oak_speech.answered carries Gen 1's six keys")
|
||||
T.eq(speech.answers.mood, "TIRED", "and the answer is stored on the speech")
|
||||
T.check(finished ~= nil and finished.answers == speech.answers,
|
||||
"intro.oak_speech.finished carries the answer table")
|
||||
|
||||
-- #308's guard, ported: a second finish is not a second speech.
|
||||
local firstFinish = finished
|
||||
finished = nil
|
||||
speech:finish()
|
||||
T.eq(finished, nil, "finished fires exactly once per speech")
|
||||
T.check(firstFinish ~= nil, "and it did fire the first time")
|
||||
|
||||
Runtime.events:removeOwner("fixture")
|
||||
Runtime.hooks:removeOwner("fixture")
|
||||
end
|
||||
|
||||
-- ------- 5. the GS boot cinema's own names
|
||||
|
||||
do
|
||||
Runtime.events, Runtime.hooks = Events.new(), Hooks.new()
|
||||
local seen = {}
|
||||
for _, name in ipairs(BOOT_EVENTS) do
|
||||
Runtime.events:on(name, function(ev) seen[name] = ev end, 0, "fixture")
|
||||
end
|
||||
|
||||
local game = fakeGame()
|
||||
CopyrightSplash.new(game, {}):enter()
|
||||
T.check(seen["intro.boot.copyright"] ~= nil,
|
||||
"the copyright card announces itself")
|
||||
T.check(seen["intro.boot.copyright"].screen ~= nil,
|
||||
"with the screen in the payload")
|
||||
|
||||
GameFreakPresents.new(game, {}):enter()
|
||||
T.check(seen["intro.boot.gamefreak"] ~= nil,
|
||||
"the GAME FREAK splash announces itself")
|
||||
|
||||
local movie = GoldSilverIntro.new(game, {})
|
||||
movie:enter()
|
||||
T.check(seen["intro.boot.movie"] ~= nil, "the attract movie announces itself")
|
||||
movie:skip()
|
||||
T.check(seen["intro.boot.movie_ended"] ~= nil,
|
||||
"and announces its end")
|
||||
T.eq(seen["intro.boot.movie_ended"].skipped, true,
|
||||
"intro.boot.movie_ended reports a button skip")
|
||||
|
||||
-- a movie that plays out reports the other answer
|
||||
seen["intro.boot.movie_ended"] = nil
|
||||
local watched = GoldSilverIntro.new(game, {})
|
||||
watched:enter()
|
||||
watched:finish()
|
||||
T.eq(seen["intro.boot.movie_ended"].skipped, false,
|
||||
"and reports a movie that was watched to the end")
|
||||
|
||||
TitleState.new(game, {}):enter()
|
||||
T.check(seen["intro.boot.title"] ~= nil, "the title screen announces itself")
|
||||
|
||||
Runtime.events:removeOwner("fixture")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
|
||||
T.finish("gen2_intro_seams")
|
||||
@@ -0,0 +1,549 @@
|
||||
-- T4: `modkit gen2check`, the tool that answers whether a mod runs on a Gen 2
|
||||
-- game and how far it gets (tools/modkit.py, MK4xx).
|
||||
--
|
||||
-- Every fixture below is generated FROM the engine: the module with no
|
||||
-- adapter, the member the coverage table calls absent and the screen id with
|
||||
-- a Gen 2 twin are all read out of src/mods/Gen2Compat.lua and
|
||||
-- src/ui/Screens.lua at run time. A case that spelled them out would pass
|
||||
-- against a stale adapter, which is the one thing this tool must never do.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local isWindows = package.config:sub(1, 1) == "\\"
|
||||
|
||||
-- luajit's pclose drops the exit status, so the shell reports it in-band
|
||||
-- (tests/modkit_tests.lua uses the same shape)
|
||||
local function run(command)
|
||||
if isWindows then
|
||||
command = 'cmd /v:on /c "' .. command .. ' 2>&1 & echo EXIT:!errorlevel!"'
|
||||
else
|
||||
command = command .. ' 2>&1; echo "EXIT:$?"'
|
||||
end
|
||||
local pipe = io.popen(command)
|
||||
local output = pipe:read("*a")
|
||||
pipe:close()
|
||||
return output, tonumber(output:match("EXIT:(%d+)%s*$")) or -1
|
||||
end
|
||||
|
||||
local python = isWindows and "python" or "python3"
|
||||
if not run(python .. " --version"):find("Python 3", 1, true) then
|
||||
T.check(true, "python3 is absent: gen2check not exercised")
|
||||
T.finish("gen2check")
|
||||
return
|
||||
end
|
||||
|
||||
-- ------- what to write the fixtures against, taken from the engine
|
||||
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
local names = {}
|
||||
for name in pairs(Gen2Compat.ADAPTERS) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
|
||||
-- written onto the running game and never onto the module table, which is
|
||||
-- what MK410 is about; read from the Gen 1 source, as the tool does
|
||||
local function instanceField(module, member)
|
||||
local handle = io.open(module:gsub("%.", "/") .. ".lua", "r")
|
||||
if not handle then return false end
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
if not body:find("self%." .. member .. "%s*=[^=]") then return false end
|
||||
if body:find("function%s+%w+[%.:]" .. member .. "%s*%(") then return false end
|
||||
for owner in body:gmatch("[\n%s]([%a_][%w_]*)%." .. member .. "%s*=[^=]") do
|
||||
if owner ~= "self" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- an adapted alias plus a member it backs: the shape a mod may hold and read
|
||||
local aliasName, backedMember
|
||||
-- a member the adapter deliberately does not carry, whatever module it is on
|
||||
local absentName, absentMember
|
||||
-- a facade member the Gen 1 module only writes onto the running game, which
|
||||
-- an entry chunk cannot have on a Gen 2 boot
|
||||
local liveName, liveMember
|
||||
for _, name in ipairs(names) do
|
||||
local row = Gen2Compat.coverage and Gen2Compat.coverage(name)
|
||||
local members = row and row.members or {}
|
||||
local sorted = {}
|
||||
for member in pairs(members) do sorted[#sorted + 1] = member end
|
||||
table.sort(sorted)
|
||||
for _, member in ipairs(sorted) do
|
||||
if not member:find("[%.%s]") then
|
||||
if members[member] == "absent" and not absentMember then
|
||||
absentName, absentMember = name, member
|
||||
end
|
||||
if members[member] == "backed" and row.kind == "alias"
|
||||
and not backedMember then
|
||||
aliasName, backedMember = name, member
|
||||
end
|
||||
if members[member] == "backed" and row.kind == "facade"
|
||||
and not liveMember and instanceField(name, member) then
|
||||
liveName, liveMember = name, member
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
T.check(aliasName ~= nil, "the adapter aliases at least one module")
|
||||
T.check(absentMember ~= nil, "the coverage table names at least one absent "
|
||||
.. "member")
|
||||
|
||||
-- A member that really closes over a local, and a local declared in the same
|
||||
-- Gen 2 file that it does NOT close over: the two answers the upvalue check
|
||||
-- has to tell apart, both read out of the engine.
|
||||
local landName, landMember, landUpvalue
|
||||
local strayUpvalue
|
||||
for _, name in ipairs(names) do
|
||||
local row = Gen2Compat.coverage and Gen2Compat.coverage(name)
|
||||
if row and row.kind == "alias" and row.target and not landMember then
|
||||
local ok, adapter = pcall(Gen2Compat.resolve, name)
|
||||
local sorted = {}
|
||||
if ok and type(adapter) == "table" then
|
||||
for member in pairs(adapter) do sorted[#sorted + 1] = member end
|
||||
end
|
||||
table.sort(sorted)
|
||||
for _, member in ipairs(sorted) do
|
||||
local value = adapter[member]
|
||||
if type(value) == "function" and not landMember then
|
||||
local held, index = {}, 1
|
||||
while true do
|
||||
local up = debug.getupvalue(value, index)
|
||||
if not up then break end
|
||||
held[up] = true
|
||||
if up:match("^%a[%w_]*$") and up ~= "_ENV" and not landUpvalue then
|
||||
landName, landMember, landUpvalue = name, member, up
|
||||
end
|
||||
index = index + 1
|
||||
end
|
||||
if landUpvalue then
|
||||
local handle = io.open(row.target:gsub("%.", "/") .. ".lua", "r")
|
||||
local body = handle and handle:read("*a") or ""
|
||||
if handle then handle:close() end
|
||||
for local_ in body:gmatch("\nlocal%s+([%a_][%w_]*)") do
|
||||
if not held[local_] and not strayUpvalue then
|
||||
strayUpvalue = local_
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- a Gen 1 screen id whose Gen 2 twin carries the prefix
|
||||
local twin
|
||||
for _, id in ipairs(Screens.GEN2_IDS) do
|
||||
local bare = id:gsub("^Gen2", "")
|
||||
local handle = io.open("src/ui/" .. bare .. ".lua", "r")
|
||||
if handle then handle:close() end
|
||||
if not twin and handle then twin = bare end
|
||||
end
|
||||
T.check(twin ~= nil, "at least one screen id exists in both generations")
|
||||
|
||||
-- ------- fixtures on disk, because the tool reads a mod directory
|
||||
|
||||
local tmp = os.tmpname()
|
||||
os.remove(tmp)
|
||||
local root = (isWindows and tmp:gsub("\\", "/") or tmp) .. "_gen2check"
|
||||
run((isWindows and "mkdir " or "mkdir -p ") .. ("%q"):format(root))
|
||||
|
||||
local function write(dir, files)
|
||||
run((isWindows and "mkdir " or "mkdir -p ")
|
||||
.. ("%q"):format(root .. "/" .. dir))
|
||||
for name, body in pairs(files) do
|
||||
local handle = assert(io.open(root .. "/" .. dir .. "/" .. name, "w"))
|
||||
handle:write(body)
|
||||
handle:close()
|
||||
end
|
||||
return root .. "/" .. dir
|
||||
end
|
||||
|
||||
local function manifest(id, extra)
|
||||
return ('{ "id": "%s", "name": "%s", "version": "1.0.0", "api": 2, '
|
||||
.. '"entry": "main.lua", "description": "gen2check fixture"%s }')
|
||||
:format(id, id, extra or "")
|
||||
end
|
||||
|
||||
local GEN2 = ', "gen2compat": true, "games": ["gen1", "gen2"]'
|
||||
|
||||
local clean = write("gen2_clean", {
|
||||
["manifest.json"] = manifest("gen2_clean", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
local held = M.%s
|
||||
mod.exports.held = held ~= nil
|
||||
]]):format(aliasName, backedMember),
|
||||
})
|
||||
|
||||
local unflagged = write("gen2_unflagged", {
|
||||
["manifest.json"] = manifest("gen2_unflagged"),
|
||||
["main.lua"] = "local mod = ...\n",
|
||||
})
|
||||
|
||||
local absent = write("gen2_absent", {
|
||||
["manifest.json"] = manifest("gen2_absent", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
M.%s(mod)
|
||||
]]):format(absentName, absentMember),
|
||||
})
|
||||
|
||||
local patterns = write("gen2_patterns", {
|
||||
["manifest.json"] = manifest("gen2_patterns", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
local function patchUpvalue(fn, name, value)
|
||||
local i = 1
|
||||
while true do
|
||||
local found = debug.getupvalue(fn, i)
|
||||
if not found then return false end
|
||||
if found == name then debug.setupvalue(fn, i, value) return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
patchUpvalue(M.%s, "gen2checkNoSuchUpvalue", 1)
|
||||
mod.events:on("screen.pushed", function(ev)
|
||||
if ev.screenId == "%s" then mod.exports.saw = true end
|
||||
end)
|
||||
]]):format(aliasName, backedMember, twin),
|
||||
})
|
||||
|
||||
local held = liveMember and write("gen2_held", {
|
||||
["manifest.json"] = manifest("gen2_held", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local G = require("%s")
|
||||
local captured = G.%s
|
||||
mod.events:on("game.ready", function()
|
||||
mod.exports.live = G.%s ~= nil
|
||||
end)
|
||||
mod.exports.captured = captured ~= nil
|
||||
]]):format(liveName, liveMember, liveMember),
|
||||
})
|
||||
|
||||
-- every shape the scan has to follow to reach one unbacked member: a wrapper
|
||||
-- the mod requires through, an inline require, a bracket index and a local hop
|
||||
local reaches = write("gen2_reaches", {
|
||||
["manifest.json"] = manifest("gen2_reaches", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local function tryRequire(path)
|
||||
local ok, m = pcall(require, path)
|
||||
if ok then return m end
|
||||
end
|
||||
local W = tryRequire("%s")
|
||||
W.%s(mod)
|
||||
require("%s").%s(mod)
|
||||
local B = require("%s")
|
||||
B["%s"](mod)
|
||||
local H = B
|
||||
H.%s(mod)
|
||||
]]):format(absentName, absentMember, absentName, absentMember,
|
||||
absentName, absentMember, absentMember),
|
||||
})
|
||||
|
||||
-- a module name this scan cannot tie to a require: it must say so, never
|
||||
-- pass the file in silence
|
||||
local opaque = write("gen2_opaque", {
|
||||
["manifest.json"] = manifest("gen2_opaque", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local CANDIDATES = { "%s" }
|
||||
mod.exports.names = CANDIDATES
|
||||
]]):format(absentName),
|
||||
})
|
||||
|
||||
-- an upvalue that is really an upvalue of the resolved function, and one that
|
||||
-- is only a file-local of the same module
|
||||
local lands = landUpvalue and write("gen2_lands", {
|
||||
["manifest.json"] = manifest("gen2_lands", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
debug.setupvalue(M.%s, "%s", nil)
|
||||
]]):format(landName, landMember, landUpvalue),
|
||||
})
|
||||
|
||||
local stray = strayUpvalue and write("gen2_stray", {
|
||||
["manifest.json"] = manifest("gen2_stray", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
debug.setupvalue(M.%s, "%s", nil)
|
||||
]]):format(landName, landMember, strayUpvalue),
|
||||
})
|
||||
|
||||
-- the screen id on a line carrying no screen-shaped word, which is the
|
||||
-- example the docs give
|
||||
local screenId = write("gen2_screen_id", {
|
||||
["manifest.json"] = manifest("gen2_screen_id", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
mod.exports.pick = function(id)
|
||||
if id == "%s" then return true end
|
||||
end
|
||||
]]):format(twin),
|
||||
})
|
||||
|
||||
-- one statement binding two requires: each name takes the value in its own
|
||||
-- slot, or neither does. Both names reach the same unbacked member, so a
|
||||
-- scan that paired them by position reports two and one that guessed reports
|
||||
-- one against a name it invented.
|
||||
local pairs_one = write("gen2_pairs_one", {
|
||||
["manifest.json"] = manifest("gen2_pairs_one", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local A, B = require("%s"), require("%s")
|
||||
A.%s(mod)
|
||||
B.%s(mod)
|
||||
]]):format(absentName, absentName, absentMember, absentMember),
|
||||
})
|
||||
|
||||
-- the same mod with the requires on their own lines: two mods that differ
|
||||
-- only in line breaks must not get opposite verdicts
|
||||
local pairs_lines = write("gen2_pairs_lines", {
|
||||
["manifest.json"] = manifest("gen2_pairs_lines", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local A = require("%s")
|
||||
local B = require("%s")
|
||||
A.%s(mod)
|
||||
B.%s(mod)
|
||||
]]):format(absentName, absentName, absentMember, absentMember),
|
||||
})
|
||||
|
||||
-- a mod helper that only ever puts its second parameter in the VALUE slot:
|
||||
-- it names no upvalue, so its call sites are unresolved, never MK407
|
||||
local valueSlot = write("gen2_value_slot", {
|
||||
["manifest.json"] = manifest("gen2_value_slot", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
local function applyPatches(fn, label)
|
||||
debug.setupvalue(fn, 1, label)
|
||||
end
|
||||
applyPatches(M.%s, "gen2checkNotAnUpvalue")
|
||||
]]):format(aliasName, backedMember),
|
||||
})
|
||||
|
||||
-- a module name assembled from two literals: the fragment binds to no engine
|
||||
-- module, and every reach off it has to say so
|
||||
local head, tail = absentName:match("^(.*%.)([^.]+)$")
|
||||
local truncated = head and write("gen2_truncated", {
|
||||
["manifest.json"] = manifest("gen2_truncated", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s" .. "%s")
|
||||
M.%s(mod)
|
||||
]]):format(head, tail, absentMember),
|
||||
})
|
||||
|
||||
-- the same concatenation starting with a literal, which a lookahead on the
|
||||
-- first token alone lets through
|
||||
local root_, rest = absentName:match("^(src)(%..*)$")
|
||||
local concat = root_ and write("gen2_concat", {
|
||||
["manifest.json"] = manifest("gen2_concat", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s" .. "%s")
|
||||
M.%s(mod)
|
||||
]]):format(root_, rest, absentMember),
|
||||
})
|
||||
|
||||
-- a bound module read as a value: parked on a table, delegated to through a
|
||||
-- metatable, handed to a call. The module escapes the scan at each one.
|
||||
local valueRead = write("gen2_value_read", {
|
||||
["manifest.json"] = manifest("gen2_value_read", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
local proxy = setmetatable({}, { __index = M })
|
||||
mod.exports.proxy = proxy
|
||||
mod.exports.parked = M
|
||||
]]):format(absentName),
|
||||
})
|
||||
|
||||
-- rawget/rawset skip the facade's metatable, so mod state stashed this way
|
||||
-- lands on the adapter table and not on the Gen 2 module behind it
|
||||
local rawReach = write("gen2_raw_reach", {
|
||||
["manifest.json"] = manifest("gen2_raw_reach", GEN2),
|
||||
["main.lua"] = ([[
|
||||
local mod = ...
|
||||
local M = require("%s")
|
||||
rawset(M, "gen2checkState", {})
|
||||
mod.exports.state = rawget(M, "gen2checkState")
|
||||
]]):format(aliasName),
|
||||
})
|
||||
|
||||
local function gen2check(dir, extra)
|
||||
return run(("%s tools/modkit.py gen2check %q %s")
|
||||
:format(python, dir, extra or ""))
|
||||
end
|
||||
|
||||
-- ------- a mod that only reads what the adapter backs
|
||||
|
||||
local out, code = gen2check(clean)
|
||||
T.eq(code, 0, "a mod inside the adapter's coverage exits 0: " .. out)
|
||||
T.check(out:find("will load", 1, true) ~= nil,
|
||||
"the clean fixture's verdict is 'will load': " .. out)
|
||||
|
||||
-- ------- the manifest gate, which decides before a line of the mod runs
|
||||
|
||||
out, code = gen2check(unflagged)
|
||||
T.eq(code, 1, "a mod claiming no Gen 2 game fails the check")
|
||||
T.check(out:find("MK400", 1, true) ~= nil, "MK400 names the manifest: " .. out)
|
||||
T.check(out:find("will not work", 1, true) ~= nil,
|
||||
"and the verdict says so: " .. out)
|
||||
|
||||
-- ------- a member the adapter refuses to invent
|
||||
|
||||
out, code = gen2check(absent)
|
||||
T.eq(code, 1, "calling an unbacked member fails the check")
|
||||
T.check(out:find("MK404", 1, true) ~= nil, "MK404 names the member: " .. out)
|
||||
T.check(out:find(absentMember, 1, true) ~= nil,
|
||||
"and quotes it by name: " .. out)
|
||||
|
||||
-- ------- the two shapes no adapter can fix
|
||||
|
||||
out, code = gen2check(patterns)
|
||||
T.eq(code, 1, "upvalue surgery with no target on Gold fails the check")
|
||||
T.check(out:find("MK407", 1, true) ~= nil,
|
||||
"MK407 names the upvalue that does not exist: " .. out)
|
||||
T.check(out:find("MK409", 1, true) ~= nil,
|
||||
"MK409 names the Gen 1 screen id: " .. out)
|
||||
T.check(out:find("Gen2" .. twin, 1, true) ~= nil,
|
||||
"and gives the Gen 2 spelling of it: " .. out)
|
||||
|
||||
-- ------- the entry chunk holding a member of a game that is not up yet
|
||||
|
||||
if held then
|
||||
out = gen2check(held)
|
||||
T.check(out:find("MK410", 1, true) ~= nil,
|
||||
"MK410 names the file-scope read: " .. out)
|
||||
T.check(select(2, out:gsub("MK410", "")) == 1,
|
||||
"and only the file-scope one, not the read inside the handler: " .. out)
|
||||
end
|
||||
|
||||
-- ------- the reaches a scan that only saw `X = require(...)` used to miss
|
||||
|
||||
out, code = gen2check(reaches)
|
||||
T.eq(code, 1, "an unbacked member reached through a wrapper fails: " .. out)
|
||||
T.eq(select(2, out:gsub("MK404", "")), 4,
|
||||
"the wrapper, the inline require, the bracket index and the local hop each "
|
||||
.. "come back: " .. out)
|
||||
|
||||
-- ------- and what it still cannot follow says so out loud
|
||||
|
||||
out = gen2check(opaque)
|
||||
T.check(out:find("unresolved", 1, true) ~= nil,
|
||||
"a module name the scan cannot tie to a require is reported unresolved, "
|
||||
.. "not passed over: " .. out)
|
||||
|
||||
-- ------- upvalue surgery, told apart by what the function really closes over
|
||||
|
||||
if lands then
|
||||
out, code = gen2check(lands)
|
||||
T.check(out:find("lands as it does on Gen 1", 1, true) ~= nil,
|
||||
"a real upvalue of the resolved function is reported as landing: " .. out)
|
||||
T.check(out:find("MK407", 1, true) == nil,
|
||||
"and raises nothing: " .. out)
|
||||
end
|
||||
|
||||
if stray then
|
||||
out, code = gen2check(stray)
|
||||
T.check(out:find("MK407", 1, true) ~= nil,
|
||||
"a file-local that is not an upvalue of the function is MK407: " .. out)
|
||||
T.check(out:find("lands as it does on Gen 1", 1, true) == nil,
|
||||
"and is never reported as landing: " .. out)
|
||||
end
|
||||
|
||||
-- ------- the screen id with no screen-shaped word beside it
|
||||
|
||||
out = gen2check(screenId)
|
||||
T.check(out:find("MK409", 1, true) ~= nil,
|
||||
"MK409 reads the id itself, not the line around it: " .. out)
|
||||
T.check(out:find("Gen2" .. twin, 1, true) ~= nil,
|
||||
"and gives the Gen 2 spelling: " .. out)
|
||||
|
||||
-- ------- names and values of one assignment, paired by position
|
||||
|
||||
out, code = gen2check(pairs_one)
|
||||
T.eq(code, 1, "two names bound to two requires in one statement fail: " .. out)
|
||||
T.eq(select(2, out:gsub("MK404", "")), 2,
|
||||
"each name carries its own require's module, so both reaches come back: "
|
||||
.. out)
|
||||
local lines, code2 = gen2check(pairs_lines)
|
||||
T.eq(code2, code, "the same mod with the requires on separate lines gets the "
|
||||
.. "same exit: " .. out .. lines)
|
||||
T.eq(select(2, lines:gsub("MK404", "")), 2,
|
||||
"and the same findings, since only the line breaks moved: " .. lines)
|
||||
|
||||
-- ------- a helper whose second parameter is a value, not an upvalue name
|
||||
|
||||
out, code = gen2check(valueSlot)
|
||||
T.check(out:find("MK407", 1, true) == nil,
|
||||
"a helper that names no upvalue is never read as upvalue surgery: " .. out)
|
||||
T.check(out:find("unresolved", 1, true) ~= nil,
|
||||
"and its call sites come back unresolved rather than silent: " .. out)
|
||||
|
||||
-- ------- a module name this scan resolved but cannot map to a module
|
||||
|
||||
if truncated then
|
||||
out, code = gen2check(truncated)
|
||||
T.check(out:find("neither an adapter nor a module", 1, true) ~= nil,
|
||||
"a require built from two literals cannot pass in silence: " .. out)
|
||||
end
|
||||
|
||||
if concat then
|
||||
out, code = gen2check(concat)
|
||||
T.check(out:find("building a require name at runtime", 1, true) ~= nil,
|
||||
"a concatenation starting with a literal is still dynamic: " .. out)
|
||||
end
|
||||
|
||||
-- ------- a bound module read as a value rather than indexed
|
||||
|
||||
out, code = gen2check(valueRead)
|
||||
T.check(out:find("read as a value", 1, true) ~= nil,
|
||||
"a module parked on a table or delegated to is reported unresolved: " .. out)
|
||||
|
||||
-- ------- rawget/rawset, which never see the module behind the facade
|
||||
|
||||
out, code = gen2check(rawReach)
|
||||
T.check(out:find("rawset", 1, true) ~= nil,
|
||||
"rawset onto an engine module earns its own note: " .. out)
|
||||
T.check(out:find("rawget", 1, true) ~= nil,
|
||||
"and so does rawget off one: " .. out)
|
||||
|
||||
-- ------- the machine-readable form one CI step reads
|
||||
|
||||
out, code = run(("%s tools/modkit.py --json gen2check %q %q")
|
||||
:format(python, clean, unflagged))
|
||||
T.eq(code, 1, "the batch fails when any mod in it fails")
|
||||
T.check(out:find('"verdict": "will load"', 1, true) ~= nil,
|
||||
"the JSON carries a verdict per mod: " .. out)
|
||||
T.check(out:find('"ok": false', 1, true) ~= nil,
|
||||
"and one ok for the batch: " .. out)
|
||||
|
||||
-- ------- every adapted name is served, so requiring one is never MK402
|
||||
|
||||
local requires = { "local mod = ..." }
|
||||
for _, name in ipairs(names) do
|
||||
requires[#requires + 1] = ("require(%q)"):format(name)
|
||||
end
|
||||
local served = write("gen2_served", {
|
||||
["manifest.json"] = manifest("gen2_served", GEN2),
|
||||
["main.lua"] = table.concat(requires, "\n") .. "\n",
|
||||
})
|
||||
out = gen2check(served)
|
||||
T.check(out:find("MK402", 1, true) == nil,
|
||||
"no adapted module is reported as unserved: " .. out)
|
||||
|
||||
run((isWindows and "rmdir /s /q " or "rm -rf ") .. ("%q"):format(root))
|
||||
|
||||
T.finish("gen2check")
|
||||
@@ -0,0 +1,301 @@
|
||||
-- A tool can persist a checkpoint before the first normal Pokémon save. After
|
||||
-- a restart the title runtime is deliberately a fresh save skeleton, so it
|
||||
-- needs a non-allocating binding to the already-selected playthrough -- not a
|
||||
-- call to the normal active-playthrough storage methods, which would mint an id.
|
||||
--
|
||||
-- This is a public SDK contract test. The fixture never reaches into storage
|
||||
-- paths or launcher slot internals.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod title playthrough context")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Version = require("src.core.Version")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local realFs = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function() return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then seen[child] = true; out[#out + 1] = child end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod)
|
||||
_G.MOD_TITLE_STORAGE = mod.storage
|
||||
_G.MOD_TITLE_CHECKPOINTS = mod.checkpoints
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
_G.MOD_TITLE_RESTORE_COUNT = (_G.MOD_TITLE_RESTORE_COUNT or 0) + 1
|
||||
_G.MOD_TITLE_RESTORE_KIND = ev.kind
|
||||
end)
|
||||
end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
love.filesystem = fs
|
||||
-- Production storage and checkpoint resume share the same engine persistence
|
||||
-- backend. Route the test's default SaveData lookup to this fixture backend so
|
||||
-- the restart path exercises that shared mapping rather than host test files.
|
||||
local originalLoadOptions = SaveData.loadOptions
|
||||
SaveData.loadOptions = function(injectedFs)
|
||||
return originalLoadOptions(injectedFs or fs)
|
||||
end
|
||||
local active = { save = SaveData.newGame({ version = "red" }) }
|
||||
local loader = Loader.new({ fs = fs })
|
||||
loader.game = active
|
||||
T.check(loader:load({}) == true, "title-context fixture mod loads")
|
||||
|
||||
local storage = _G.MOD_TITLE_STORAGE
|
||||
T.check(type(storage) == "table", "loader exposes the public storage facade")
|
||||
if type(storage) == "table" then
|
||||
local written, writeCode, writeMessage = storage:write(active, "history/index", {
|
||||
format = 1, newest = "q0001",
|
||||
})
|
||||
T.check(written == true,
|
||||
"a fresh playthrough can durably store tool history: "
|
||||
.. tostring(writeCode or writeMessage))
|
||||
local originalId = active.save.meta and active.save.meta.playthroughId
|
||||
T.check(type(originalId) == "string" and originalId ~= "",
|
||||
"first tool persistence allocates the opaque active playthrough identity")
|
||||
local nonTitleSelected, nonTitleCode = storage:selected(active)
|
||||
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
|
||||
"selected-playthrough storage cannot be used from active gameplay")
|
||||
|
||||
-- Simulate a fresh process/title session. The normal save was never written:
|
||||
-- only the engine-owned slot/playthrough mapping and this mod's durable data
|
||||
-- exist. The title skeleton must remain unmodified by browsing.
|
||||
SaveData.resetSlotState()
|
||||
local title = {
|
||||
save = SaveData.newGame({ version = "red" }),
|
||||
stack = {
|
||||
states = { { screenId = "TitleState" } },
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
},
|
||||
}
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"title starts from an unbound fresh skeleton before normal SAVE")
|
||||
T.check(type(storage.selected) == "function",
|
||||
"public storage exposes a read-only selected-playthrough binding at title")
|
||||
|
||||
if type(storage.selected) == "function" then
|
||||
local selected, selectedCode, selectedMessage = storage:selected(title)
|
||||
T.check(type(selected) == "table",
|
||||
"title resolves the selected existing playthrough: "
|
||||
.. tostring(selectedCode or selectedMessage))
|
||||
if type(selected) == "table" then
|
||||
T.same(selected:context(), {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = originalId,
|
||||
}, "selected binding reports the durable playthrough without exposing a slot path")
|
||||
T.same(selected:read("history/index"), { format = 1, newest = "q0001" },
|
||||
"title reads only this mod's selected-playthrough durable history")
|
||||
T.check(selected:write("history/title-operation", { allowed = true }) == true,
|
||||
"title binding supports safe same-namespace durable operations")
|
||||
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||
"title durable operation remains scoped to the selected playthrough")
|
||||
end
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
end
|
||||
|
||||
local function makeRuntime(save, title)
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local overworld = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map, target.player.x, target.player.y = self.map.id,
|
||||
self.player.cellX, self.player.cellY
|
||||
target.player.facing, target.player.surfing = self.player.facing,
|
||||
self.player.surfing and true or false
|
||||
end
|
||||
function overworld:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false }
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = save, stack = stack, overworld = overworld,
|
||||
data = {
|
||||
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
|
||||
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = title and { screenId = "TitleState" } or overworld
|
||||
if title then
|
||||
-- The failure-injection path needs the same title recovery contract as a
|
||||
-- real Game without constructing renderer-owned title content.
|
||||
function game:makeTitleState() return { screenId = "TitleState" } end
|
||||
end
|
||||
return game
|
||||
end
|
||||
|
||||
local runtime = makeRuntime(active.save, false)
|
||||
local checkpoints = _G.MOD_TITLE_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
|
||||
local checkpoint = checkpoints and checkpoints:capture(runtime)
|
||||
T.check(type(checkpoint) == "table",
|
||||
"a fresh playthrough can capture a stable overworld checkpoint")
|
||||
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
|
||||
"public checkpoints expose an idempotent first-save anchor")
|
||||
local normalWrites = 0
|
||||
local writeSave = runtime.writeSave
|
||||
function runtime:writeSave()
|
||||
normalWrites = normalWrites + 1
|
||||
return writeSave(self)
|
||||
end
|
||||
local anchored, anchorCode, anchorMessage =
|
||||
checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||
T.check(anchored == true,
|
||||
"first persisted checkpoint can anchor normal progress: "
|
||||
.. tostring(anchorCode or anchorMessage))
|
||||
T.eq(normalWrites, 1,
|
||||
"first checkpoint creates exactly one normal Pokemon save")
|
||||
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
|
||||
T.check(anchoredAgain == true and againCode == "already_exists",
|
||||
"later checkpoints leave the established normal save independent")
|
||||
T.eq(normalWrites, 1,
|
||||
"idempotent anchor never rewrites the established normal save")
|
||||
local normalBytes = files["save.lua"]
|
||||
T.check(type(normalBytes) == "string" and normalBytes ~= "",
|
||||
"first checkpoint anchor is durably represented before restart")
|
||||
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
|
||||
SaveData.resetSlotState()
|
||||
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
titleRuntime.save.options = { volume = 9, bindings = {} }
|
||||
T.check(type(checkpoints and checkpoints.resume) == "function",
|
||||
"public checkpoints expose validated title-session resume")
|
||||
if type(checkpoints and checkpoints.resume) == "function" and checkpoint then
|
||||
local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint)
|
||||
T.check(resumed == true,
|
||||
"title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage))
|
||||
T.eq(titleRuntime.save.meta.playthroughId, originalId,
|
||||
"title bootstrap retains the checkpoint's original playthrough identity")
|
||||
T.eq(titleRuntime.save.options.volume, 9,
|
||||
"title bootstrap preserves current options rather than rewinding them")
|
||||
T.eq(SaveData.selectedNormalSaveInfo({
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"title bootstrap never rewrites the first normal save")
|
||||
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
"a successfully verified title resume emits checkpoint.restored exactly once")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_KIND, "overworld",
|
||||
"title resume lifecycle reports the reconstructed checkpoint kind")
|
||||
|
||||
-- Force a failure after restoreCheckpointSave has already installed the
|
||||
-- checkpoint's canonical save and overworld. Title has no live checkpoint
|
||||
-- rollback, so it must rebuild a clean title session.
|
||||
SaveData.resetSlotState()
|
||||
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
failingTitle.save.options = { volume = 7, bindings = {} }
|
||||
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
|
||||
function failingTitle:restoreCheckpointSave(loaded)
|
||||
restoreCheckpointSave(self, loaded)
|
||||
error("forced title reconstruction failure")
|
||||
end
|
||||
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
|
||||
T.check(failed == false and failureCode == "resume_failed",
|
||||
"failed title reconstruction reports a recoverable bootstrap failure")
|
||||
T.eq(failingTitle.stack:top().screenId, "TitleState",
|
||||
"failed title reconstruction returns to a usable title session")
|
||||
T.check(failingTitle.save.meta.playthroughId == nil,
|
||||
"failed title reconstruction restores the unbound title skeleton")
|
||||
T.eq(failingTitle.save.options.volume, 7,
|
||||
"failed title reconstruction retains current title options")
|
||||
T.eq(SaveData.selectedNormalSaveInfo({
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"failed title reconstruction never rewrites the normal Pokémon save")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
"failed title reconstruction emits no additional restored lifecycle event")
|
||||
end
|
||||
|
||||
-- A title policy may compare its own durable checkpoint chronology with the
|
||||
-- ordinary CONTINUE target, but it must never receive that save's contents,
|
||||
-- slot path, or a way to open another playthrough. This fixture writes the
|
||||
-- canonical normal save directly to model an already-completed vanilla SAVE.
|
||||
active.save.meta.savedAt = 4321
|
||||
T.check(SaveData.save(active.save) == true,
|
||||
"fixture updates the selected normal save chronology")
|
||||
SaveData.resetSlotState()
|
||||
local titleWithNormalSave = {
|
||||
save = SaveData.newGame({ version = "red" }),
|
||||
stack = { states = { { screenId = "TitleState" } } },
|
||||
}
|
||||
local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave)
|
||||
T.check(type(selectedWithNormal) == "table",
|
||||
"legacy-to-slot migration keeps the selected playthrough identity: "
|
||||
.. tostring(normalCode or normalMessage))
|
||||
if type(selectedWithNormal) == "table" then
|
||||
T.eq(selectedWithNormal:context().normalSavedAt, 4321,
|
||||
"title selected context exposes only matching normal-save chronology")
|
||||
end
|
||||
T.check(titleWithNormalSave.save.meta.playthroughId == nil,
|
||||
"normal-save chronology lookup does not bind the fresh title skeleton")
|
||||
|
||||
local explicitNewGame = SaveData.newGame({ version = "red" })
|
||||
local freshContext = storage:context({ save = explicitNewGame })
|
||||
T.check(freshContext and freshContext.playthroughId ~= originalId,
|
||||
"an explicit New Game receives a distinct identity and cannot inherit old history")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_TITLE_STORAGE = nil
|
||||
_G.MOD_TITLE_CHECKPOINTS = nil
|
||||
_G.MOD_TITLE_RESTORE_COUNT = nil
|
||||
_G.MOD_TITLE_RESTORE_KIND = nil
|
||||
SaveData.resetSlotState()
|
||||
SaveData.loadOptions = originalLoadOptions
|
||||
love.filesystem = realFs
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Public party-ordering contract over an idle overworld fixture. No ROM data
|
||||
-- is needed, so companion UIs exercise this seam in the normal mod-SDK tier.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod world party reorder")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
local first = { species = "BULBASAUR" }
|
||||
local second = { species = "CHARMANDER" }
|
||||
local runner = { running = false }
|
||||
function runner:isRunning() return self.running end
|
||||
|
||||
local ow = {
|
||||
isOverworld = true,
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { moving = false, inputLocked = false },
|
||||
runner = runner,
|
||||
scriptMoves = {},
|
||||
}
|
||||
local stack = setmetatable({ states = { ow } }, { __index = StateStack })
|
||||
local game = {
|
||||
data = {},
|
||||
save = { party = { first, second } },
|
||||
stack = stack,
|
||||
overworld = ow,
|
||||
}
|
||||
local api = WorldAPI.new(game, "fixture")
|
||||
|
||||
T.check(api:canReorderParty(), "idle free roam allows party reordering")
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
local realPlay, played = Sound.play
|
||||
Sound.play = function(_, name) played = name end
|
||||
T.check(api:reorderParty(1, 2) == true, "valid slots reorder")
|
||||
Sound.play = realPlay
|
||||
T.check(game.save.party[1] == second and game.save.party[2] == first,
|
||||
"the live party is swapped")
|
||||
T.eq(played, "Swap", "the normal party swap sound is used")
|
||||
|
||||
local value, err = api:reorderParty(1.5, 2)
|
||||
T.check(value == nil and err == "invalid party slot",
|
||||
"non-integer slots are rejected")
|
||||
value, err = api:reorderParty("1", 2)
|
||||
T.check(value == nil and err == "invalid party slot",
|
||||
"string slots are rejected")
|
||||
|
||||
stack:push({ screenId = "SomeMenu" })
|
||||
T.check(not api:canReorderParty(), "a screen above the world blocks reordering")
|
||||
value, err = api:reorderParty(1, 2)
|
||||
T.check(value == nil and err == "world is busy",
|
||||
"reordering refuses while another screen owns input")
|
||||
stack:pop()
|
||||
|
||||
ow.player.moving = true
|
||||
T.check(not api:canReorderParty(), "movement blocks reordering")
|
||||
ow.player.moving = false
|
||||
runner.running = true
|
||||
T.check(not api:canReorderParty(), "scripts block reordering")
|
||||
runner.running = false
|
||||
ow.transitioning = true
|
||||
T.check(not api:canReorderParty(), "map transitions block reordering")
|
||||
|
||||
T.finish()
|
||||
@@ -66,12 +66,33 @@ function Catalog.events()
|
||||
return events
|
||||
end
|
||||
|
||||
-- A hook whose Runtime.call lives in a shared raiser rather than at the site
|
||||
-- that decides the value. Gold's trainer art is not in field.playerPics, so
|
||||
-- its screens resolve their own path and hand it to Sprites.playerPic
|
||||
-- (src/pokemon/Sprites.lua); those callers are player.sprite sites and the
|
||||
-- generation gate has to see them as such.
|
||||
local INDIRECT_HOOKS = {
|
||||
["player.sprite"] = "playerPic%(",
|
||||
}
|
||||
|
||||
function Catalog.hooks()
|
||||
if not hooks then
|
||||
hookSites = scan({ "src" }, {
|
||||
'Runtime%.call%("([%w%._]+)"',
|
||||
'hooks:call%("([%w%._]+)"',
|
||||
})
|
||||
for name, pattern in pairs(INDIRECT_HOOKS) do
|
||||
local list = hookSites[name] or {}
|
||||
hookSites[name] = list
|
||||
for _, path in ipairs(luaFilesUnder("src")) do
|
||||
local handle = io.open(path, "r")
|
||||
if handle then
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
if body:match(pattern) then list[#list + 1] = path end
|
||||
end
|
||||
end
|
||||
end
|
||||
hooks = sortedKeys(hookSites)
|
||||
end
|
||||
return hooks
|
||||
|
||||
@@ -111,10 +111,13 @@ function Sdk.restoreRuntime()
|
||||
saved = nil
|
||||
end
|
||||
|
||||
-- opts.data the merge target (defaults to a fresh fixture dataset)
|
||||
-- opts.fs override the filesystem entirely (e.g. Sdk.memfs)
|
||||
-- opts.root repo root the real paths are relative to
|
||||
-- opts.dev force the dev tripwire on
|
||||
-- opts.data the merge target (defaults to a fresh fixture dataset)
|
||||
-- opts.fs override the filesystem entirely (e.g. Sdk.memfs)
|
||||
-- opts.root repo root the real paths are relative to
|
||||
-- opts.dev force the dev tripwire on
|
||||
-- opts.generation 1 (default) or 2; loads as if Gold were the running game,
|
||||
-- which is the seam the gen2compat gate and the registry
|
||||
-- target routing are tested through without booting Gold
|
||||
function Sdk.loadMods(paths, opts)
|
||||
opts = opts or {}
|
||||
local data = opts.data or require("tests.modkit.fixtures").fresh()
|
||||
@@ -127,7 +130,8 @@ function Sdk.loadMods(paths, opts)
|
||||
end
|
||||
|
||||
Sdk.captureRuntime()
|
||||
local loader = Loader.new({ fs = fs, dev = opts.dev })
|
||||
local loader = Loader.new({ fs = fs, dev = opts.dev,
|
||||
generation = opts.generation })
|
||||
local ok, err = pcall(loader.load, loader, data)
|
||||
if not ok then
|
||||
Sdk.restoreRuntime()
|
||||
|
||||
Reference in New Issue
Block a user