mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge remote-tracking branch 'origin/dev' into feat/worldapi-start-wild-battle
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ROM-free regression tests for source-build version detection and routing."""
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase, main, mock
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import build_rom_data # noqa: E402
|
||||
|
||||
|
||||
class BuildRomDataCliTest(TestCase):
|
||||
def run_builder(self, sha1, *extra):
|
||||
manifest = {"romSha1": sha1, "symbols": {}}
|
||||
rom = SimpleNamespace(sha1=sha1)
|
||||
with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \
|
||||
mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \
|
||||
mock.patch.object(build_rom_data, "build") as build, \
|
||||
mock.patch.object(build_rom_data.os, "makedirs"), \
|
||||
redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
||||
result = build_rom_data.main([
|
||||
"--rom", "fixture.gb", "--only", "constants", *extra])
|
||||
return result, build
|
||||
|
||||
def test_blue_rom_selects_blue_manifest_and_cache_paths(self):
|
||||
result, build = self.run_builder(
|
||||
build_rom_data.CANONICAL_BLUE_SHA1)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "blue/data/generated")
|
||||
self.assertEqual(args[4], "blue/assets/generated")
|
||||
|
||||
def test_red_rom_keeps_historical_root_paths(self):
|
||||
result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "data/generated")
|
||||
self.assertEqual(args[4], "assets/generated")
|
||||
|
||||
def test_explicit_output_paths_are_preserved(self):
|
||||
result, build = self.run_builder(
|
||||
build_rom_data.CANONICAL_YELLOW_SHA1,
|
||||
"--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets")
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "/tmp/custom-data")
|
||||
self.assertEqual(args[4], "/tmp/custom-assets")
|
||||
|
||||
def test_unknown_rom_is_rejected_before_build(self):
|
||||
unknown = "0" * 40
|
||||
result, build = self.run_builder(unknown)
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
build.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Battle checkpoints are exposed only at a settled, reconstructable player
|
||||
-- decision boundary. This suite is ROM-free and exercises the public engine
|
||||
-- checkpoint capability against the fixture battle implementation.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint boundary")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = save.player.map },
|
||||
player = {
|
||||
cellX = save.player.x, cellY = save.player.y,
|
||||
facing = save.player.facing, surfing = false,
|
||||
},
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase = "menu"
|
||||
battle.queue = {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter" }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, overworld, battle
|
||||
end
|
||||
|
||||
local game, overworld, battle = makeGame()
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled standard wild battle is a checkpoint boundary")
|
||||
|
||||
local function refused(mutator, code, label)
|
||||
local game2, ow2, battle2 = makeGame()
|
||||
mutator(game2, ow2, battle2)
|
||||
local capability = Checkpoint.inspect(game2)
|
||||
T.check(capability.canCapture == false and capability.reason == code,
|
||||
label .. ": " .. tostring(capability.reason))
|
||||
end
|
||||
|
||||
refused(function(_, _, b) b.phase = "messages" end,
|
||||
"battle_phase_busy", "message phase is rejected")
|
||||
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
|
||||
"battle_phase_busy", "nonempty action queue is rejected")
|
||||
refused(function(_, _, b) b.waitFrames = 1 end,
|
||||
"battle_phase_busy", "partial wait is rejected")
|
||||
refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
|
||||
"battle_phase_busy", "unfinished HP display synchronization is rejected")
|
||||
refused(function(_, _, b) b.player.mustRecharge = true end,
|
||||
"battle_phase_busy", "automatic locked action is rejected")
|
||||
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
|
||||
"script_busy", "suspended script beneath battle is rejected")
|
||||
refused(function(_, _, b) b.checkpointOrigin = nil end,
|
||||
"battle_origin_unsupported", "unknown completion closure is rejected")
|
||||
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
|
||||
"battle_variant_unsupported", "Safari battle is rejected")
|
||||
refused(function(_, _, b) b.ghost = true end,
|
||||
"battle_variant_unsupported", "ghost battle is rejected")
|
||||
refused(function(_, _, b) b.demo = true end,
|
||||
"battle_variant_unsupported", "old-man demo is rejected")
|
||||
refused(function(_, _, b) b.kind = "link" end,
|
||||
"link_battle_unsupported", "link battle is rejected")
|
||||
|
||||
-- Ordinary overworld behavior remains unchanged by the battle branch.
|
||||
game.stack.states[2] = nil
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "overworld",
|
||||
}, "settled overworld remains supported")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Data-only capture of a settled battle checkpoint, including deterministic
|
||||
-- gameplay RNG and normalized object-reference sets.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint capture")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
|
||||
local randomState = "fixture-rng-A"
|
||||
love.math.getRandomState = function() return randomState end
|
||||
love.math.setRandomState = function(state) randomState = state end
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 20),
|
||||
Pokemon.new(Data, "FIXMON_C", 15),
|
||||
}
|
||||
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
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1",
|
||||
}
|
||||
else
|
||||
battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local game, battle = makeGame("wild")
|
||||
battle.turnCount = 7
|
||||
battle.runAttempts = 2
|
||||
battle.payDay = 45
|
||||
battle.player.stages.attack = 2
|
||||
battle.player.confusedTurns = 3
|
||||
battle.player.curTypes = { "FIRE", "FLYING" }
|
||||
local originalMoveId = battle.player.curMoves[1].id
|
||||
battle.player.curMoves[1].id = "FIX_CUT"
|
||||
battle.player.curMoves[1].mimic = true
|
||||
battle.mimicRestores = {
|
||||
{ battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId },
|
||||
}
|
||||
battle.enemy.mon.hp = battle.enemy.mon.hp - 4
|
||||
battle.enemy.shownHP = battle.enemy.mon.hp
|
||||
battle.enemy.stages.defense = -1
|
||||
battle.enemy.aiLayer2 = 1
|
||||
battle.enemy.thrashMove = battle.enemy.curMoves[1]
|
||||
battle.enemy.thrashTurns = 2
|
||||
battle.participants = { [game.save.party[1]] = true,
|
||||
[game.save.party[2]] = true }
|
||||
battle.leveledUp = { [game.save.party[2]] = true }
|
||||
battle.sideToxic = { enemy = 3 }
|
||||
battle.sides[1].screens.reflect = { turns = 2 }
|
||||
battle.field.weather = { id = "fixture-rain", turns = 4 }
|
||||
|
||||
local snapshot, code, message = Checkpoint.capture(game)
|
||||
T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message))
|
||||
T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle")
|
||||
if snapshot and snapshot.kind == "battle" then
|
||||
T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured")
|
||||
T.same(snapshot.runtime.overworld,
|
||||
{ map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false },
|
||||
"return overworld point is captured")
|
||||
T.same(snapshot.runtime.battle.origin,
|
||||
{ kind = "wild_encounter", map = "FIX_TOWN" },
|
||||
"semantic continuation origin is data-only")
|
||||
T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured")
|
||||
T.eq(snapshot.runtime.battle.rulesetId, "gen1_faithful",
|
||||
"battle mechanics ruleset identity is captured")
|
||||
T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured")
|
||||
T.eq(snapshot.runtime.battle.player.stages.attack, 2,
|
||||
"player stat stages are captured")
|
||||
T.eq(snapshot.runtime.battle.player.confusedTurns, 3,
|
||||
"player volatile status is captured")
|
||||
T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" },
|
||||
"transformed battle types are captured")
|
||||
T.same(snapshot.runtime.battle.mimicRestores,
|
||||
{ { side = "player", slot = 1, id = originalMoveId } },
|
||||
"Mimic restore pointers normalize to side and move slot")
|
||||
T.eq(snapshot.runtime.battle.enemy.stages.defense, -1,
|
||||
"enemy stat stages are captured")
|
||||
T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1,
|
||||
"enemy AI selection layer is captured")
|
||||
T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1,
|
||||
"move-instance references normalize to move slots")
|
||||
T.eq(snapshot.runtime.battle.enemy.thrashMove, nil,
|
||||
"live move-instance references are not serialized as detached copies")
|
||||
T.same(snapshot.runtime.battle.participants, { 1, 2 },
|
||||
"Pokemon-keyed participants normalize to party indices")
|
||||
T.same(snapshot.runtime.battle.leveledUp, { 2 },
|
||||
"Pokemon-keyed level-up set normalizes to party indices")
|
||||
T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 },
|
||||
"data-only side extensions are captured")
|
||||
T.same(snapshot.runtime.battle.field.weather,
|
||||
{ id = "fixture-rain", turns = 4 },
|
||||
"data-only field extensions are captured")
|
||||
local encoded = SaveSerializer.encode(snapshot)
|
||||
T.check(type(encoded) == "string" and #encoded > 0,
|
||||
"battle checkpoint passes the canonical data-only serializer")
|
||||
|
||||
snapshot.save.money = 1
|
||||
snapshot.runtime.battle.player.stages.attack = -6
|
||||
T.check(game.save.money ~= 1, "checkpoint progress is detached")
|
||||
T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached")
|
||||
end
|
||||
|
||||
local trainerGame, trainer = makeGame("trainer")
|
||||
trainer.enemyIndex = 1
|
||||
trainer.aiUses = 2
|
||||
local trainerSnapshot = Checkpoint.capture(trainerGame)
|
||||
T.eq(trainerSnapshot and trainerSnapshot.kind, "battle",
|
||||
"ordinary trainer battle captures")
|
||||
if trainerSnapshot and trainerSnapshot.kind == "battle" then
|
||||
T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER",
|
||||
"trainer class is captured")
|
||||
T.eq(trainerSnapshot.runtime.battle.partyIndex, 1,
|
||||
"trainer roster index is captured")
|
||||
T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty,
|
||||
"complete enemy roster is captured")
|
||||
end
|
||||
|
||||
local extensionGame, extensionBattle = makeGame("wild")
|
||||
extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end }
|
||||
local unsafe, unsafeCode = Checkpoint.capture(extensionGame)
|
||||
T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe",
|
||||
"callback-bearing battle extensions are rejected, not stripped")
|
||||
|
||||
love.math.getRandomState = nil
|
||||
local rngGame = makeGame("wild")
|
||||
local noRng, rngCode = Checkpoint.capture(rngGame)
|
||||
T.check(noRng == nil and rngCode == "rng_state_unavailable",
|
||||
"battle capture fails closed without serializable gameplay RNG")
|
||||
|
||||
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
|
||||
T.finish()
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Engine-owned battle continuations replace unserializable onFinish closures
|
||||
-- after a persistent checkpoint reconstructs the overworld and battle.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint continuation")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local function fakeOverworld()
|
||||
local npc = { id = "FIX_TOWN_obj_1", frozen = true }
|
||||
local ow = setmetatable({
|
||||
map = { id = "FIX_TOWN" },
|
||||
npcPool = { [npc.id] = npc },
|
||||
engaging = true,
|
||||
}, { __index = OverworldState })
|
||||
ow.afterBattle = function(self, result, battle)
|
||||
self.after = { result = result, battle = battle }
|
||||
end
|
||||
ow.checkVictoryRewards = function(self, class, party)
|
||||
self.reward = { class = class, party = party }
|
||||
end
|
||||
return ow, npc
|
||||
end
|
||||
|
||||
local wildOw = fakeOverworld()
|
||||
local wildGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local wild = { game = wildGame, kind = "wild" }
|
||||
T.check(wildOw:restoreBattleContinuation(wild,
|
||||
{ kind = "wild_encounter", map = "FIX_TOWN" }) == true,
|
||||
"ordinary wild continuation binds")
|
||||
wild.onFinish("run")
|
||||
T.same(wildOw.after, { result = "run", battle = wild },
|
||||
"wild continuation returns through canonical afterBattle")
|
||||
|
||||
local trainerOw, trainerNpc = fakeOverworld()
|
||||
local trainerGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local trainer = {
|
||||
game = trainerGame, kind = "trainer",
|
||||
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
}
|
||||
local trainerOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN",
|
||||
npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1,
|
||||
event = "EVENT_BEAT_FIX_TRAINER",
|
||||
}
|
||||
T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true,
|
||||
"ordinary trainer continuation binds")
|
||||
trainer.onFinish("win")
|
||||
T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true,
|
||||
"trainer win stamps the stable object id")
|
||||
T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true,
|
||||
"trainer win stamps the header event")
|
||||
T.same(trainerOw.reward,
|
||||
{ class = "OPP_FIX_YOUNGSTER", party = 1 },
|
||||
"trainer win runs canonical victory rewards")
|
||||
T.same(trainerOw.after, { result = "win", battle = trainer },
|
||||
"trainer win returns through canonical afterBattle")
|
||||
T.check(trainerOw.engaging == false and trainerNpc.frozen == false,
|
||||
"reconstructed trainer completion leaves overworld input unfrozen")
|
||||
|
||||
local lossOw, lossNpc = fakeOverworld()
|
||||
local lossGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local lossBattle = {
|
||||
game = lossGame, kind = "trainer",
|
||||
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
}
|
||||
T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true,
|
||||
"trainer loss continuation binds")
|
||||
lossBattle.onFinish("lose")
|
||||
T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil,
|
||||
"trainer loss does not stamp the trainer defeated")
|
||||
T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil,
|
||||
"trainer loss does not stamp the header event")
|
||||
T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards")
|
||||
|
||||
local mismatchOw = fakeOverworld()
|
||||
T.check(mismatchOw:restoreBattleContinuation(trainer, {
|
||||
kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id,
|
||||
trainerClass = trainer.oppClass, partyIndex = 1,
|
||||
}) == false, "continuation from another map is rejected")
|
||||
T.check(mismatchOw:restoreBattleContinuation(trainer, {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id,
|
||||
trainerClass = "OPP_OTHER", partyIndex = 1,
|
||||
}) == false, "mismatched trainer identity is rejected")
|
||||
|
||||
local ow = {}
|
||||
local stack = { states = { ow } }
|
||||
function stack:top() return self.states[#self.states] end
|
||||
local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods })
|
||||
local entered, resumed = false, false
|
||||
local battle = {
|
||||
enter = function() entered = true end,
|
||||
resumeCheckpoint = function() resumed = true end,
|
||||
}
|
||||
game:restoreCheckpointBattle(battle)
|
||||
T.check(game.stack:top() == battle, "reconstructed battle is installed on stack")
|
||||
T.check(resumed == true, "checkpoint-specific battle resume path runs")
|
||||
T.check(entered == false, "ordinary battle intro is not replayed")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,315 @@
|
||||
-- A battle checkpoint reconstructs a new controller from data, rather than
|
||||
-- retaining the original table/closure, and restores gameplay RNG exactly.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint restore")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Damage = require("src.battle.Damage")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local Music = require("src.core.Music")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
local oldRandom = love.math.random
|
||||
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
|
||||
local oldPlayBattle = Music.playBattle
|
||||
Music.playBattle = function() end
|
||||
local rng = 12345
|
||||
love.math.getRandomState = function() return tostring(rng) end
|
||||
love.math.setRandomState = function(state) rng = assert(tonumber(state)) end
|
||||
love.math.random = function(a, b)
|
||||
rng = (rng * 1103515245 + 12345) % 2147483648
|
||||
local unit = rng / 2147483648
|
||||
if a == nil then return unit end
|
||||
if b == nil then return math.floor(unit * a) + 1 end
|
||||
return a + math.floor(unit * (b - a + 1))
|
||||
end
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 20),
|
||||
Pokemon.new(Data, "FIXMON_C", 15),
|
||||
}
|
||||
-- Strip new-game defaults that are intentionally absent from the tiny
|
||||
-- fixture registry, then place the sanitized save on a fixture map.
|
||||
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(battle, origin)
|
||||
battle.onFinish = function(result)
|
||||
self.lastRestoredFinish = { result = result, origin = origin.kind }
|
||||
end
|
||||
return true
|
||||
end
|
||||
local game = setmetatable(
|
||||
{ data = Data, save = save, stack = stack, overworld = overworld },
|
||||
{ __index = GameMethods })
|
||||
function game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self.overworld.map = { id = loaded.player.map }
|
||||
self.overworld.player = {
|
||||
cellX = loaded.player.x, cellY = loaded.player.y,
|
||||
facing = loaded.player.facing,
|
||||
surfing = loaded.player.surfing and true or false,
|
||||
}
|
||||
self.overworld.runner = { isRunning = function() return false end }
|
||||
self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {}
|
||||
self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {}
|
||||
self.stack.states = { self.overworld }
|
||||
end
|
||||
stack.states[1] = overworld
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1",
|
||||
}
|
||||
else
|
||||
battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local function settleOverworld(game)
|
||||
game.stack.states = { game.overworld }
|
||||
game.save.money = 999999
|
||||
game.save.party[1].hp = 1
|
||||
game.overworld.player.cellX = 8
|
||||
game.overworld.player.facing = "up"
|
||||
end
|
||||
|
||||
local game, originalBattle = makeGame("wild")
|
||||
originalBattle.turnCount = 4
|
||||
originalBattle.runAttempts = 1
|
||||
originalBattle.player.stages.speed = 3
|
||||
local originalMoveId = originalBattle.player.curMoves[1].id
|
||||
originalBattle.player.curMoves[1].id = "FIX_CUT"
|
||||
originalBattle.player.curMoves[1].mimic = true
|
||||
originalBattle.mimicRestores = {
|
||||
{ battler = originalBattle.player, entry = originalBattle.player.curMoves[1],
|
||||
id = originalMoveId },
|
||||
}
|
||||
originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5
|
||||
originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp
|
||||
originalBattle.enemy.disabledSlot = 1
|
||||
originalBattle.enemy.disabledTurns = 2
|
||||
originalBattle.enemy.aiLayer2 = 1
|
||||
originalBattle.enemy.thrashTurns = 2
|
||||
originalBattle.enemy.mon.moves = {
|
||||
{ id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 },
|
||||
}
|
||||
originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves
|
||||
originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1]
|
||||
originalBattle.participants = { [game.save.party[1]] = true }
|
||||
local checkpoint = assert(Checkpoint.capture(game))
|
||||
local encounterDef = { grass = {
|
||||
rate = 256, buckets = { 128, 256 },
|
||||
slots = { { species = "FIXMON_A", level = 4 },
|
||||
{ species = "FIXMON_B", level = 7 } },
|
||||
} }
|
||||
Encounter.load(Data)
|
||||
local function randomOutcomes(battle)
|
||||
local damage, detail = Damage.compute(battle.ruleset, battle.player,
|
||||
battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng })
|
||||
local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT,
|
||||
battle.player, battle.enemy, battle.rng)
|
||||
local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil)
|
||||
local escaped = battle:runRollVanilla(1, 100)
|
||||
local encounter = Encounter.roll(encounterDef, love.math.random)
|
||||
local nextValue = love.math.random(1, 1000000)
|
||||
return {
|
||||
damage = damage, critical = detail.crit, hit = hit,
|
||||
ai = ai.id, escaped = escaped, encounter = encounter,
|
||||
nextValue = nextValue,
|
||||
}
|
||||
end
|
||||
local expectedOutcomes = randomOutcomes(originalBattle)
|
||||
|
||||
settleOverworld(game)
|
||||
game.save.options.ruleset = "modern_clean"
|
||||
rng = 777
|
||||
local restored, code, message = Checkpoint.restore(game, checkpoint)
|
||||
T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code))
|
||||
local rebuilt = restored and game.stack:top()
|
||||
if restored then
|
||||
T.check(rebuilt ~= originalBattle, "restore creates a new battle controller")
|
||||
T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState")
|
||||
T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu")
|
||||
T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue")
|
||||
T.eq(rebuilt.turnCount, 4, "turn count roundtrips")
|
||||
T.eq(rebuilt.runAttempts, 1, "escape state roundtrips")
|
||||
T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip")
|
||||
T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1]
|
||||
and rebuilt.mimicRestores[1].battler == rebuilt.player
|
||||
and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1],
|
||||
"Mimic restore references are rebuilt against the new battler")
|
||||
rebuilt:restoreMimicked(rebuilt.player)
|
||||
T.eq(rebuilt.player.curMoves[1].id, originalMoveId,
|
||||
"restored Mimic move returns to its canonical id when battle copy leaves")
|
||||
T.eq(rebuilt.player.curMoves[1].mimic, nil,
|
||||
"restored Mimic marker clears with the battle copy")
|
||||
-- Put the checkpointed battle state back before differential recapture.
|
||||
rebuilt.player.curMoves[1].id = "FIX_CUT"
|
||||
rebuilt.player.curMoves[1].mimic = true
|
||||
rebuilt.mimicRestores = {
|
||||
{ battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId },
|
||||
}
|
||||
T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips")
|
||||
T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips")
|
||||
T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips")
|
||||
T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1],
|
||||
"multi-turn move references rebuild against the new move list")
|
||||
T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp,
|
||||
"enemy Pokemon model roundtrips")
|
||||
T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips")
|
||||
T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp,
|
||||
"party model roundtrips")
|
||||
T.eq(game.save.options.ruleset, "modern_clean",
|
||||
"current global ruleset option remains untouched")
|
||||
T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"),
|
||||
"restored battle keeps the mechanics ruleset it was captured with")
|
||||
T.same(Checkpoint.capture(game), checkpoint,
|
||||
"capture A, discard, restore A, capture A2 yields normalized A == A2")
|
||||
local replayed = randomOutcomes(rebuilt)
|
||||
T.same(replayed, expectedOutcomes,
|
||||
"damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly")
|
||||
rebuilt.onFinish("run")
|
||||
T.same(game.overworld.lastRestoredFinish,
|
||||
{ result = "run", origin = "wild_encounter" },
|
||||
"restored battle receives a reconstructed semantic continuation")
|
||||
end
|
||||
|
||||
local partyGame, switchedOriginal = makeGame("wild")
|
||||
partyGame.save.party[1].hp = 0
|
||||
local activeMon = partyGame.save.party[2]
|
||||
activeMon.status = "PAR"
|
||||
activeMon.moves[1].pp = activeMon.moves[1].pp - 4
|
||||
switchedOriginal.player = BattleState.makeBattler(
|
||||
Data, activeMon, true, partyGame.save)
|
||||
switchedOriginal.sides[1].battlers = { switchedOriginal.player }
|
||||
switchedOriginal.participants = {
|
||||
[partyGame.save.party[1]] = true,
|
||||
[partyGame.save.party[2]] = true,
|
||||
}
|
||||
local partyCheckpoint = assert(Checkpoint.capture(partyGame))
|
||||
settleOverworld(partyGame)
|
||||
partyGame.save.party[2].status = nil
|
||||
partyGame.save.party[2].moves[1].pp = 1
|
||||
restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint)
|
||||
T.check(restored == true,
|
||||
"switched/status/PP checkpoint restores: " .. tostring(message or code))
|
||||
local partyRebuilt = partyGame.stack:top()
|
||||
if restored then
|
||||
T.eq(partyGame.save.party[1].hp, 0,
|
||||
"fainted non-active party member roundtrips")
|
||||
T.check(partyRebuilt.player.mon == partyGame.save.party[2],
|
||||
"switched active Pokemon reconstructs against restored party identity")
|
||||
T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips")
|
||||
T.eq(partyRebuilt.player.mon.moves[1].pp,
|
||||
partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips")
|
||||
T.check(partyRebuilt.participants[partyGame.save.party[1]] == true
|
||||
and partyRebuilt.participants[partyGame.save.party[2]] == true,
|
||||
"participant references rebuild against fainted and active party members")
|
||||
T.same(Checkpoint.capture(partyGame), partyCheckpoint,
|
||||
"switch, faint, status and PP differential recapture is exact")
|
||||
end
|
||||
|
||||
local trainerGame, trainerOriginal = makeGame("trainer")
|
||||
trainerOriginal.turnCount = 6
|
||||
trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3
|
||||
trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp
|
||||
trainerOriginal.aiUses = 1
|
||||
trainerOriginal.participants = { [trainerGame.save.party[1]] = true,
|
||||
[trainerGame.save.party[2]] = true }
|
||||
local trainerCheckpoint = assert(Checkpoint.capture(trainerGame))
|
||||
settleOverworld(trainerGame)
|
||||
restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint)
|
||||
T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code))
|
||||
local trainerRebuilt = trainerGame.stack:top()
|
||||
if restored then
|
||||
T.check(trainerRebuilt ~= trainerOriginal,
|
||||
"trainer restore is independent of the original controller")
|
||||
T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips")
|
||||
T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips")
|
||||
T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips")
|
||||
T.same(Checkpoint.capture(trainerGame), trainerCheckpoint,
|
||||
"trainer differential recapture is exact")
|
||||
end
|
||||
|
||||
local function clone(value)
|
||||
return assert(SaveSerializer.decode(SaveSerializer.encode(value)))
|
||||
end
|
||||
|
||||
local beforeRejected = assert(Checkpoint.capture(trainerGame))
|
||||
local missingSpecies = clone(trainerCheckpoint)
|
||||
missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES"
|
||||
restored, code = Checkpoint.restore(trainerGame, missingSpecies)
|
||||
T.check(restored == false and code == "invalid_content",
|
||||
"unknown battle content is rejected before mutation")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeRejected,
|
||||
"rejected battle content leaves runtime and RNG unchanged")
|
||||
|
||||
local badOrigin = clone(trainerCheckpoint)
|
||||
badOrigin.runtime.battle.origin.npcId = nil
|
||||
restored, code = Checkpoint.restore(trainerGame, badOrigin)
|
||||
T.check(restored == false and code == "battle_origin_unsupported",
|
||||
"incomplete semantic continuation is rejected before mutation")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeRejected,
|
||||
"rejected continuation leaves runtime and RNG unchanged")
|
||||
|
||||
-- Fail after the new battle has been installed, when its RNG is applied.
|
||||
-- The transaction must reconstruct the prior battle and restore its RNG.
|
||||
local workingSetRandomState = love.math.setRandomState
|
||||
local setCalls = 0
|
||||
love.math.setRandomState = function(state)
|
||||
setCalls = setCalls + 1
|
||||
if setCalls == 1 then error("injected RNG restore failure") end
|
||||
return workingSetRandomState(state)
|
||||
end
|
||||
local beforeFailure = assert(Checkpoint.capture(trainerGame))
|
||||
local rngBeforeFailure = rng
|
||||
restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint)
|
||||
T.check(restored == false and code == "restore_failed",
|
||||
"post-install RNG failure is returned as a structured restore failure")
|
||||
T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeFailure,
|
||||
"failed battle restore rolls the complete runtime back exactly")
|
||||
love.math.setRandomState = workingSetRandomState
|
||||
|
||||
love.math.random = oldRandom
|
||||
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
|
||||
Music.playBattle = oldPlayBattle
|
||||
T.finish()
|
||||
@@ -0,0 +1,345 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.link.Net")
|
||||
local Json = require("src.link.Json")
|
||||
local Session = require("src.link.Session")
|
||||
|
||||
local function sessionPair()
|
||||
local hostNet, guestNet = Net.loopbackPair()
|
||||
return Session.new(hostNet, { role = "host", kind = "link" }),
|
||||
Session.new(guestNet, { role = "guest", kind = "link" })
|
||||
end
|
||||
|
||||
local function fakeTransport(options)
|
||||
options = options or {}
|
||||
local transport = {
|
||||
paired = options.paired ~= false,
|
||||
closed = false,
|
||||
error = nil,
|
||||
inbox = options.inbox or {},
|
||||
closeCount = 0,
|
||||
}
|
||||
function transport:update()
|
||||
if options.onUpdate then options.onUpdate(self) end
|
||||
if options.updateError then error(options.updateError) end
|
||||
end
|
||||
function transport:poll()
|
||||
if options.pollError then error(options.pollError) end
|
||||
local messages = self.inbox
|
||||
self.inbox = {}
|
||||
return messages
|
||||
end
|
||||
function transport:send(message)
|
||||
self.sent = message
|
||||
return true
|
||||
end
|
||||
function transport:close()
|
||||
self.closeCount = self.closeCount + 1
|
||||
self.closed = true
|
||||
if options.closeError then error(options.closeError) end
|
||||
end
|
||||
return transport
|
||||
end
|
||||
|
||||
local function readFile(path)
|
||||
local handle = assert(io.open(path, "rb"))
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
return body
|
||||
end
|
||||
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
T.eq(host:getRole(), "host", "host role is assigned locally")
|
||||
T.eq(guest:getRole(), "guest", "guest role is assigned locally")
|
||||
T.eq(host:getKind(), "link", "session kind is retained")
|
||||
T.eq(host:getStatus(), "paired", "wrapped loopback starts paired")
|
||||
|
||||
guest:send({
|
||||
type = "hello", name = "BLUE", role = "host", kind = "tournament",
|
||||
})
|
||||
host:update()
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.name, "BLUE", "send forwards the original payload")
|
||||
T.eq(hello.session, nil, "send adds no session envelope")
|
||||
T.eq(host:getRole(), "host", "peer payload cannot replace local role")
|
||||
T.eq(host:getKind(), "link", "peer payload cannot replace local kind")
|
||||
end
|
||||
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
guest:send({ type = "before", sequence = 1 })
|
||||
guest:send({ type = "hello", sequence = 2 })
|
||||
guest:send({ type = "after", sequence = 3 })
|
||||
guest:send({ type = "hello", sequence = 4 })
|
||||
host:update()
|
||||
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||
|
||||
local rest = host:poll()
|
||||
T.eq(#rest, 2, "poll returns every remaining packet once")
|
||||
T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order")
|
||||
T.eq(rest[2].sequence, 4, "take preserves repeated-type order")
|
||||
T.eq(#host:poll(), 0, "poll clears the private FIFO")
|
||||
end
|
||||
|
||||
do
|
||||
local sent
|
||||
local transport = {
|
||||
paired = false,
|
||||
code = nil,
|
||||
address = "192.0.2.5:7777",
|
||||
target = "ROOM01",
|
||||
update = function(self)
|
||||
self.paired = true
|
||||
self.code = "ROOM02"
|
||||
end,
|
||||
poll = function() return {} end,
|
||||
send = function(_, message)
|
||||
sent = message
|
||||
return "queued", 7
|
||||
end,
|
||||
close = function(self) self.closed = true end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "tournament" })
|
||||
T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting")
|
||||
T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored")
|
||||
T.eq(session.target, "ROOM01", "target metadata is mirrored")
|
||||
|
||||
local outbound = { type = "ping" }
|
||||
local result, count = session:send(outbound)
|
||||
T.eq(result, "queued", "send preserves the transport's first return")
|
||||
T.eq(count, 7, "send preserves the transport's second return")
|
||||
T.eq(sent, outbound, "send forwards the original table unchanged")
|
||||
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "paired", "update observes transport pairing")
|
||||
T.eq(session.code, "ROOM02", "update refreshes relay metadata")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
onUpdate = function(self) self.closed = true end,
|
||||
closeError = "normal cleanup exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"normal-close cleanup exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error",
|
||||
"normal-close cleanup exception becomes a transport failure")
|
||||
T.check(detail:find("normal cleanup exploded", 1, true) ~= nil,
|
||||
"normal-close cleanup failure keeps its diagnostic detail")
|
||||
T.eq(session:getStatus(), "failed",
|
||||
"normal-close cleanup exception cannot report a clean close")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox = { { type = "before", sequence = 1 } }
|
||||
self.error = "socket failed"
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "transport failure has a stable reason")
|
||||
T.eq(detail, "socket failed", "transport failure retains original detail")
|
||||
T.eq(session:getStatus(), "draining", "transport failure drains valid prefix")
|
||||
T.eq(session.error, nil, "legacy error stays hidden during drain")
|
||||
T.eq(session.closed, false, "legacy closed stays false during failed drain")
|
||||
T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix")
|
||||
T.eq(session:getStatus(), "failed", "failed drain reaches failed")
|
||||
T.eq(session.error, "socket failed", "legacy error appears at terminal failure")
|
||||
transport.error = "later error"
|
||||
session:update()
|
||||
local _, latchedDetail = session:getFailure()
|
||||
T.eq(latchedDetail, "socket failed", "first terminal failure stays latched")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = {
|
||||
{ type = "before", sequence = 1 },
|
||||
false,
|
||||
{ type = "after", sequence = 3 },
|
||||
} })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
local reason = session:getFailure()
|
||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
||||
local messages = session:poll()
|
||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"table without string type is a protocol error")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "future_world_packet", value = 9 } },
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "already_decoded", value = 4 } },
|
||||
updateError = "update exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
local ok = pcall(session.update, session)
|
||||
T.check(ok, "transport update exception does not escape the game loop")
|
||||
T.eq(session:getStatus(), "draining", "update exception still drains prior inbox")
|
||||
T.eq(session:pollOne().value, 4, "decoded packet survives update exception")
|
||||
T.eq(session:getStatus(), "failed", "update exception becomes terminal failure")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ pollError = "poll exploded" })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"transport poll exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "poll exception is a transport failure")
|
||||
T.check(detail:find("poll exploded", 1, true) ~= nil,
|
||||
"poll exception keeps its diagnostic detail")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ closeError = "close exploded" })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
T.check(pcall(session.close, session),
|
||||
"transport close exception does not escape cleanup")
|
||||
T.eq(session:getFailure(), "transport_error",
|
||||
"close exception is a transport failure")
|
||||
session:close()
|
||||
T.eq(transport.closeCount, 1, "failed close is still attempted only once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport()
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:close()
|
||||
session:close()
|
||||
session:update()
|
||||
T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent")
|
||||
T.eq(session:getStatus(), "closed", "explicit close reaches closed")
|
||||
end
|
||||
|
||||
do
|
||||
T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }),
|
||||
"constructor rejects missing transport")
|
||||
local transport = fakeTransport()
|
||||
T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }),
|
||||
"constructor rejects unsupported role")
|
||||
T.check(not pcall(Session.new, transport, { role = "host", kind = "" }),
|
||||
"constructor rejects empty kind")
|
||||
end
|
||||
|
||||
do
|
||||
local senderNet, receiverNet = Net.loopbackPair()
|
||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||
senderNet:send(false)
|
||||
receiver:update()
|
||||
T.eq(receiver:getFailure(), "protocol_error",
|
||||
"loopback forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local delivered = false
|
||||
local transport = Net.new()
|
||||
transport.enetHost = {
|
||||
service = function()
|
||||
if delivered then return nil end
|
||||
delivered = true
|
||||
return { type = "receive", data = "false" }
|
||||
end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"ENet forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
local session = Session.new(transport, { role = "host", kind = "tournament" })
|
||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||
"TCP control handoff does not index a decoded scalar")
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"decoded TCP scalar reaches session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" }))
|
||||
T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned")
|
||||
transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" }))
|
||||
T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact")
|
||||
end
|
||||
|
||||
do
|
||||
local source = readFile("src/link/LinkState.lua")
|
||||
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
||||
"LinkState depends on the session boundary")
|
||||
T.check(source:find('kind = "link"', 1, true) ~= nil,
|
||||
"LinkState assigns the link session kind locally")
|
||||
T.check(source:find("self.net.inbox", 1, true) == nil,
|
||||
"LinkState never mutates a transport inbox")
|
||||
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
||||
"LinkState stores only successful session wrappers")
|
||||
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
||||
"LinkState retrieves hello without draining unrelated packets")
|
||||
T.check(source:find(':take("party")', 1, true) ~= nil,
|
||||
"LinkState leaves battle handoff packets in session order")
|
||||
T.check(source:find("getStatus()", 1, true) ~= nil,
|
||||
"LinkState uses the session lifecycle instead of raw terminal flags")
|
||||
end
|
||||
|
||||
do
|
||||
local source = readFile("src/link/Tournament.lua")
|
||||
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
||||
"Tournament depends on the session boundary")
|
||||
T.check(source:find('kind = "tournament"', 1, true) ~= nil,
|
||||
"Tournament assigns its connection role and kind locally")
|
||||
T.check(source:find("self.net.inbox", 1, true) == nil,
|
||||
"Tournament never mutates a transport inbox")
|
||||
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
||||
"Tournament stores only a successful session wrapper")
|
||||
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
||||
"Tournament retrieves match hello without draining its tail")
|
||||
T.check(source:find(":pollOne()", 1, true) ~= nil,
|
||||
"Tournament processes handoff prefixes one packet at a time")
|
||||
T.check(source:find("getStatus()", 1, true) ~= nil,
|
||||
"Tournament uses the session lifecycle instead of raw terminal flags")
|
||||
end
|
||||
|
||||
T.finish("link_session")
|
||||
@@ -142,7 +142,11 @@ do
|
||||
index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } }))
|
||||
check(index == nil and err ~= nil, "a feed with no schema_version is refused")
|
||||
index, err = ModIndex.parse("<!DOCTYPE html><html>404</html>")
|
||||
check(index == nil and err ~= nil, "an HTML error page soft-fails")
|
||||
check(index == nil and tostring(err):find("HTML", 1, true) ~= nil,
|
||||
"an HTML error page is named, not blamed on the parser")
|
||||
index, err = ModIndex.parse("Error: upstream unavailable")
|
||||
check(index == nil and tostring(err):find("not JSON", 1, true) ~= nil,
|
||||
"a plain-text error names the response")
|
||||
index, err = ModIndex.parse('{"schema_version":1}')
|
||||
check(index == nil and err ~= nil, "a feed with no mods array soft-fails")
|
||||
end
|
||||
|
||||
@@ -76,6 +76,31 @@ do
|
||||
check(path == nil and dlErr ~= nil, "empty url soft-fails")
|
||||
end
|
||||
|
||||
-- the reported bug: a non-JSON answer (plain-text error, proxy/captive
|
||||
-- prompt, outage message) used to leak the decoder's "unexpected character"
|
||||
-- assert at the first byte of the body. The guard must name what the server
|
||||
-- actually sent and never let that assert surface.
|
||||
do
|
||||
local list, err = ModUpdate.parseReleases("Error: API rate limit exceeded", "demo")
|
||||
check(list == nil and err ~= nil, "plain-text error soft-fails")
|
||||
check(tostring(err):find("not JSON", 1, true) ~= nil
|
||||
and tostring(err):find("Error: API", 1, true) ~= nil,
|
||||
"plain-text error names the response and previews what it said")
|
||||
list, err = ModUpdate.parseReleases("<!DOCTYPE html><html>502 Bad Gateway</html>", "demo")
|
||||
check(list == nil and tostring(err):find("HTML", 1, true) ~= nil,
|
||||
"an HTML error page is named as such")
|
||||
list, err = ModUpdate.parseReleases("", "demo")
|
||||
check(list == nil and tostring(err):find("empty", 1, true) ~= nil,
|
||||
"an empty response is named")
|
||||
check(tostring(err):find("unexpected character", 1, true) == nil,
|
||||
"the decoder's assert never leaks into the message")
|
||||
list = ModUpdate.parseReleases(Json.encode({
|
||||
{ tag_name = "v1.0.0", assets = {
|
||||
{ name = "demo-1.0.0.zip", browser_download_url = "https://x/d.zip" } } },
|
||||
}), "demo")
|
||||
eq(#list, 1, "the guard lets real JSON through")
|
||||
end
|
||||
|
||||
do
|
||||
local body = Json.encode({
|
||||
tag_name = "v2.0.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- #932 "Bugs reset settings": a caller that hands saveOptions a PARTIAL
|
||||
-- table (only the keys it changed) used to drop every key it did not
|
||||
-- mention -- launcher-only keys like lastVersion, and keys the launcher set
|
||||
-- (battleBg, tilt) all fell back to defaults. saveOptions now reads the
|
||||
-- on-disk file first and folds caller-absent values underneath, so a delta
|
||||
-- write changes only what it names.
|
||||
--
|
||||
-- This suite pins the three-way merge against injected filesystem stubs
|
||||
-- (the same { getInfo, read, write, remove } shape the other engine suites
|
||||
-- use). It is ROM-free (T2 engine tier).
|
||||
-- luajit tests/engine/options_partial_write_bug932.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
|
||||
local function memfs()
|
||||
local files = {}
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Seed a save dir with the full snapshot a launcher would write: defaults,
|
||||
-- plus the keys the issue cares about. lastVersion is launcher-only (not a
|
||||
-- defaultOptions member) and must survive ANY write that does not name it.
|
||||
local function seed(fs)
|
||||
local seed = SaveData.defaultOptions()
|
||||
seed.battleBg = "world"
|
||||
seed.lastVersion = "blue"
|
||||
seed.tilt = 1
|
||||
seed.mods = { foo = true }
|
||||
seed.modOptions = { alpha = { keep = true, x = 1 } }
|
||||
check(SaveData.saveOptions(seed, fs) ~= nil, "seeding lands")
|
||||
end
|
||||
|
||||
-- ---- launcher-only keys survive a delta write
|
||||
|
||||
local fs = memfs()
|
||||
seed(fs)
|
||||
|
||||
-- loader-style partial write: only the mods bucket it manages.
|
||||
SaveData.saveOptions({ mods = { foo = true } }, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
eq(opts.battleBg, "world", "a partial write keeps battleBg the launcher set")
|
||||
eq(opts.lastVersion, "blue", "a partial write keeps lastVersion (#932)")
|
||||
eq(opts.tilt, 1, "a partial write keeps tilt the launcher set")
|
||||
|
||||
-- ---- caller-present keys still win
|
||||
|
||||
SaveData.saveOptions({ battleBg = "black" }, fs)
|
||||
eq(SaveData.loadOptions(fs).battleBg, "black",
|
||||
"a key the caller DOES provide wins over the on-disk value")
|
||||
eq(SaveData.loadOptions(fs).lastVersion, "blue",
|
||||
"...while the launcher-only key is still carried")
|
||||
|
||||
-- ---- modOptions per-mod deep merge stays intact
|
||||
|
||||
SaveData.saveOptions({ modOptions = { alpha = { x = 5 } } }, fs)
|
||||
local after = SaveData.loadOptions(fs)
|
||||
eq(after.modOptions.alpha.x, 5, "newest alpha value wins the per-mod merge")
|
||||
eq(after.modOptions.alpha.keep, true, "alpha's untouched keys survive")
|
||||
eq(after.modOptions.beta, nil, "no beta was invented by the merge")
|
||||
|
||||
-- ---- full-table writes stay authoritative (bindings/activeProfile drops)
|
||||
|
||||
-- The fold must NOT resurrect a key a full snapshot deliberately deletes:
|
||||
-- the RESET REBINDS path nils bindings and the mod manager nils
|
||||
-- activeProfile, always on full loadOptions tables.
|
||||
fs = memfs()
|
||||
seed(fs)
|
||||
SaveData.saveOptions({ bindings = { a = 1 } }, fs)
|
||||
eq(SaveData.loadOptions(fs).bindings.a, 1, "bindings is not a default member")
|
||||
|
||||
local full = SaveData.loadOptions(fs)
|
||||
full.bindings = nil
|
||||
full.activeProfile = nil
|
||||
SaveData.saveOptions(full, fs)
|
||||
local reopened = SaveData.loadOptions(fs)
|
||||
eq(reopened.bindings, nil,
|
||||
"a full-snapshot deletion of bindings is NOT resurrected by the fold")
|
||||
eq(reopened.activeProfile, nil,
|
||||
"a full-snapshot deletion of activeProfile is NOT resurrected")
|
||||
eq(reopened.battleBg, "world",
|
||||
"the rest of the full snapshot is still what it was")
|
||||
|
||||
T.finish("options_partial_write_bug932")
|
||||
@@ -204,23 +204,26 @@ eq(reopened.lastVersion, "blue",
|
||||
"launcher-only keys the game never reads are carried through its write")
|
||||
|
||||
-- The corollary, and the reason the copy has to come from loadOptions: a
|
||||
-- caller that writes a partial literal instead of a loaded table drops every
|
||||
-- key it does not mention, because mergeOptions only fills DEFAULTS in around
|
||||
-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does
|
||||
-- this today; the assertion is the guard rail if someone shortcuts it.
|
||||
-- caller that writes a partial literal instead of a loaded table would drop
|
||||
-- every key it does not mention. Since #932 that drop is closed by a
|
||||
-- three-way merge -- saveOptions folds on-disk values the caller's table
|
||||
-- does not carry (lastVersion here), defaults-filling only what neither side
|
||||
-- has -- so even a delta write keeps the launcher's key alive. Nothing on
|
||||
-- the boot path writes partials today; the assertion is the guard rail if
|
||||
-- someone shortcuts it.
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
eq(SaveData.loadOptions(hop).lastVersion, nil,
|
||||
"a partial write drops launcher-only keys, so the game must write the "
|
||||
.. "table loadOptions handed it")
|
||||
eq(SaveData.loadOptions(hop).lastVersion, "blue",
|
||||
"a partial write no longer drops launcher-only keys (#932)")
|
||||
|
||||
-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's
|
||||
-- write and flushed after it still wins, because saveOptions merges only
|
||||
-- modOptions from disk and every other key is last-writer-wins. Measured,
|
||||
-- not guessed (og beats a newer wide). No shipping path holds an options
|
||||
-- table across a launcher write -- HostShell.restart replaces the process on
|
||||
-- the way back to the launcher (#785, #575) and LauncherSettings.open notes
|
||||
-- its own cached table is only true while its modal covers the launcher --
|
||||
-- so closing that gap needs a three-way merge (baseline vs caller vs disk),
|
||||
-- not a straight "disk wins", which would throw away real in-game changes.
|
||||
-- Known gap, deliberately not asserted: a FULL copy taken BEFORE the
|
||||
-- launcher's write and flushed after it still wins -- a table holding every
|
||||
-- defaultOptions key is authoritative, so its og is never folded against a
|
||||
-- newer wide on disk (#932 closes the PARTIAL-write drop, not this).
|
||||
-- Measured, not guessed. No shipping path holds an options table across a
|
||||
-- launcher write -- HostShell.restart replaces the process on the way back
|
||||
-- to the launcher (#785, #575) and LauncherSettings.open notes its own
|
||||
-- cached table is only true while its modal covers the launcher -- so
|
||||
-- closing that gap needs a real three-way baseline (vs caller vs disk), not
|
||||
-- a straight "disk wins", which would throw away real in-game changes.
|
||||
|
||||
T.finish("options_write_readback_bug828")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Opaque playthrough identity: New Game uniqueness, save/load persistence,
|
||||
-- stable legacy backfill, and version/slot isolation. No real save directory.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] 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,
|
||||
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 function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function legacy(version, name)
|
||||
return {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {} },
|
||||
player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 },
|
||||
flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {},
|
||||
money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
|
||||
}
|
||||
end
|
||||
|
||||
-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope.
|
||||
do
|
||||
fresh()
|
||||
local first = SaveData.newGame({ version = "red" })
|
||||
local second = SaveData.newGame({ version = "red" })
|
||||
T.eq(first.meta.playthroughId, nil,
|
||||
"New Game allocates no playthrough id before a public tool requests it")
|
||||
T.check(SaveData.save(first), "unused identity fixture saves")
|
||||
local untouched = SaveData.load("red")
|
||||
T.eq(untouched.meta.playthroughId, nil,
|
||||
"normal save/load stays identity-free when no tool uses the capability")
|
||||
|
||||
local firstId = SaveData.ensurePlaythroughId(first)
|
||||
local secondId = SaveData.ensurePlaythroughId(second)
|
||||
T.check(type(firstId) == "string" and firstId ~= "",
|
||||
"the first tool request allocates an opaque playthrough id")
|
||||
T.neq(secondId, firstId,
|
||||
"separate New Games receive separate requested playthrough ids")
|
||||
end
|
||||
|
||||
-- Dropping the id from buildMeta or save encoding must fail the roundtrip.
|
||||
do
|
||||
fresh()
|
||||
local save = SaveData.newGame({ version = "red" })
|
||||
local expected = SaveData.ensurePlaythroughId(save)
|
||||
T.check(SaveData.save(save), "identity fixture saves")
|
||||
local loaded = SaveData.load("red")
|
||||
T.eq(loaded and loaded.meta.playthroughId, expected,
|
||||
"normal save/load preserves the playthrough id")
|
||||
end
|
||||
|
||||
-- Legacy identity is persisted independently: the legacy progress bytes remain
|
||||
-- unchanged, yet two loads resolve the same id before a normal SAVE occurs.
|
||||
do
|
||||
local files = fresh()
|
||||
local raw = legacy("red", "LEGACY")
|
||||
files["save.lua"] = SaveSerializer.encode(raw)
|
||||
|
||||
local first = SaveData.load("red")
|
||||
T.eq(first and first.meta.playthroughId, nil,
|
||||
"loading a legacy save alone does not allocate tool identity")
|
||||
local id = SaveData.ensurePlaythroughId(first)
|
||||
T.check(type(id) == "string" and id ~= "",
|
||||
"a legacy save receives a playthrough id")
|
||||
local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "")
|
||||
T.check(mappedOptions ~= nil,
|
||||
"legacy identity mapping remains decodable: " .. tostring(mappedErr))
|
||||
|
||||
local slotBytes = files["saves/red/slot1.lua"]
|
||||
local onDisk = slotBytes and SaveSerializer.decode(slotBytes)
|
||||
T.eq(onDisk and onDisk.meta.playthroughId, nil,
|
||||
"legacy backfill does not rewrite normal progress")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
local second = SaveData.load("red")
|
||||
T.eq(SaveData.ensurePlaythroughId(second), id,
|
||||
"legacy backfill is stable across reload before normal SAVE")
|
||||
end
|
||||
|
||||
-- Reusing names and coordinates cannot merge identities across slots or games.
|
||||
do
|
||||
fresh()
|
||||
local redA = SaveData.createSlot("red")
|
||||
local redB = SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", redA)
|
||||
T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")),
|
||||
"seed red slot A")
|
||||
local idA = SaveData.ensurePlaythroughId(SaveData.load("red"))
|
||||
|
||||
SaveData.setActiveSlot("red", redB)
|
||||
T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")),
|
||||
"seed red slot B")
|
||||
local idB = SaveData.ensurePlaythroughId(SaveData.load("red"))
|
||||
|
||||
GameVersion.set("blue")
|
||||
local blue = SaveData.createSlot("blue")
|
||||
SaveData.setActiveSlot("blue", blue)
|
||||
T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")),
|
||||
"seed blue slot")
|
||||
local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue"))
|
||||
|
||||
T.neq(idA, idB, "two active slots do not share legacy identity")
|
||||
T.neq(idA, idBlue, "Red and Blue do not share legacy identity")
|
||||
T.neq(idB, idBlue, "every version/slot scope is isolated")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
T.finish("playthrough_identity")
|
||||
@@ -0,0 +1,42 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local name = "src.render.SecondScreen"
|
||||
local oldModule = package.loaded[name]
|
||||
local oldFfi = package.loaded.ffi
|
||||
local oldPreload = package.preload.ffi
|
||||
local null = {}
|
||||
local calls = 0
|
||||
local C = {
|
||||
love_android_secondary_ready = function() return 1 end,
|
||||
love_android_push_secondary = function() end,
|
||||
love_android_secondary_enable = function() end,
|
||||
love_android_poll_secondary_touch = function()
|
||||
calls = calls + 1
|
||||
return calls == 1 and "down,12,34" or null
|
||||
end,
|
||||
}
|
||||
local fakeFfi = {
|
||||
C = C,
|
||||
NULL = null,
|
||||
cdef = function() end,
|
||||
load = function() return C end,
|
||||
string = function(value) return value end,
|
||||
}
|
||||
|
||||
package.loaded[name] = nil
|
||||
package.loaded.ffi = nil
|
||||
package.preload.ffi = function() return fakeFfi end
|
||||
|
||||
local SecondScreen = require(name)
|
||||
T.eq(SecondScreen.pollTouch(), "down,12,34",
|
||||
"secondary touch reaches the Lua facade")
|
||||
T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil")
|
||||
C.love_android_poll_secondary_touch = nil
|
||||
T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe")
|
||||
|
||||
package.loaded[name] = oldModule
|
||||
package.loaded.ffi = oldFfi
|
||||
package.preload.ffi = oldPreload
|
||||
|
||||
T.finish("second-screen touch facade")
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Issue #945: a mod's per-trainer battleTheme (trainers.battleTheme, an
|
||||
-- audio.songs id) was validated and merged onto the trainer record but
|
||||
-- never read -- battle music came solely from data.audio.battle[kind] where
|
||||
-- kind is computeMusicKind()'s final/gym/trainer/wild. Both battle-theme
|
||||
-- start sites (OverworldController:pushBattle's pre-wipe cue and
|
||||
-- BattleState:enter) now route through BattleState:playBattleTheme(), which
|
||||
-- hands the override to Music.playBattle's new song arg. A nil override
|
||||
-- keeps the kind default, so vanilla trainer fights -- and #782's non-gym
|
||||
-- Giovanni -- are unchanged.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- ------- love audio stub: file-backed songs only (mod_audio pattern)
|
||||
|
||||
local love = _G.love or {}
|
||||
_G.love = love
|
||||
love.audio = love.audio or {}
|
||||
|
||||
local assets = {
|
||||
["assets/theme.ogg"] = true,
|
||||
["assets/alt.ogg"] = true,
|
||||
}
|
||||
|
||||
local sources = {}
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() end
|
||||
function Source:stop() end
|
||||
function Source:setLooping() end
|
||||
function Source:setVolume() end
|
||||
function Source:setFilter() end
|
||||
|
||||
love.audio.newSource = function(what, mode)
|
||||
if type(what) == "string" and not assets[what] then
|
||||
error("could not open file " .. what, 0)
|
||||
end
|
||||
local src = setmetatable({ file = what, mode = mode, queueable = false }, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
love.audio.newQueueableSource = function()
|
||||
local src = setmetatable({ queueable = true }, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Font = require("src.render.Font")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- ------- the fix-945 mod: register a song and point a trainer class at it
|
||||
|
||||
local MOD = {
|
||||
["mods/fix_youngster_theme/manifest.json"] = [[{
|
||||
"id": "fix_youngster_theme",
|
||||
"name": "Fix Youngster Theme",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_youngster_theme/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.music:register("Music_ModTheme", { file = "assets/theme.ogg" })
|
||||
mod.content.trainers:patch("OPP_FIX_YOUNGSTER", {
|
||||
battleTheme = "Music_ModTheme",
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
local function newGame(data)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.player.rival = "GARY"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||
return { data = data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end, pop = function() end } }
|
||||
end
|
||||
|
||||
-- record every cue the music.select hook sees
|
||||
local function hookRecorder(seen)
|
||||
Runtime.hooks:wrap("music.select", function(nextLink, song, ctx)
|
||||
seen[#seen + 1] = { song = song, kind = ctx.kind,
|
||||
trainerId = ctx.trainerId }
|
||||
return nextLink(song, ctx)
|
||||
end, nil, "bug945")
|
||||
end
|
||||
|
||||
-- a playBattle spy that records (kind, trainerId, song) without touching audio
|
||||
local function spyPlayBattle()
|
||||
local calls = {}
|
||||
local real = Music.playBattle
|
||||
Music.playBattle = function(data, kind, trainerId, song)
|
||||
calls[#calls + 1] = { kind = kind, trainerId = trainerId, song = song }
|
||||
end
|
||||
return calls, function() Music.playBattle = real end
|
||||
end
|
||||
|
||||
-- ------- the modded class resolves and the override reaches the cue
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
Font.load(Data)
|
||||
TypeChart.load(Data)
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/fix_youngster_theme" },
|
||||
{ data = Data, fs = T.sdk.memfs(MOD) })
|
||||
T.eq(#run.errors, 0, "the battleTheme mod loads without validation errors")
|
||||
T.eq(Data.trainers.OPP_FIX_YOUNGSTER.battleTheme, "Music_ModTheme",
|
||||
"the patch lands on the trainer record")
|
||||
|
||||
-- give the kind defaults a home so the no-override fallback is observable
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.battle = Data.audio.battle or {
|
||||
wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer",
|
||||
}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.songs.Music_DefaultWild = { file = "assets/alt.ogg" }
|
||||
Data.audio.songs.Music_DefaultTrainer = { file = "assets/alt.ogg" }
|
||||
|
||||
local battle = BattleState.newTrainer(newGame(Data), "OPP_FIX_YOUNGSTER", 1)
|
||||
T.eq(battle:battleTheme(), "Music_ModTheme",
|
||||
"battleTheme() resolves the per-trainer override")
|
||||
T.eq(battle:computeMusicKind(), "trainer",
|
||||
"a plain trainer fight is still trainer-kind")
|
||||
|
||||
local calls, restore = spyPlayBattle()
|
||||
battle:playBattleTheme()
|
||||
T.eq(#calls, 1, "playBattleTheme cues the theme once")
|
||||
T.eq(calls[1].kind, "trainer", "the cue carries the computed kind")
|
||||
T.eq(calls[1].trainerId, "OPP_FIX_YOUNGSTER", "the cue carries the trainer id")
|
||||
T.eq(calls[1].song, "Music_ModTheme", "the override label wins over the kind default")
|
||||
|
||||
-- enter() sets self.musicKind before playing; playBattleTheme honors it
|
||||
battle.musicKind = "gym"
|
||||
battle:playBattleTheme()
|
||||
T.eq(calls[2].kind, "gym", "a pre-set musicKind (the enter path) is used as-is")
|
||||
restore()
|
||||
|
||||
-- ------- Music.playBattle: override arg wins; nil falls back to the default
|
||||
|
||||
local seen = {}
|
||||
hookRecorder(seen)
|
||||
Music.reload()
|
||||
Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER", "Music_ModTheme")
|
||||
T.eq(seen[1].song, "Music_ModTheme", "the override arg is played")
|
||||
T.eq(seen[1].kind, "trainer", "the hook sees the battle kind")
|
||||
T.eq(seen[1].trainerId, "OPP_FIX_YOUNGSTER", "the hook sees the trainer id")
|
||||
|
||||
Music.reload()
|
||||
Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER")
|
||||
T.eq(seen[2].song, "Music_DefaultTrainer",
|
||||
"no override falls back to the kind's default song")
|
||||
T.eq(seen[2].trainerId, "OPP_FIX_YOUNGSTER",
|
||||
"the hook still sees the trainer id on the default path")
|
||||
|
||||
-- ------- a vanilla class has no override, so the kind default is untouched
|
||||
|
||||
local DataV = T.fixtures.fresh()
|
||||
Font.load(DataV)
|
||||
TypeChart.load(DataV)
|
||||
DataV.audio = {
|
||||
battle = { wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer" },
|
||||
songs = {
|
||||
Music_DefaultWild = { file = "assets/alt.ogg" },
|
||||
Music_DefaultTrainer = { file = "assets/alt.ogg" },
|
||||
},
|
||||
}
|
||||
|
||||
local battleV = BattleState.newTrainer(newGame(DataV), "OPP_FIX_YOUNGSTER", 1)
|
||||
T.eq(battleV:battleTheme(), nil, "a vanilla trainer class has no override")
|
||||
local callsV, restoreV = spyPlayBattle()
|
||||
battleV:playBattleTheme()
|
||||
T.eq(callsV[1].kind, "trainer", "vanilla cue keeps the trainer kind")
|
||||
T.eq(callsV[1].song, nil, "vanilla passes no override, so the default plays (#782)")
|
||||
restoreV()
|
||||
|
||||
local seenV = {}
|
||||
hookRecorder(seenV)
|
||||
Music.reload()
|
||||
Music.playBattle(DataV, "trainer", "OPP_FIX_YOUNGSTER")
|
||||
T.eq(seenV[1].song, "Music_DefaultTrainer",
|
||||
"vanilla battles play the kind default, not a per-trainer theme (#782)")
|
||||
|
||||
T.finish("trainer battle theme bug945")
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Engine invariant (#916): after the Fly / Dig departure animation ends, the
|
||||
-- trainer sprite must stay hidden through the warp fade-out and only become
|
||||
-- visible again when the arrival animation (flyArrive / teleport spin-down)
|
||||
-- plays on the new map.
|
||||
--
|
||||
-- Root cause: the player-hide guard only held while a departure animation
|
||||
-- was live. flyAnim was nil'd the instant the bird finished path2, and the
|
||||
-- teleportOut countdown cleared the spin fields at 0, but startWarpTo's
|
||||
-- 32-frame fade keeps the overworld drawing beneath the veil (the Transition
|
||||
-- is not isOpaque), so with the departure guard gone and the arrival not yet
|
||||
-- armed, the standing sprite popped back in at the old cell for the whole
|
||||
-- fade.
|
||||
--
|
||||
-- The fix is a playerHidden flag on OverworldState: set when the departure
|
||||
-- completes (flyAnim path2 / teleportOut countdown), cleared in startWarpTo's
|
||||
-- midpoint the same tick the arrival arms, and folded into both player-draw
|
||||
-- guards. This suite runs the REAL Transition + setMap headlessly and
|
||||
-- asserts there is no fade frame where the player would draw bare.
|
||||
--
|
||||
-- ROM-free (fixture dataset, no ROM boot): lives in tests/engine so the CI
|
||||
-- headless tier runs it; also runnable standalone via
|
||||
-- `luajit tests/engine/warp_sprite_hidden_bug916.lua`.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
-- fixture patches that let the overworld boot and run headlessly
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {} }
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = Game.stack
|
||||
|
||||
-- The draw guard both entity passes use: the player sprite is skipped while
|
||||
-- any of flyAnim / flyArrive / playerHidden is set.
|
||||
local function playerHidden(ow)
|
||||
return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true
|
||||
end
|
||||
|
||||
local function newOW()
|
||||
stack:push(OW, "FIX_TOWN", 5, 6, "down")
|
||||
local ow = stack:top()
|
||||
Game.overworld = ow
|
||||
return ow
|
||||
end
|
||||
|
||||
-- Drive `ow` until its departure + warp + arrival all complete, tracking the
|
||||
-- fade window. Returns counters: fadeFrames / fadeFramesHidden (frames the
|
||||
-- Transition was on top; of those, frames the player was hidden), gapFrames
|
||||
-- (fade frames where NO arrival was active AND the player was NOT hidden --
|
||||
-- the regression this suite guards), arrivalFrame (first frame an arrival
|
||||
-- animation armed), warpFrame (first frame a fade is up).
|
||||
--
|
||||
-- Breaks once an arrival armed and then fully finished (no stale departure
|
||||
-- or arrival animation, OW back on top); `maxFrames` is the safety net.
|
||||
local function drive(ow, maxFrames)
|
||||
local st = { fadeFrames = 0, fadeFramesHidden = 0, gapFrames = 0,
|
||||
arrivalFrame = nil, warpFrame = nil }
|
||||
for i = 1, maxFrames or 260 do
|
||||
local fading = stack:top() ~= ow
|
||||
stack:update()
|
||||
if fading then
|
||||
st.fadeFrames = st.fadeFrames + 1
|
||||
if playerHidden(ow) then st.fadeFramesHidden = st.fadeFramesHidden + 1 end
|
||||
local arrivalActive = ow.flyArrive ~= nil or ow.player.spinDrop == true
|
||||
if not arrivalActive and not playerHidden(ow) then
|
||||
st.gapFrames = st.gapFrames + 1
|
||||
end
|
||||
if st.warpFrame == nil then st.warpFrame = i end
|
||||
end
|
||||
if st.arrivalFrame == nil
|
||||
and (ow.flyArrive ~= nil or ow.player.spinDrop == true) then
|
||||
st.arrivalFrame = i
|
||||
end
|
||||
if st.arrivalFrame and stack:top() == ow
|
||||
and ow.flyArrive == nil and ow.player.spinDrop ~= true
|
||||
and not ow.player.inputLocked then
|
||||
break -- departure + fade + arrival all finished
|
||||
end
|
||||
end
|
||||
return st
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ dig/teleport
|
||||
-- Departure spin (48) -> warp fade -> arrival spin-down. From the moment
|
||||
-- the spin ends until the arrival arms, the sprite must never draw bare.
|
||||
local ow = newOW()
|
||||
local doneFired = false
|
||||
ow:beginTeleportOut(function()
|
||||
doneFired = true
|
||||
ow.player.inputLocked = false -- the party-menu caller unlocks after the warp
|
||||
end)
|
||||
local st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
|
||||
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no dig fade frame leaves the player standing bare (#916)")
|
||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
||||
"dig fade hidden on every frame but the arrival-arming midpoint ("
|
||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
||||
check(st.arrivalFrame ~= nil, "dig arrival spin-down arms")
|
||||
check(ow.playerHidden == false, "dig hide cleared on the new map")
|
||||
check(doneFired, "dig onDone fires after the warp")
|
||||
check(ow.player.spinDrop ~= true and ow.player.spinning == false,
|
||||
"dig arrival spin-down completes")
|
||||
check(not playerHidden(ow), "player drawable again after the dig landing")
|
||||
|
||||
-- ------------------------------------------------------------------ fly
|
||||
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
|
||||
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
|
||||
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
|
||||
ow = newOW()
|
||||
ow:flyTo("FIX_ROUTE")
|
||||
st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
|
||||
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
|
||||
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
|
||||
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
|
||||
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no fly fade frame leaves the player standing bare (#916)")
|
||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
||||
"fly fade hidden on every frame but the arrival-arming midpoint ("
|
||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
||||
check(st.arrivalFrame ~= nil, "fly arrival swoop arms")
|
||||
check(ow.playerHidden == false, "fly hide cleared on the new map")
|
||||
check(ow.flyArrive == nil, "fly arrival swoop completes")
|
||||
check(not ow.player.inputLocked, "fly landing releases player input")
|
||||
check(not playerHidden(ow), "player drawable again after the fly landing")
|
||||
|
||||
T.finish("warp_sprite_hidden_bug916")
|
||||
@@ -0,0 +1,42 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Assets = require("src.render.Assets")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
Assets.imageData = function()
|
||||
return { getPixel = function(_, x)
|
||||
local shade = x < 8 and 1 or 0
|
||||
return shade, shade, shade, 1
|
||||
end }
|
||||
end
|
||||
|
||||
local api = WorldAPI.new({ stack = { states = {} } }, "tester")
|
||||
local overview, err = api:mapOverview()
|
||||
T.eq(overview, nil, "map overview is unavailable outside the overworld")
|
||||
T.eq(err, "no overworld", "map overview reports why it is unavailable")
|
||||
|
||||
local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 }
|
||||
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
|
||||
function map:isWaterCell(x, y) return x == 0 and y == 1 end
|
||||
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
|
||||
function map:tileAt(x) return x % 2 end
|
||||
|
||||
api = WorldAPI.new({ stack = { states = {
|
||||
{ isOverworld = true, map = map },
|
||||
} } }, "tester")
|
||||
overview = api:mapOverview()
|
||||
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
|
||||
T.eq(overview.width, 2, "map overview reports its width")
|
||||
T.eq(overview.height, 2, "map overview reports its height")
|
||||
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
|
||||
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
|
||||
T.eq(overview.tileRows, nil, "tile overview is optional")
|
||||
|
||||
map.tileset = { image = "test.png", tilesPerRow = 2 }
|
||||
overview = api:mapOverview()
|
||||
T.eq(overview.tileWidth, 4, "tile overview reports its width")
|
||||
T.eq(overview.tileHeight, 4, "tile overview reports its height")
|
||||
T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading")
|
||||
|
||||
T.finish("world map overview")
|
||||
@@ -0,0 +1,442 @@
|
||||
-- Public mod.checkpoints contract over a semantic Game/StateStack fixture.
|
||||
-- The mod entry chunk sees no private module; the harness builds the engine side.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local oldGetRandomState = love.math.getRandomState
|
||||
local oldSetRandomState = love.math.setRandomState
|
||||
local checkpointRngState = "overworld-rng-A"
|
||||
love.math.getRandomState = function() return checkpointRngState end
|
||||
love.math.setRandomState = function(state) checkpointRngState = state end
|
||||
|
||||
local T = require("tests.harness").suite("mod checkpoints")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
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 function baseSave()
|
||||
return {
|
||||
version = "red",
|
||||
meta = { format = 4, mods = {}, playthroughId = "play-a" },
|
||||
player = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false,
|
||||
name = "RED", rival = "BLUE", id = 7,
|
||||
},
|
||||
money = 3000,
|
||||
party = { { species = "BULBASAUR", level = 5, hp = 19,
|
||||
moves = { "TACKLE" } } },
|
||||
flags = { GOT_STARTER = true },
|
||||
inventory = { POTION = 1 },
|
||||
pcItems = { POTION = 2 },
|
||||
box = { { species = "BULBASAUR", level = 4, hp = 16,
|
||||
moves = { "TACKLE" } } },
|
||||
boxes = { [2] = { { species = "BULBASAUR", level = 3, hp = 14,
|
||||
moves = { "TACKLE" } } } },
|
||||
defeatedTrainers = { PALLET_RIVAL = true },
|
||||
objectToggles = { PALLET_TOWN = { OAK = false } },
|
||||
itemsTaken = { PALLET_TOWN_POTION = true },
|
||||
pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } },
|
||||
modData = {},
|
||||
options = { volume = 4, bindings = {} },
|
||||
}
|
||||
end
|
||||
|
||||
local function makeGame()
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local ow = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 5, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function ow:captureSave(save)
|
||||
save.player.map = self.map.id
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function ow:enter(mapId, x, y, facing, opts)
|
||||
game.lastEnterOpts = opts
|
||||
if game.failNextEnter then
|
||||
game.failNextEnter = false
|
||||
error("injected reconstruction failure")
|
||||
end
|
||||
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 = baseSave(), stack = stack, overworld = ow,
|
||||
data = {
|
||||
pokemon = { BULBASAUR = { dex = 1 } },
|
||||
moves = { TACKLE = { pp = 35 } },
|
||||
items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } },
|
||||
maps = {
|
||||
PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 },
|
||||
ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 },
|
||||
BROKEN = { id = "BROKEN", width = 10, height = 9 },
|
||||
},
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = ow
|
||||
return game, ow
|
||||
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_CHECKPOINTS = mod.checkpoints end
|
||||
]],
|
||||
}
|
||||
local game, ow = makeGame()
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader.game = game
|
||||
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
|
||||
local checkpoints = _G.MOD_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table",
|
||||
"Loader exposes mod.checkpoints through the public mod object")
|
||||
if type(checkpoints) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
local capability = checkpoints:inspect(game)
|
||||
T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" },
|
||||
"plain overworld control is a stable checkpoint boundary")
|
||||
|
||||
local function refused(mutator, expectedCode, message)
|
||||
local undo = mutator()
|
||||
local result = checkpoints:inspect(game)
|
||||
T.check(result.canCapture == false and result.reason == expectedCode, message)
|
||||
undo()
|
||||
end
|
||||
|
||||
refused(function()
|
||||
ow.transitioning = true
|
||||
return function() ow.transitioning = nil end
|
||||
end, "transition_busy", "transition frames are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
return function() ow.runner = { isRunning = function() return false end } end
|
||||
end, "script_busy", "foreground suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.parallelRunners = { { isRunning = function() return true end } }
|
||||
return function() ow.parallelRunners = {} end
|
||||
end, "script_busy", "parallel suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.pendingScripts = { { rows = {} } }
|
||||
return function() ow.pendingScripts = {} end
|
||||
end, "script_busy", "queued scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.scriptMoves = { { entity = ow.player } }
|
||||
return function() ow.scriptMoves = {} end
|
||||
end, "script_busy", "scripted movement is rejected")
|
||||
|
||||
refused(function()
|
||||
game.stack.states[2] = { screenId = "StartMenu" }
|
||||
return function() game.stack.states[2] = nil end
|
||||
end, "screen_busy", "modal screens over the overworld are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.emote = { frames = 1 }
|
||||
return function() ow.emote = nil end
|
||||
end, "animation_busy", "partial overworld animations are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.player.moving = true
|
||||
return function() ow.player.moving = nil end
|
||||
end, "movement_busy", "partial player movement is rejected")
|
||||
|
||||
local titleGame = { save = game.save, stack = {
|
||||
top = function() return { screenId = "TitleState" } end,
|
||||
} }
|
||||
local titleCapability = checkpoints:inspect(titleGame)
|
||||
T.check(titleCapability.canCapture == false
|
||||
and titleCapability.reason == "not_overworld",
|
||||
"title and non-playthrough runtime is rejected")
|
||||
|
||||
-- Capture synchronizes semantic position into a detached data-only record.
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8
|
||||
ow.player.facing, ow.player.surfing = "left", true
|
||||
local snapshot, code, message = checkpoints:capture(game)
|
||||
T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message))
|
||||
T.eq(snapshot.format, 1, "checkpoint format is explicit")
|
||||
T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit")
|
||||
T.same(snapshot.identity, {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = "play-a",
|
||||
},
|
||||
"checkpoint carries compatibility identity")
|
||||
T.same(snapshot.runtime.overworld,
|
||||
{ map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true },
|
||||
"checkpoint carries exact semantic overworld position")
|
||||
T.eq(snapshot.save.player.map, "ROUTE_1",
|
||||
"captured progress is synchronized from the live controller")
|
||||
T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind")
|
||||
T.same(snapshot.rng, { love = "overworld-rng-A" },
|
||||
"overworld checkpoint carries deterministic gameplay RNG")
|
||||
|
||||
local legacy = checkpoints:capture(game)
|
||||
legacy.rng = nil
|
||||
checkpointRngState = "legacy-runtime-rng"
|
||||
local legacyRestored, legacyCode = checkpoints:restore(game, legacy)
|
||||
T.check(legacyRestored == true,
|
||||
"legacy format-1 overworld checkpoint without RNG remains loadable: "
|
||||
.. tostring(legacyCode))
|
||||
T.eq(checkpointRngState, "legacy-runtime-rng",
|
||||
"legacy checkpoint leaves the current RNG stream untouched")
|
||||
checkpointRngState = "overworld-rng-A"
|
||||
|
||||
snapshot.save.money = 1
|
||||
snapshot.runtime.overworld.x = 1
|
||||
T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress")
|
||||
T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player")
|
||||
|
||||
-- Recapture the unmodified canonical A used for the differential roundtrip.
|
||||
snapshot = checkpoints:capture(game)
|
||||
local original = snapshot
|
||||
|
||||
game.save.money = 999999
|
||||
game.save.flags.GOT_STARTER = nil
|
||||
game.save.party[1].hp = 1
|
||||
game.save.inventory.POTION = 99
|
||||
game.save.pcItems.POTION = nil
|
||||
game.save.box = {}
|
||||
game.save.boxes = {}
|
||||
game.save.defeatedTrainers.PALLET_RIVAL = nil
|
||||
game.save.objectToggles.PALLET_TOWN.OAK = true
|
||||
game.save.itemsTaken.PALLET_TOWN_POTION = nil
|
||||
game.save.pokedex.seen.BULBASAUR = nil
|
||||
game.save.pokedex.owned.BULBASAUR = nil
|
||||
game.save.options.volume = 9
|
||||
checkpointRngState = "overworld-rng-B"
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3
|
||||
ow.player.facing, ow.player.surfing = "up", false
|
||||
|
||||
local restored, restoreCode, restoreMessage = checkpoints:restore(game, original)
|
||||
T.check(restored == true,
|
||||
"valid checkpoint restores: " .. tostring(restoreCode or restoreMessage))
|
||||
local recaptured = checkpoints:capture(game)
|
||||
T.same(recaptured, original,
|
||||
"capture A, mutate B, restore A, capture A2 yields normalized A == A2")
|
||||
T.eq(game.save.options.volume, 9,
|
||||
"checkpoint restoration preserves current global settings")
|
||||
T.eq(checkpointRngState, "overworld-rng-A",
|
||||
"overworld checkpoint restores gameplay RNG")
|
||||
T.eq(game.save.inventory.POTION, 1, "inventory progress roundtrips")
|
||||
T.eq(game.save.pcItems.POTION, 2, "PC item progress roundtrips")
|
||||
T.eq(game.save.box[1].hp, 16, "current box Pokemon roundtrips")
|
||||
T.eq(game.save.boxes[2][1].hp, 14, "stored box collection roundtrips")
|
||||
T.eq(game.save.defeatedTrainers.PALLET_RIVAL, true,
|
||||
"defeated trainer progress roundtrips")
|
||||
T.eq(game.save.objectToggles.PALLET_TOWN.OAK, false,
|
||||
"map object toggle progress roundtrips")
|
||||
T.eq(game.save.itemsTaken.PALLET_TOWN_POTION, true,
|
||||
"taken-object progress roundtrips")
|
||||
T.eq(game.save.pokedex.owned.BULBASAUR, true, "Pokedex progress roundtrips")
|
||||
T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true,
|
||||
"engine reconstruction is marked to suppress map-entry side effects")
|
||||
|
||||
-- Compatibility and schema failures occur before any mutation.
|
||||
local beforeRejected = checkpoints:capture(game)
|
||||
local wrongFormat = checkpoints:capture(game)
|
||||
wrongFormat.format = 99
|
||||
restored, restoreCode = checkpoints:restore(game, wrongFormat)
|
||||
T.check(not restored and restoreCode == "unsupported_format",
|
||||
"unknown checkpoint format is rejected")
|
||||
|
||||
local wrongGame = checkpoints:capture(game)
|
||||
wrongGame.identity.gameVersion = "blue"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongGame)
|
||||
T.check(not restored and restoreCode == "wrong_game",
|
||||
"another game version is rejected")
|
||||
|
||||
local wrongProfile = checkpoints:capture(game)
|
||||
wrongProfile.identity.playthroughId = "play-b"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongProfile)
|
||||
T.check(not restored and restoreCode == "wrong_playthrough",
|
||||
"another playthrough is rejected")
|
||||
|
||||
local badMap = checkpoints:capture(game)
|
||||
badMap.runtime.overworld.map = "MISSING_MAP"
|
||||
badMap.save.player.map = "MISSING_MAP"
|
||||
restored, restoreCode = checkpoints:restore(game, badMap)
|
||||
T.check(not restored and restoreCode == "invalid_map",
|
||||
"unknown content reference is rejected")
|
||||
T.same(checkpoints:capture(game), beforeRejected,
|
||||
"validation failures leave the live state unchanged")
|
||||
|
||||
local invalidGame = makeGame()
|
||||
local badSpecies = checkpoints:capture(invalidGame)
|
||||
badSpecies.save.party[1].species = "MISSING_SPECIES"
|
||||
restored, restoreCode = checkpoints:restore(invalidGame, badSpecies)
|
||||
T.check(not restored and restoreCode == "invalid_content",
|
||||
"unknown Pokemon content is rejected before reconstruction")
|
||||
T.eq(invalidGame.save.party[1].species, "BULBASAUR",
|
||||
"invalid Pokemon content leaves the live party unchanged")
|
||||
|
||||
-- A reconstruction exception rolls back to the exact pre-operation state.
|
||||
local target = checkpoints:capture(game)
|
||||
target.runtime.overworld.map = "BROKEN"
|
||||
target.runtime.overworld.x, target.runtime.overworld.y = 1, 1
|
||||
target.save.player.map = "BROKEN"
|
||||
target.save.player.x, target.save.player.y = 1, 1
|
||||
target.save.money = 42
|
||||
local beforeFailure = checkpoints:capture(game)
|
||||
game.failNextEnter = true
|
||||
restored, restoreCode = checkpoints:restore(game, target)
|
||||
T.check(not restored and restoreCode == "restore_failed",
|
||||
"reconstruction exception is returned as a structured failure")
|
||||
T.same(checkpoints:capture(game), beforeFailure,
|
||||
"failed reconstruction rolls back the complete pre-operation checkpoint")
|
||||
|
||||
-- The same public facade must carry a real battle checkpoint end to end. The
|
||||
-- engine-side fixture is deliberately constructed outside the probe mod; the
|
||||
-- mod sees and calls only mod.checkpoints.
|
||||
local function makeBattleGame()
|
||||
local data = Fixtures.fresh()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "public-battle-playthrough"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
|
||||
-- The tiny fixture registry intentionally omits several full-game defaults.
|
||||
-- Normalize those once, then place the save on its fixture map.
|
||||
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 battleGame
|
||||
local battleOw = {
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function battleOw: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 battleOw:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
|
||||
end
|
||||
function battleOw:restoreBattleContinuation(restoredBattle, origin)
|
||||
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
restoredBattle.onFinish = function() end
|
||||
return true
|
||||
end
|
||||
battleGame = setmetatable({
|
||||
data = data, save = save, stack = stack, overworld = battleOw,
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = battleOw
|
||||
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return battleGame, battle
|
||||
end
|
||||
|
||||
checkpointRngState = "public-battle-rng-A"
|
||||
local battleGame, liveBattle = makeBattleGame()
|
||||
T.same(checkpoints:inspect(battleGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "public mod.checkpoints reports a settled battle boundary")
|
||||
liveBattle.turnCount = 4
|
||||
liveBattle.player.stages.attack = 2
|
||||
local battleSnapshot, battleCaptureCode = checkpoints:capture(battleGame)
|
||||
T.check(battleSnapshot and battleSnapshot.kind == "battle",
|
||||
"public mod.checkpoints captures a data-only battle: "
|
||||
.. tostring(battleCaptureCode))
|
||||
if battleSnapshot then
|
||||
battleGame.save.money = 1
|
||||
liveBattle.turnCount = 99
|
||||
checkpointRngState = "public-battle-rng-B"
|
||||
local battleRestored, battleRestoreCode, battleRestoreMessage = checkpoints:restore(
|
||||
battleGame, battleSnapshot)
|
||||
T.check(battleRestored == true,
|
||||
"public mod.checkpoints reconstructs a battle: "
|
||||
.. tostring(battleRestoreCode) .. " / " .. tostring(battleRestoreMessage))
|
||||
local restoredBattle = battleGame.stack:top()
|
||||
T.eq(restoredBattle.turnCount, 4,
|
||||
"public battle reconstruction restores the exact turn")
|
||||
T.eq(restoredBattle.player.stages.attack, 2,
|
||||
"public battle reconstruction restores battler stages")
|
||||
T.eq(checkpointRngState, "public-battle-rng-A",
|
||||
"public battle reconstruction restores gameplay RNG")
|
||||
T.same(checkpoints:capture(battleGame), battleSnapshot,
|
||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,181 @@
|
||||
-- Public mod.storage contract: data-only transactions, namespace isolation,
|
||||
-- deterministic listing, recovery, and failure retention.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod storage")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function manifest(id)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0",')
|
||||
:format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}'
|
||||
end
|
||||
|
||||
local function memfs(files)
|
||||
local fs = { files = files, failTmp = false, failMain = false }
|
||||
|
||||
function fs.read(path) return files[path] end
|
||||
function fs.write(path, body)
|
||||
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
|
||||
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
|
||||
files[path] = body
|
||||
return true
|
||||
end
|
||||
function fs.remove(path) files[path] = nil return true end
|
||||
function fs.createDirectory() return true end
|
||||
function fs.getInfo(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
|
||||
function fs.load(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end
|
||||
function fs.getDirectoryItems(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
|
||||
return fs
|
||||
end
|
||||
|
||||
local function game(version, playthroughId)
|
||||
return { save = {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {}, playthroughId = playthroughId },
|
||||
} }
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/alpha/manifest.json"] = manifest("alpha"),
|
||||
["mods/alpha/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
|
||||
]],
|
||||
["mods/beta/manifest.json"] = manifest("beta"),
|
||||
["mods/beta/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
local loader = Loader.new({ fs = fs })
|
||||
local current = game("red", "play-a")
|
||||
loader.game = current
|
||||
T.check(loader:load({}) == true, "storage fixture mods load")
|
||||
|
||||
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
|
||||
T.check(type(alpha) == "table" and type(beta) == "table",
|
||||
"Loader exposes mod.storage through the public mod object")
|
||||
if type(alpha) ~= "table" or type(beta) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
-- Removing scope identity or exposing a mutable private slot id breaks this.
|
||||
local context = alpha:context(current)
|
||||
T.same(context, {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = "play-a",
|
||||
}, "context exposes stable engine/game/playthrough compatibility identity")
|
||||
|
||||
-- Data-only write/read. The literal expected table is independent of storage.
|
||||
local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } }
|
||||
local ok, code, message = alpha:write(current, "states/quick/q1", payload)
|
||||
T.check(ok == true, "data-only payload writes: " .. tostring(code or message))
|
||||
local loaded = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "stored payload roundtrips as data")
|
||||
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
|
||||
"read returns decoded data rather than the caller's live table")
|
||||
|
||||
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
|
||||
T.check(not bad and badCode == "encode_failed",
|
||||
"functions are rejected with a stable data-only error")
|
||||
|
||||
local escaped, escapedCode = alpha:write(current, "../escape", {})
|
||||
T.check(not escaped and escapedCode == "invalid_key",
|
||||
"path traversal is rejected before persistence")
|
||||
|
||||
-- Logical enumeration is deterministic and prefix-scoped.
|
||||
T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
|
||||
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
|
||||
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
|
||||
local keys = alpha:list(current, "states/quick")
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
|
||||
"list returns sorted logical keys under the requested prefix")
|
||||
|
||||
-- Mod, playthrough, and game namespaces cannot observe each other.
|
||||
local missing, missingCode = beta:read(current, "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's payload")
|
||||
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the payload")
|
||||
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the payload")
|
||||
|
||||
-- Find the implementation-owned file only to inject corruption; assertions stay
|
||||
-- on public read behavior, not the path shape.
|
||||
local function mainFor(fragment)
|
||||
for path in pairs(files) do
|
||||
if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end
|
||||
end
|
||||
end
|
||||
|
||||
local q1Main = mainFor("q1")
|
||||
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
|
||||
files[q1Main] = "not a serialized table"
|
||||
loaded, code = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "corrupt main recovers the last verified payload")
|
||||
T.eq(code, nil, "successful recovery is a normal read")
|
||||
|
||||
-- A failed replacement cannot destroy the prior verified value.
|
||||
T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value")
|
||||
fs.failTmp = true
|
||||
ok, code = alpha:write(current, "replace", { version = 2 })
|
||||
fs.failTmp = false
|
||||
T.check(not ok and code == "write_failed", "staging failure is reported")
|
||||
T.same(alpha:read(current, "replace"), { version = 1 },
|
||||
"staging failure leaves the prior value readable")
|
||||
|
||||
-- Delete is exact and idempotent-not-found is explicit.
|
||||
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
|
||||
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
|
||||
T.check(alpha:delete(current, "delete/me") == true, "delete removes its target")
|
||||
missing, missingCode = alpha:read(current, "delete/me")
|
||||
T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable")
|
||||
T.same(alpha:read(current, "delete/keep"), { yes = true },
|
||||
"delete leaves neighboring keys untouched")
|
||||
|
||||
-- No-mod parity: constructing/loading an empty loader creates no storage bytes.
|
||||
local emptyFiles, emptyFs = {}, nil
|
||||
emptyFs = memfs(emptyFiles)
|
||||
local emptyLoader = Loader.new({ fs = emptyFs })
|
||||
emptyLoader.game = current
|
||||
T.check(emptyLoader:load({}) == true, "no-mod loader still boots")
|
||||
T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
|
||||
T.finish()
|
||||
@@ -636,6 +636,52 @@ local packed = io.open(cleanPkg, "rb")
|
||||
check(packed ~= nil, "pack writes the package")
|
||||
if packed then packed:close() end
|
||||
|
||||
-- Reproducible-build callers pin the informational pack timestamp through the
|
||||
-- standard SOURCE_DATE_EPOCH contract. Two clean invocations over the same
|
||||
-- input must then produce identical archive bytes and metadata.
|
||||
local epoch = "1234567890"
|
||||
local envPrefix = isWindows
|
||||
and ('set "SOURCE_DATE_EPOCH=%s" && '):format(epoch)
|
||||
or ("SOURCE_DATE_EPOCH=%s "):format(epoch)
|
||||
local deterministicA = root .. "/declared-a.modpkg"
|
||||
local deterministicB = root .. "/declared-b.modpkg"
|
||||
out, code = run(envPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, deterministicA))
|
||||
check(code == 0, "SOURCE_DATE_EPOCH package A succeeds: " .. out)
|
||||
out, code = run(envPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, deterministicB))
|
||||
check(code == 0, "SOURCE_DATE_EPOCH package B succeeds: " .. out)
|
||||
local archiveA = assert(io.open(deterministicA, "rb"))
|
||||
local bytesA = archiveA:read("*a")
|
||||
archiveA:close()
|
||||
local archiveB = assert(io.open(deterministicB, "rb"))
|
||||
local bytesB = archiveB:read("*a")
|
||||
archiveB:close()
|
||||
check(bytesA == bytesB, "SOURCE_DATE_EPOCH makes package bytes reproducible")
|
||||
local inspectPack = root .. "/inspect_pack.py"
|
||||
write(inspectPack, [[
|
||||
import json, sys, zipfile
|
||||
with zipfile.ZipFile(sys.argv[1]) as archive:
|
||||
meta = json.loads(archive.read(".modkit/pack.json"))
|
||||
assert meta["packed_at"] == "2009-02-13T23:31:30Z", meta["packed_at"]
|
||||
]])
|
||||
out, code = run(("%s %q %q"):format(python, inspectPack, deterministicA))
|
||||
check(code == 0, "pack metadata honors SOURCE_DATE_EPOCH: " .. out)
|
||||
local invalidEpochPrefix = isWindows
|
||||
and 'set "SOURCE_DATE_EPOCH=not-a-time" && '
|
||||
or "SOURCE_DATE_EPOCH=not-a-time "
|
||||
local invalidEpochPkg = root .. "/declared-invalid-epoch.modpkg"
|
||||
out, code = run(invalidEpochPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, invalidEpochPkg))
|
||||
check(code == 2, "invalid SOURCE_DATE_EPOCH is a usage failure: " .. out)
|
||||
check(out:find("SOURCE_DATE_EPOCH", 1, true) ~= nil,
|
||||
"invalid source epoch names the failed contract")
|
||||
check(io.open(invalidEpochPkg, "rb") == nil,
|
||||
"invalid source epoch writes no package")
|
||||
|
||||
-- MK305 diffs shipped tables against the imported dataset; fake one under
|
||||
-- a scratch repo root so the check exercises the same on ROM-less machines
|
||||
local fake = root .. "/fakerepo"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Pokemon Yellow's Oak-speech show-off mon is the player's Pikachu, not
|
||||
-- Red/Blue's NIDORINO (engine/battle/core.asm BATTLE_TYPE_PIKACHU, the
|
||||
-- ProfOak demo; engine/movie/oak_speech/oak_speech.asm). The import
|
||||
-- manifest must carry field.oakSpeech.demoSpecies, and
|
||||
-- Data:applyVersionedFieldData repairs Yellow caches made before the
|
||||
-- manifest carried it (#915).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.field and Data.field.oakSpeech) then Data:load() end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local S = require("tests.harness").suite("parity Yellow Oak speech")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local oldVersion = GameVersion.get()
|
||||
local oldTrades = Data.field.trades
|
||||
local oldOldManBattle = Data.field.oldManBattle
|
||||
|
||||
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
|
||||
local manifest = manifestFile:read("*a")
|
||||
manifestFile:close()
|
||||
|
||||
check(manifest:find('"demoSpecies": "PIKACHU"') ~= nil,
|
||||
"Yellow manifest stamps field.oakSpeech.demoSpecies as PIKACHU")
|
||||
|
||||
-- a stale Yellow cache carries shrink frames but no demoSpecies
|
||||
local stale = { shrink1 = "assets/generated/intro/shrink1.png",
|
||||
shrink2 = "assets/generated/intro/shrink2.png" }
|
||||
local oldOakSpeech = Data.field.oakSpeech
|
||||
Data.field.oakSpeech = stale
|
||||
|
||||
GameVersion.set("yellow")
|
||||
Data:applyVersionedFieldData()
|
||||
eq(Data.field.oakSpeech.demoSpecies, "PIKACHU",
|
||||
"applyVersionedFieldData fills a stale Yellow cache with PIKACHU")
|
||||
|
||||
-- fill-if-absent: an importer that learns to stamp the key wins
|
||||
local preStamped = { demoSpecies = "RAICHU",
|
||||
shrink1 = "assets/generated/intro/shrink1.png" }
|
||||
Data.field.oakSpeech = preStamped
|
||||
Data:applyVersionedFieldData()
|
||||
eq(Data.field.oakSpeech.demoSpecies, "RAICHU",
|
||||
"applyVersionedFieldData leaves an already-stamped demoSpecies alone")
|
||||
|
||||
Data.field.oakSpeech = oldOakSpeech
|
||||
Data.field.trades = oldTrades
|
||||
Data.field.oldManBattle = oldOldManBattle
|
||||
GameVersion.set(oldVersion)
|
||||
|
||||
return S:finish()
|
||||
Reference in New Issue
Block a user