mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
-- Trainer battles may use a battle-local view of save-party records without
|
||||
-- mutating, reordering, or hiding those records in the authoritative save.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("trainer battle party scope")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
Data.items.POTION = { id = "POTION", index = 99, name = "POTION",
|
||||
price = 300, tossable = true }
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 10),
|
||||
Pokemon.new(Data, "FIXMON_B", 11),
|
||||
Pokemon.new(Data, "FIXMON_C", 12),
|
||||
}
|
||||
local stack = { states = {} }
|
||||
function stack:push(value) self.states[#self.states + 1] = value end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return { data = Data, save = save, stack = stack }
|
||||
end
|
||||
|
||||
local game = makeGame()
|
||||
local originalParty = game.save.party
|
||||
local first, second, third = unpack(originalParty)
|
||||
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1, {
|
||||
playerPartyIndices = { 2, 3 },
|
||||
})
|
||||
T.check(game.save.party == originalParty,
|
||||
"scoping never replaces the authoritative save-party table")
|
||||
T.check(game.save.party[1] == first and game.save.party[2] == second
|
||||
and game.save.party[3] == third,
|
||||
"scoping never reorders authoritative save-party records")
|
||||
T.same(battle.playerPartyIndices, { 2, 3 },
|
||||
"the battle records normalized save-party indices")
|
||||
T.check(battle.playerParty[1] == second and battle.playerParty[2] == third,
|
||||
"the local party view contains the same selected Pokemon records")
|
||||
T.check(battle.player.mon == second,
|
||||
"initial send chooses the first healthy scoped member")
|
||||
|
||||
local menu = PartyMenu.new(game, { battle = battle })
|
||||
T.check(menu.party == battle.playerParty,
|
||||
"battle party menus traverse only the local eligible view")
|
||||
|
||||
Bag.add(game.save, "POTION", 1)
|
||||
local bag = BagMenu.new(game, { battle = battle })
|
||||
local potion
|
||||
for _, row in ipairs(bag.items) do
|
||||
if row.value == "POTION" then potion = row; break end
|
||||
end
|
||||
T.check(potion ~= nil, "the fixture potion is available for target selection")
|
||||
bag.onChoose(potion, bag)
|
||||
local targetPicker = game.stack:top()
|
||||
T.check(targetPicker and targetPicker.party == battle.playerParty,
|
||||
"in-battle item target selection traverses only eligible members")
|
||||
|
||||
second.hp = 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:playerMonFainted()
|
||||
T.eq(battle.result, nil,
|
||||
"a healthy scoped replacement prevents premature exhaustion")
|
||||
third.hp = 0
|
||||
battle:playerMonFainted()
|
||||
T.eq(battle.result, "lose",
|
||||
"an excluded healthy save-party member cannot prevent scoped exhaustion")
|
||||
T.check(first.hp > 0, "the excluded save-party member remains untouched")
|
||||
|
||||
local expGame = makeGame()
|
||||
expGame.save.inventory.EXP_ALL = 1
|
||||
local expBattle = BattleState.newTrainer(expGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
|
||||
local excludedExp = expGame.save.party[1].exp
|
||||
local participantExp = expGame.save.party[2].exp
|
||||
local sharedExp = expGame.save.party[3].exp
|
||||
expBattle.participants = { [expGame.save.party[2]] = true }
|
||||
expBattle:awardExp()
|
||||
T.eq(expGame.save.party[1].exp, excludedExp,
|
||||
"EXP.ALL cannot award an excluded save-party member")
|
||||
T.check(expGame.save.party[2].exp > participantExp,
|
||||
"a scoped participant receives battle experience")
|
||||
T.check(expGame.save.party[3].exp > sharedExp,
|
||||
"EXP.ALL traverses other eligible scoped members")
|
||||
|
||||
local fallbackGame = makeGame()
|
||||
local fallback = BattleState.newTrainer(fallbackGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 0, 99, 1.5, 0 } })
|
||||
T.eq(fallback.playerParty, nil,
|
||||
"a malformed or empty scope degrades to the vanilla full-party path")
|
||||
T.eq(fallback.player.mon, fallbackGame.save.party[1],
|
||||
"invalid scope fallback preserves vanilla initial send")
|
||||
|
||||
local partialGame = makeGame()
|
||||
local partial = BattleState.newTrainer(partialGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 99 } })
|
||||
T.eq(partial.playerParty, nil,
|
||||
"one invalid member makes the entire scope fall back")
|
||||
|
||||
local duplicateGame = makeGame()
|
||||
local duplicate = BattleState.newTrainer(duplicateGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 2 } })
|
||||
T.eq(duplicate.playerParty, nil,
|
||||
"duplicate members make the entire scope fall back")
|
||||
|
||||
local malformedOptionsGame = makeGame()
|
||||
local malformedOptions = BattleState.newTrainer(malformedOptionsGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, 7)
|
||||
T.eq(malformedOptions.playerParty, nil,
|
||||
"a malformed options value degrades to the vanilla full-party path")
|
||||
|
||||
local linkGame = makeGame()
|
||||
local linkBattle = BattleState.newTrainer(linkGame,
|
||||
"OPP_FIX_YOUNGSTER", 1, { playerPartyIndices = { 2, 3 } })
|
||||
linkBattle.kind = "link"
|
||||
linkBattle.result = "guestWin"
|
||||
linkGame.save.party[2].hp, linkGame.save.party[3].hp = 0, 0
|
||||
linkBattle:finish()
|
||||
T.eq(linkBattle.result, "guestWin",
|
||||
"link spectator outcomes are not rewritten by trainer eligibility scope")
|
||||
|
||||
T.finish()
|
||||
@@ -13,6 +13,8 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
@@ -96,6 +98,89 @@ T.eq(rivalCount, 0, "rival classes play no encounter sting here")
|
||||
local _, seenCount = stingFor("OPP_LASS", true)
|
||||
T.eq(seenCount, 0, "self.engaging suppresses a second sting")
|
||||
|
||||
-- A deferred preparation may cancel instead of constructing a battle. For a
|
||||
-- sight trainer, that must leave a one-position latch: otherwise the still
|
||||
-- undefeated adjacent trainer sees the stationary player again next frame and
|
||||
-- immediately reopens the preparation screen.
|
||||
local oldEvents, oldHooks, oldErrors = Runtime.events, Runtime.hooks,
|
||||
Runtime.errors
|
||||
local cancelHooks = Hooks.new()
|
||||
Runtime.install(oldEvents, cancelHooks, oldErrors)
|
||||
cancelHooks:wrap("trainer.before_battle", function(_, _, _, continue)
|
||||
continue({ cancel = true })
|
||||
return true
|
||||
end, 0, "cancel_probe")
|
||||
local cancelNpc = { id = "npc#cancel", cellX = 0, cellY = -1,
|
||||
facing = "down", moving = false, def = { trainerClass = "OPP_LASS",
|
||||
trainerParty = 1, index = 1 } }
|
||||
fakeSelf.player = { cellX = 0, cellY = 0, moving = false }
|
||||
fakeSelf.map.id = "FIX_ROUTE"
|
||||
fakeSelf.engaging = false
|
||||
pushed, plays = {}, {}
|
||||
local completed = 0
|
||||
fakeSelf:engageTrainer(cancelNpc, function() completed = completed + 1 end)
|
||||
pushed[1].onDone()
|
||||
T.eq(completed, 1, "cancel completes the deferred encounter without a battle")
|
||||
T.same(fakeSelf.cancelledTrainerSight, {
|
||||
npcId = "npc#cancel", playerX = 0, playerY = 0,
|
||||
}, "cancel suppresses immediate sight re-entry at the current player cell")
|
||||
|
||||
local approaches = 0
|
||||
fakeSelf.npcs = { cancelNpc }
|
||||
fakeSelf.trainerDefeated = function() return false end
|
||||
fakeSelf.startTrainerApproach = function() approaches = approaches + 1 end
|
||||
fakeGame.stack.top = function() return fakeSelf end
|
||||
fakeGame.data.trainerHeader = function() return { range = 2 } end
|
||||
T.check(setUpvalue(OW.checkTrainerSight, "mapScripts", {
|
||||
talkScript = function() return nil end,
|
||||
}), "mapScripts upvalue on checkTrainerSight")
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(approaches, 0,
|
||||
"a cancelled adjacent trainer cannot reacquire the stationary player")
|
||||
fakeSelf.player.cellX = 1
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(fakeSelf.cancelledTrainerSight, nil,
|
||||
"moving one cell releases the cancelled sight latch")
|
||||
fakeSelf.player.cellX = 0
|
||||
fakeSelf:checkTrainerSight()
|
||||
T.eq(approaches, 1,
|
||||
"returning to the sight line permits a fresh trainer challenge")
|
||||
Runtime.install(oldEvents, oldHooks, oldErrors)
|
||||
|
||||
-- OverworldState is a singleton reused by StateStack. A title/load cycle must
|
||||
-- clear this volatile latch too, or CONTINUE at the same map and cell inherits
|
||||
-- the cancelled sight suppression from the previous session.
|
||||
local Camera = require("src.render.Camera")
|
||||
local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local oldCameraNew, oldCollisionLoad = Camera.new, Collision.load
|
||||
local oldEncounterLoad, oldRunnerNew = Encounter.load, ScriptRunner.new
|
||||
local oldGameModule = package.loaded["src.core.Game"]
|
||||
local oldScriptsModule = package.loaded["data.scripts.init"]
|
||||
Camera.new = function() return {} end
|
||||
Collision.load = function() end
|
||||
Encounter.load = function() end
|
||||
ScriptRunner.new = function() return {} end
|
||||
package.loaded["src.core.Game"] = {
|
||||
data = {}, save = { lastOutdoor = "FIX_ROUTE" },
|
||||
}
|
||||
package.loaded["data.scripts.init"] = {}
|
||||
local lifecycle = setmetatable({
|
||||
cancelledTrainerSight = {
|
||||
npcId = "FIX_ROUTE_obj_1", playerX = 0, playerY = 0,
|
||||
},
|
||||
setMap = function() end,
|
||||
refreshStandingOnWarp = function() end,
|
||||
}, { __index = OW })
|
||||
lifecycle:enter("FIX_ROUTE", 0, 0, "down", { via = "boot" })
|
||||
T.eq(lifecycle.cancelledTrainerSight, nil,
|
||||
"fresh overworld entry clears a cancelled trainer sight latch")
|
||||
Camera.new, Collision.load = oldCameraNew, oldCollisionLoad
|
||||
Encounter.load, ScriptRunner.new = oldEncounterLoad, oldRunnerNew
|
||||
package.loaded["src.core.Game"] = oldGameModule
|
||||
package.loaded["data.scripts.init"] = oldScriptsModule
|
||||
|
||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic
|
||||
else package.loaded["src.core.Music"] = nil end
|
||||
if realBattle ~= nil then package.loaded["src.battle.BattleState"] = realBattle
|
||||
|
||||
@@ -358,11 +358,13 @@ T.same(checkpoints:capture(game), beforeFailure,
|
||||
-- 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 function makeBattleGame(kind)
|
||||
local data = Fixtures.fresh()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "public-battle-playthrough"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20),
|
||||
Pokemon.new(data, "FIXMON_B", 19),
|
||||
Pokemon.new(data, "FIXMON_C", 18) }
|
||||
-- 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)
|
||||
@@ -387,7 +389,8 @@ local function makeBattleGame()
|
||||
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
|
||||
local expected = kind == "trainer" and "trainer_encounter" or "wild_encounter"
|
||||
if origin.kind ~= expected or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
restoredBattle.onFinish = function() end
|
||||
@@ -397,9 +400,19 @@ local function makeBattleGame()
|
||||
data = data, save = save, stack = stack, overworld = battleOw,
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = battleOw
|
||||
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(battleGame, "OPP_FIX_YOUNGSTER", 1, {
|
||||
playerPartyIndices = { 2, 3 },
|
||||
})
|
||||
else
|
||||
battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1 }
|
||||
or { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
@@ -437,6 +450,62 @@ if battleSnapshot then
|
||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||
end
|
||||
|
||||
checkpointRngState = "scoped-trainer-rng-A"
|
||||
local scopedGame, scopedBattle = makeBattleGame("trainer")
|
||||
local scopedSnapshot, scopedCaptureCode = checkpoints:capture(scopedGame)
|
||||
T.check(scopedSnapshot ~= nil,
|
||||
"public checkpoints capture a scoped trainer battle: "
|
||||
.. tostring(scopedCaptureCode))
|
||||
if scopedSnapshot then
|
||||
T.same(scopedSnapshot.runtime.battle.playerPartyIndices, { 2, 3 },
|
||||
"capture stores the battle-local save-party index scope")
|
||||
local restored, code, message = checkpoints:restore(scopedGame, scopedSnapshot)
|
||||
T.check(restored == true,
|
||||
"public checkpoints restore a scoped trainer battle: "
|
||||
.. tostring(code) .. " / " .. tostring(message))
|
||||
local scopedRestored = scopedGame.stack:top()
|
||||
T.same(scopedRestored.playerPartyIndices, { 2, 3 },
|
||||
"restore reconstructs the same ordered party scope")
|
||||
T.check(scopedRestored.playerParty[1] == scopedGame.save.party[2]
|
||||
and scopedRestored.playerParty[2] == scopedGame.save.party[3],
|
||||
"restored scope points at authoritative save-party records")
|
||||
|
||||
scopedSnapshot.runtime.battle.playerPartyIndices = nil
|
||||
local oldRestored, oldCode = checkpoints:restore(scopedGame, scopedSnapshot)
|
||||
T.check(oldRestored == true,
|
||||
"an old checkpoint without party scope remains compatible: "
|
||||
.. tostring(oldCode))
|
||||
T.eq(scopedGame.stack:top().playerParty, nil,
|
||||
"an old checkpoint restores the vanilla full-party view")
|
||||
end
|
||||
|
||||
local function scopedCheckpoint()
|
||||
local freshGame = makeBattleGame("trainer")
|
||||
local snapshot = assert(checkpoints:capture(freshGame))
|
||||
return freshGame, snapshot
|
||||
end
|
||||
|
||||
local excludedGame, excludedSnapshot = scopedCheckpoint()
|
||||
excludedSnapshot.runtime.battle.player.index = 1
|
||||
local excludedRestored, excludedCode = checkpoints:restore(excludedGame,
|
||||
excludedSnapshot)
|
||||
T.check(excludedRestored == false and excludedCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects an active battler outside the eligible view")
|
||||
|
||||
local malformedGame, malformedSnapshot = scopedCheckpoint()
|
||||
malformedSnapshot.runtime.battle.playerPartyIndices.extra = 3
|
||||
local malformedRestored, malformedCode = checkpoints:restore(malformedGame,
|
||||
malformedSnapshot)
|
||||
T.check(malformedRestored == false and malformedCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects non-array scope members instead of failing open")
|
||||
|
||||
local participantGame, participantSnapshot = scopedCheckpoint()
|
||||
participantSnapshot.runtime.battle.participants = { 1 }
|
||||
local participantRestored, participantCode = checkpoints:restore(
|
||||
participantGame, participantSnapshot)
|
||||
T.check(participantRestored == false and participantCode == "invalid_checkpoint",
|
||||
"a scoped checkpoint rejects excluded participant references")
|
||||
|
||||
-- The mod receives the normal public hook facade, never BattleState. START
|
||||
-- at the restored safe decision reaches its semantic auxiliary action without
|
||||
-- selecting a native command.
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 Storage = require("src.mods.Storage")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
@@ -22,7 +23,9 @@ local function memfs(files)
|
||||
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
|
||||
if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
|
||||
return false, "main denied"
|
||||
end
|
||||
files[path] = body
|
||||
return true
|
||||
end
|
||||
@@ -107,6 +110,50 @@ 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")
|
||||
|
||||
T.check(type(alpha.writeBytes) == "function"
|
||||
and type(alpha.readBytes) == "function",
|
||||
"mod.storage exposes opaque byte read/write methods")
|
||||
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
|
||||
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
|
||||
local binaryOk, binaryCode, binaryMessage =
|
||||
alpha:writeBytes(current, "states/quick/blob", binary)
|
||||
T.check(binaryOk == true,
|
||||
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
|
||||
local binaryLoaded, binaryReadCode =
|
||||
alpha:readBytes(current, "states/quick/blob")
|
||||
T.eq(binaryLoaded, binary,
|
||||
"opaque bytes round-trip without text or Lua decoding")
|
||||
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
|
||||
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
|
||||
"Lua-looking opaque bytes are never executed")
|
||||
|
||||
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
|
||||
T.check(emptyOk == true, "empty opaque byte payloads are valid")
|
||||
T.eq(alpha:readBytes(current, "binary/empty"), "",
|
||||
"empty opaque byte payloads round-trip")
|
||||
|
||||
local badBytes, badBytesCode =
|
||||
alpha:writeBytes(current, "binary/bad-type", { byte = true })
|
||||
T.check(not badBytes and badBytesCode == "invalid_bytes",
|
||||
"non-string opaque payloads are rejected")
|
||||
|
||||
local savedLimit = Storage.MAX_BYTES
|
||||
Storage.MAX_BYTES = 4
|
||||
local tooLarge, tooLargeCode =
|
||||
alpha:writeBytes(current, "binary/too-large", "12345")
|
||||
Storage.MAX_BYTES = savedLimit
|
||||
T.check(not tooLarge and tooLargeCode == "size_limit",
|
||||
"opaque payloads over the per-key limit are rejected")
|
||||
|
||||
local tableConflict, tableConflictCode =
|
||||
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
|
||||
T.check(not tableConflict and tableConflictCode == "type_conflict",
|
||||
"bytes cannot replace a table record without deletion")
|
||||
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
|
||||
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
|
||||
"table reads identify byte records as the wrong storage type")
|
||||
end
|
||||
|
||||
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")
|
||||
@@ -120,19 +167,33 @@ 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" },
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/blob",
|
||||
"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")
|
||||
if type(alpha.readBytes) == "function" then
|
||||
missing, missingCode = beta:readBytes(current, "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's opaque payload")
|
||||
end
|
||||
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")
|
||||
if type(alpha.readBytes) == "function" then
|
||||
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the opaque payload")
|
||||
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the opaque payload")
|
||||
end
|
||||
|
||||
-- Find the implementation-owned file only to inject corruption; assertions stay
|
||||
-- on public read behavior, not the path shape.
|
||||
@@ -142,6 +203,12 @@ local function mainFor(fragment)
|
||||
end
|
||||
end
|
||||
|
||||
local function byteMainFor(fragment)
|
||||
for path in pairs(files) do
|
||||
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" 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"
|
||||
@@ -158,6 +225,47 @@ 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")
|
||||
|
||||
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
|
||||
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
|
||||
"seed opaque recovery value")
|
||||
local recoverMain = byteMainFor("binary/recover")
|
||||
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
|
||||
files[recoverMain] = nil
|
||||
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
|
||||
"missing opaque main recovers the last verified backup")
|
||||
|
||||
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
|
||||
"seed opaque replacement value")
|
||||
fs.failTmp = true
|
||||
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
|
||||
fs.failTmp = false
|
||||
T.check(not ok and code == "write_failed",
|
||||
"opaque staging failure is reported")
|
||||
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
|
||||
"opaque staging failure leaves the prior value readable")
|
||||
|
||||
fs.failMain = true
|
||||
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
|
||||
fs.failMain = false
|
||||
T.check(not ok and code == "write_failed",
|
||||
"opaque replacement failure is reported")
|
||||
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
|
||||
"opaque replacement failure leaves the prior value readable")
|
||||
|
||||
local byteConflict, byteConflictCode =
|
||||
alpha:write(current, "binary/replace", { version = 3 })
|
||||
T.check(not byteConflict and byteConflictCode == "type_conflict",
|
||||
"tables cannot replace a byte record without deletion")
|
||||
|
||||
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
|
||||
"seed opaque delete target")
|
||||
T.check(alpha:delete(current, "binary/delete") == true,
|
||||
"delete removes an opaque record")
|
||||
missing, missingCode = alpha:readBytes(current, "binary/delete")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"deleted opaque key is unavailable")
|
||||
end
|
||||
|
||||
-- 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")
|
||||
|
||||
@@ -135,6 +135,17 @@ if type(storage) == "table" then
|
||||
"title binding supports safe same-namespace durable operations")
|
||||
T.same(selected:read("history/title-operation"), { allowed = true },
|
||||
"title durable operation remains scoped to the selected playthrough")
|
||||
T.check(type(selected.writeBytes) == "function"
|
||||
and type(selected.readBytes) == "function",
|
||||
"selected storage exposes opaque byte methods")
|
||||
if type(selected.writeBytes) == "function"
|
||||
and type(selected.readBytes) == "function" then
|
||||
local titleBytes = "TITLE\0\255-cache"
|
||||
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
|
||||
"title binding writes opaque bytes in the selected namespace")
|
||||
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
|
||||
"title binding reads opaque bytes in the selected namespace")
|
||||
end
|
||||
end
|
||||
T.check(title.save.meta.playthroughId == nil,
|
||||
"opening title history never allocates or adopts a playthrough identity")
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
-- A sandboxed mod can defer an ordinary trainer engagement and later resume
|
||||
-- it with a battle-local player-party scope, using only public mod surfaces.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/scope_probe/manifest.json"] = [[{
|
||||
"id": "scope_probe",
|
||||
"name": "Scope Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/scope_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.hooks:wrap("trainer.before_battle", function(next, game, context, continue)
|
||||
mod.exports.game = game
|
||||
mod.exports.context = context
|
||||
mod.exports.continue = continue
|
||||
return true
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
local vanillaCalls, vanillaOptions = 0
|
||||
OW.prepareTrainerBattle({ id = "game" }, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_1",
|
||||
}, function(options)
|
||||
vanillaCalls, vanillaOptions = vanillaCalls + 1, options
|
||||
end)
|
||||
T.eq(vanillaCalls, 1, "no mod starts the trainer battle exactly once")
|
||||
T.eq(vanillaOptions, nil, "no mod supplies no battle-local party scope")
|
||||
vanilla.release()
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/scope_probe" }, {
|
||||
fs = T.sdk.memfs(FIXTURE),
|
||||
})
|
||||
T.eq(#run.errors, 0,
|
||||
"the public preparation probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local game = { id = "live-game" }
|
||||
local calls, options = 0
|
||||
OW.prepareTrainerBattle(game, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, function(value)
|
||||
calls, options = calls + 1, value
|
||||
end)
|
||||
T.eq(calls, 0, "a claiming public hook defers battle construction")
|
||||
local out = run.loader.exports.scope_probe or {}
|
||||
T.check(out.game == game, "the hook receives the live game")
|
||||
T.same(out.context, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, "the hook receives data-only trainer identity context")
|
||||
T.eq(out.continue({ playerPartyIndices = { 2, 4 } }), true,
|
||||
"the retained continuation resumes the deferred battle")
|
||||
T.eq(calls, 1, "resume constructs the battle exactly once")
|
||||
T.same(options, { playerPartyIndices = { 2, 4 } },
|
||||
"ordered eligible indices cross the public seam unchanged")
|
||||
T.eq(out.continue({ playerPartyIndices = { 1 } }), false,
|
||||
"the continuation refuses a second invocation")
|
||||
T.eq(calls, 1, "a duplicate resume cannot start a second battle")
|
||||
run.release()
|
||||
|
||||
local cancelRun = T.sdk.loadMods({ "mods/scope_probe" }, {
|
||||
fs = T.sdk.memfs(FIXTURE),
|
||||
})
|
||||
local starts, cancels = 0, 0
|
||||
OW.prepareTrainerBattle(game, {
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 2,
|
||||
mapId = "FIX_ROUTE", npcId = "TRAINER_7",
|
||||
}, function()
|
||||
starts = starts + 1
|
||||
end, function()
|
||||
cancels = cancels + 1
|
||||
end)
|
||||
local cancelOut = cancelRun.loader.exports.scope_probe or {}
|
||||
T.eq(cancelOut.continue({ cancel = true }), true,
|
||||
"the retained continuation can cancel a deferred encounter")
|
||||
T.eq(starts, 0, "cancelling never constructs a trainer battle")
|
||||
T.eq(cancels, 1, "cancelling invokes the encounter's completion callback")
|
||||
T.eq(cancelOut.continue(), false,
|
||||
"a cancelled continuation remains one-shot")
|
||||
cancelRun.release()
|
||||
|
||||
T.finish("trainer_before_battle")
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Contextual bicycle and fishing actions share one public contract in both
|
||||
-- generations while each engine keeps ownership of its own field-item path.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness").suite("mod world field items")
|
||||
|
||||
local facingWater = false
|
||||
local redWorld = {
|
||||
isOverworld = true,
|
||||
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
|
||||
player = { moving = false, inputLocked = false, surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
scriptMoves = {},
|
||||
bikeAllowed = function() return true end,
|
||||
facingIsShoreOrWater = function() return facingWater end,
|
||||
useBicycle = function(self) self.bikeUsed = true return true end,
|
||||
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
|
||||
}
|
||||
local redGame = {
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { player = { name = "RED" }, party = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
overworld = redWorld,
|
||||
}
|
||||
function redGame.stack:top() return self.states[#self.states] end
|
||||
|
||||
local RedAPI = require("src.world.WorldAPI")
|
||||
local red = RedAPI.new(redGame, "fixture")
|
||||
local RedWorld = require("src.world.OverworldController")
|
||||
T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function",
|
||||
"Red keeps field-item execution in its world")
|
||||
local actions = red:availableFieldActions()
|
||||
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
|
||||
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
|
||||
T.check(redWorld.bikeUsed, "Red delegates to its world-owned bicycle path")
|
||||
|
||||
facingWater = true
|
||||
actions = red:availableFieldActions()
|
||||
T.eq(actions[2].rods[1].id, "OLD_ROD", "Red lists owned rods at water")
|
||||
T.check(red:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||
"Red accepts a listed rod")
|
||||
T.eq(redWorld.rodUsed, "OLD_ROD", "Red delegates to its fishing path")
|
||||
local used = redWorld.rodUsed
|
||||
local ok, err = red:useFieldAction("fish", { rod = "SUPER_ROD" })
|
||||
T.check(not ok and err == "fishing rod unavailable",
|
||||
"Red rejects an unowned rod")
|
||||
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
|
||||
|
||||
redWorld.player.moving = true
|
||||
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
|
||||
ok, err = red:useFieldAction("bicycle")
|
||||
T.check(not ok and err == "world is busy",
|
||||
"Red refuses a stale action while busy")
|
||||
|
||||
local goldWorld = {
|
||||
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
|
||||
player = {}, playerState = "normal",
|
||||
acceptsMenuInput = function() return true end,
|
||||
playerCollision = function() return 0x00 end,
|
||||
alwaysOnBike = function() return false end,
|
||||
fieldContext = function() return { facingColl = 0x20 } end,
|
||||
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
|
||||
}
|
||||
local goldGame = {
|
||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
world = goldWorld,
|
||||
}
|
||||
|
||||
local GoldAPI = require("src.world.gen2.WorldAPI")
|
||||
local gold = GoldAPI.new(goldGame, "fixture")
|
||||
actions = gold:availableFieldActions()
|
||||
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
|
||||
T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape")
|
||||
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||
"Gold accepts the same fishing request")
|
||||
T.eq(goldWorld.itemUsed, "OLD_ROD",
|
||||
"Gold delegates to its own field-item path")
|
||||
used = goldWorld.itemUsed
|
||||
ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
|
||||
T.check(not ok and err == "fishing rod unavailable",
|
||||
"Gold rejects an unowned rod")
|
||||
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
|
||||
|
||||
T.finish()
|
||||
Reference in New Issue
Block a user