mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #952 from MaxTomahawk/feat/mod-state-checkpoints
Add playthrough-scoped storage and stable overworld checkpoints
This commit is contained in:
@@ -140,6 +140,59 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||
records can use `mod.storage`; the engine scopes every logical key by game
|
||||
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||
standard or portable persistence backend as saves:
|
||||
|
||||
```lua
|
||||
local context, code, message = mod.storage:context(game)
|
||||
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||
})
|
||||
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
```lua
|
||||
local capability = mod.checkpoints:inspect(game)
|
||||
if capability.canCapture then
|
||||
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||
-- Store the detached data-only checkpoint through mod.storage.
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control only: the overworld must
|
||||
be topmost, the player stationary on a tile, and no transition, menu, script,
|
||||
queued script movement, or partial field animation may be active. Refusals carry
|
||||
a stable `reason` and readable `message`. Capture excludes global options and
|
||||
runtime objects. Restore validates format, game/playthrough identity, content,
|
||||
and coordinates before mutation; preserves current options; suppresses normal
|
||||
map-entry/save-load side effects; verifies a recapture; and rolls back in memory
|
||||
if reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
See RFC 0003 and RFC 0004 for exact contracts and error codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# RFC 0003 — Playthrough-scoped mod storage
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
|
||||
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
|
||||
save-slot and mod-save suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
`mod.save` intentionally lives inside the normal progress record. That is the
|
||||
right home for quest state, but not for independent tool data such as replay
|
||||
captures, checkpoint histories, or recovery records: writing it would require a
|
||||
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
|
||||
recursively embed the save that contains them.
|
||||
|
||||
Mods also cannot safely infer which launcher slot or portable filesystem backs
|
||||
the active playthrough. Direct filesystem access would expose private paths and
|
||||
make isolation dependent on engine implementation details.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the per-mod persistence contract documented in `docs/modding.md` and the
|
||||
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
|
||||
32-hex-character identity without consuming gameplay RNG. It is called only when
|
||||
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
|
||||
and ordinary load remain byte-compatible when no caller uses either API.
|
||||
|
||||
The id is stored in `save.meta.playthroughId` after allocation. Until the next
|
||||
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
|
||||
saves stable by game version and active launcher slot (or the legacy flat-save
|
||||
scope). A newly created playthrough never adopts the previous playthrough's
|
||||
mapping for that slot.
|
||||
|
||||
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
|
||||
implementation. It follows the same standard/portable backend as progress and
|
||||
honors injected test filesystems; it is not exposed on the mod object.
|
||||
|
||||
### `mod.storage:context(game)`
|
||||
|
||||
Returns:
|
||||
|
||||
```lua
|
||||
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
|
||||
```
|
||||
|
||||
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
|
||||
the context intentionally omits launcher slot ids and paths.
|
||||
|
||||
### `mod.storage:write(game, key, value)`
|
||||
|
||||
Accepts a data-only table and returns `true`, or
|
||||
`false, code, message`. Keys are nonempty slash-separated segments containing
|
||||
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
|
||||
`.`/`..`, and other characters are rejected.
|
||||
|
||||
The engine encodes deterministically, stages and decodes a `.tmp` witness,
|
||||
preserves the previous valid generation, writes and decodes the main record,
|
||||
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
|
||||
verified prior generation readable.
|
||||
|
||||
### `mod.storage:read(game, key)`
|
||||
|
||||
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
names one, or `nil, code, message`. Physical witness filenames are hidden.
|
||||
|
||||
### `mod.storage:delete(game, key)`
|
||||
|
||||
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
|
||||
`false, code, message`.
|
||||
|
||||
### Scope and errors
|
||||
|
||||
Physical records are scoped as:
|
||||
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
drop a newly inserted nested identity entry and produce undecodable bytes; save
|
||||
encoding is infrequent and I/O-bound, so interpreter execution is the safe
|
||||
boundary.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
|
||||
file; the existing save-slot and mod-save suites remain green.
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||
persistence/identity helpers.
|
||||
@@ -0,0 +1,131 @@
|
||||
# RFC 0004 — Stable runtime checkpoints for mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
|
||||
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
Mods can observe world events and request semantic actions, but no supported API
|
||||
can capture canonical progress at a proven-safe runtime boundary or reconstruct
|
||||
the overworld without replaying map-entry scripts. Reaching into the state stack,
|
||||
controller, ScriptRunner, or save restore internals would bind distributable mods
|
||||
to private objects and can duplicate story side effects.
|
||||
|
||||
The engine is the only component that can authoritatively decide whether the
|
||||
runtime is settled and rebuild its controller objects. A generic checkpoint seam
|
||||
lets tools store data-only records while keeping those responsibilities private.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
|
||||
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
|
||||
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
|
||||
internals inside a checkpoint.
|
||||
|
||||
### `mod.checkpoints:inspect(game)`
|
||||
|
||||
Returns a capability record. Stable overworld control returns:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "overworld" }
|
||||
```
|
||||
|
||||
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
|
||||
player-readable `message`. Format-1 supports only an overworld whose controller
|
||||
is topmost, player movement has settled on a tile, and no transition, foreground
|
||||
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
|
||||
teleport, field animation, or similar partial controller mutation is active.
|
||||
|
||||
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
|
||||
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
|
||||
Identity allocation is lazy and happens only after an active topmost overworld
|
||||
has been established.
|
||||
|
||||
### `mod.checkpoints:capture(game)`
|
||||
|
||||
Returns a detached data-only format-1 checkpoint, or
|
||||
`nil, code, message`:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = "...", gameVersion = "red", playthroughId = "...",
|
||||
},
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = { overworld = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6,
|
||||
facing = "down", surfing = false,
|
||||
} },
|
||||
}
|
||||
```
|
||||
|
||||
`engineVersion` is metadata for caller compatibility warnings; the engine does
|
||||
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
|
||||
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
|
||||
state. It excludes `save.options`, functions, userdata, threads, metatables as
|
||||
behavior, controller instances, and static content registries. Failure code
|
||||
`capture_failed` covers non-data progress and synchronization errors.
|
||||
|
||||
### `mod.checkpoints:restore(game, checkpoint)`
|
||||
|
||||
Returns `true`, or `false, code, message`. Before mutation it requires the current
|
||||
runtime to be capturable and validates a detached copy of the complete record:
|
||||
format, kind, internal identity consistency, current game/playthrough identity,
|
||||
map availability, integral in-bounds tile, facing, surfing, and synchronized save
|
||||
position.
|
||||
|
||||
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
|
||||
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
|
||||
`invalid_position`, in addition to the capability refusal reasons.
|
||||
|
||||
The canonical save validator runs against the detached record. Unlike ordinary
|
||||
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
|
||||
repair: any such content change returns `invalid_content` before live mutation.
|
||||
|
||||
The engine captures an in-memory rollback checkpoint, preserves current global
|
||||
options, then reconstructs semantic overworld state through
|
||||
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
|
||||
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
|
||||
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
|
||||
reconstruction, the engine recaptures and byte-compares normalized data. A failed
|
||||
apply rolls back and returns `restore_failed`; failure of that rollback returns
|
||||
`rollback_failed`.
|
||||
|
||||
Durable recovery remains a caller responsibility: in-memory rollback handles a
|
||||
runtime exception, not process termination.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
Format 1 intentionally rejects battles, menus, transitions, animations, and
|
||||
suspended/queued scripts. Future battle or explicit script-checkpoint kinds must
|
||||
have separate inventories, validation, reconstruction, deterministic RNG, and
|
||||
differential tests; they are not implied by this RFC.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** No existing hook, event, save, controller, or world action changes
|
||||
when `mod.checkpoints` is unused. The reconstruction path is called only by a
|
||||
successful public restore after validation.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
|
||||
green; ordinary New Game/save/load allocates no checkpoint identity.
|
||||
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
|
||||
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
|
||||
`A -> mutate B -> restore A -> recapture A2` equality across representative
|
||||
progress, settings preservation, compatibility rejection without mutation,
|
||||
map-side-effect suppression, and injected reconstruction rollback.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||
reconstruction route.
|
||||
@@ -0,0 +1,266 @@
|
||||
-- Public runtime checkpoint implementation. Loader exposes bound forwarding
|
||||
-- methods; mods never receive controller or state-stack internals from here.
|
||||
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Checkpoint = {}
|
||||
|
||||
Checkpoint.FORMAT = 1
|
||||
|
||||
local function refusal(kind, reason, message)
|
||||
return {
|
||||
canCapture = false,
|
||||
canRestore = false,
|
||||
kind = kind or "unknown",
|
||||
reason = reason,
|
||||
message = message,
|
||||
}
|
||||
end
|
||||
|
||||
local function running(runner)
|
||||
return runner and runner.isRunning and runner:isRunning()
|
||||
end
|
||||
|
||||
local function nonempty(value)
|
||||
return type(value) == "table" and next(value) ~= nil
|
||||
end
|
||||
|
||||
function Checkpoint.inspect(game)
|
||||
local save = game and game.save
|
||||
if type(save) ~= "table" or type(save.version) ~= "string" then
|
||||
return refusal("unknown", "not_in_playthrough",
|
||||
"A checkpoint requires an identified active playthrough.")
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
if type(ow) ~= "table" or type(ow.map) ~= "table"
|
||||
or type(ow.map.id) ~= "string" or type(ow.player) ~= "table" then
|
||||
return refusal("unknown", "not_overworld",
|
||||
"Only a settled overworld can be checkpointed.")
|
||||
end
|
||||
local top = game.stack and game.stack.top and game.stack:top()
|
||||
if top ~= ow then
|
||||
return refusal("overworld", "screen_busy",
|
||||
"Close the active menu or screen before creating a checkpoint.")
|
||||
end
|
||||
local identity = save.meta and save.meta.playthroughId
|
||||
if type(identity) ~= "string" or identity == "" then
|
||||
identity = SaveData.ensurePlaythroughId(save)
|
||||
end
|
||||
if type(identity) ~= "string" or identity == "" then
|
||||
return refusal("overworld", "not_in_playthrough",
|
||||
"The active playthrough could not be identified.")
|
||||
end
|
||||
if ow.transitioning then
|
||||
return refusal("overworld", "transition_busy",
|
||||
"Wait for the map transition to finish.")
|
||||
end
|
||||
if running(ow.runner) or nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves) then
|
||||
return refusal("overworld", "script_busy",
|
||||
"Wait for the active or queued script to finish.")
|
||||
end
|
||||
|
||||
local animationFields = {
|
||||
"engaging", "emote", "teleportOut", "dustAnim", "cutAnim", "fishPose",
|
||||
"pikaHop", "healAnim", "flyAnim", "flyArrive",
|
||||
}
|
||||
for _, field in ipairs(animationFields) do
|
||||
if ow[field] then
|
||||
return refusal("overworld", "animation_busy",
|
||||
"Wait for the overworld animation to finish.")
|
||||
end
|
||||
end
|
||||
if ow.player.moving or ow.player.targetX ~= nil or ow.player.targetY ~= nil then
|
||||
return refusal("overworld", "movement_busy",
|
||||
"Wait for movement to settle on a tile.")
|
||||
end
|
||||
return { canCapture = true, canRestore = true, kind = "overworld" }
|
||||
end
|
||||
|
||||
local function dataCopy(value)
|
||||
local ok, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not ok then return nil, tostring(encoded) end
|
||||
local decoded, err = SaveSerializer.decode(encoded)
|
||||
if not decoded then return nil, err end
|
||||
return decoded
|
||||
end
|
||||
|
||||
function Checkpoint.capture(game)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canCapture then
|
||||
return nil, capability.reason, capability.message
|
||||
end
|
||||
|
||||
local progress = {}
|
||||
for key, value in pairs(game.save) do
|
||||
if key ~= "options" then progress[key] = value end
|
||||
end
|
||||
progress = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Progress contains non-serializable runtime data."
|
||||
end
|
||||
|
||||
local ok, err = pcall(game.overworld.captureSave, game.overworld, progress)
|
||||
if not ok then
|
||||
return nil, "capture_failed", "Could not synchronize overworld progress: "
|
||||
.. tostring(err)
|
||||
end
|
||||
progress, err = dataCopy(progress)
|
||||
if not progress then
|
||||
return nil, "capture_failed", "Synchronized progress is not data-only: "
|
||||
.. tostring(err)
|
||||
end
|
||||
|
||||
local player = game.overworld.player
|
||||
return {
|
||||
format = Checkpoint.FORMAT,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = game.save.version,
|
||||
playthroughId = game.save.meta.playthroughId,
|
||||
},
|
||||
save = progress,
|
||||
runtime = { overworld = {
|
||||
map = game.overworld.map.id,
|
||||
x = player.cellX,
|
||||
y = player.cellY,
|
||||
facing = player.facing,
|
||||
surfing = player.surfing and true or false,
|
||||
} },
|
||||
}
|
||||
end
|
||||
|
||||
local FACINGS = { up = true, down = true, left = true, right = true }
|
||||
|
||||
local function validate(game, checkpoint)
|
||||
if type(checkpoint) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint root must be a table."
|
||||
end
|
||||
if checkpoint.format ~= Checkpoint.FORMAT then
|
||||
return nil, "unsupported_format", "This checkpoint format is not supported."
|
||||
end
|
||||
if checkpoint.kind ~= "overworld" then
|
||||
return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported."
|
||||
end
|
||||
|
||||
local copy, copyErr = dataCopy(checkpoint)
|
||||
if not copy then
|
||||
return nil, "invalid_checkpoint", "Checkpoint is not data-only: "
|
||||
.. tostring(copyErr)
|
||||
end
|
||||
local identity = copy.identity
|
||||
local current = game and game.save
|
||||
local currentId = current and current.meta and current.meta.playthroughId
|
||||
if type(identity) ~= "table" or type(identity.engineVersion) ~= "string"
|
||||
or type(identity.gameVersion) ~= "string"
|
||||
or type(identity.playthroughId) ~= "string" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint identity is missing or corrupt."
|
||||
end
|
||||
if identity.gameVersion ~= current.version then
|
||||
return nil, "wrong_game", "Checkpoint belongs to another game version."
|
||||
end
|
||||
if identity.playthroughId ~= currentId then
|
||||
return nil, "wrong_playthrough", "Checkpoint belongs to another playthrough."
|
||||
end
|
||||
|
||||
local save = copy.save
|
||||
local runtime = copy.runtime and copy.runtime.overworld
|
||||
if type(save) ~= "table" or type(save.player) ~= "table"
|
||||
or type(runtime) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress or runtime data is missing."
|
||||
end
|
||||
if save.version ~= identity.gameVersion
|
||||
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
|
||||
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
|
||||
end
|
||||
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
|
||||
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
|
||||
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
|
||||
return nil, "invalid_checkpoint", "Overworld position is missing or corrupt."
|
||||
end
|
||||
if save.player.map ~= runtime.map or save.player.x ~= runtime.x
|
||||
or save.player.y ~= runtime.y or save.player.facing ~= runtime.facing
|
||||
or (save.player.surfing and true or false) ~= runtime.surfing then
|
||||
return nil, "invalid_checkpoint", "Progress and runtime position disagree."
|
||||
end
|
||||
|
||||
local map = game.data and game.data.maps and game.data.maps[runtime.map]
|
||||
if type(map) ~= "table" then
|
||||
return nil, "invalid_map", "Checkpoint references a map that is unavailable."
|
||||
end
|
||||
local width, height = tonumber(map.width), tonumber(map.height)
|
||||
if not width or not height or runtime.x < 0 or runtime.y < 0
|
||||
or runtime.x >= width * 2 or runtime.y >= height * 2 then
|
||||
return nil, "invalid_position", "Checkpoint position is outside the map."
|
||||
end
|
||||
|
||||
-- A checkpoint is a strict restoration record, not an ordinary CONTINUE
|
||||
-- migration. Reuse the canonical save validator on the detached copy, but
|
||||
-- reject any quarantine, remap, reclaim, clamp, or content repair it would
|
||||
-- perform instead of silently changing the state the caller selected.
|
||||
local beforeContent = SaveSerializer.encode(copy.save)
|
||||
local validOk, report = pcall(SaveData.validate, copy.save, game.data)
|
||||
local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save)
|
||||
if not validOk or not afterOk or not SaveData.emptyReport(report)
|
||||
or afterContent ~= beforeContent then
|
||||
return nil, "invalid_content",
|
||||
"Checkpoint references unavailable or invalid game content."
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
local function apply(game, checkpoint, options)
|
||||
local save, err = dataCopy(checkpoint.save)
|
||||
if not save then error("checkpoint progress decode failed: " .. tostring(err), 0) end
|
||||
local runtime = checkpoint.runtime.overworld
|
||||
save.options = options
|
||||
save.player.map = runtime.map
|
||||
save.player.x = runtime.x
|
||||
save.player.y = runtime.y
|
||||
save.player.facing = runtime.facing
|
||||
save.player.surfing = runtime.surfing
|
||||
if type(game.restoreCheckpointSave) ~= "function" then
|
||||
error("game has no checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointSave(save)
|
||||
end
|
||||
|
||||
local function equalData(a, b)
|
||||
local okA, encodedA = pcall(SaveSerializer.encode, a)
|
||||
local okB, encodedB = pcall(SaveSerializer.encode, b)
|
||||
return okA and okB and encodedA == encodedB
|
||||
end
|
||||
|
||||
function Checkpoint.restore(game, checkpoint)
|
||||
local capability = Checkpoint.inspect(game)
|
||||
if not capability.canRestore then
|
||||
return false, capability.reason, capability.message
|
||||
end
|
||||
local validated, code, message = validate(game, checkpoint)
|
||||
if not validated then return false, code, message end
|
||||
|
||||
local rollback, captureCode, captureMessage = Checkpoint.capture(game)
|
||||
if not rollback then return false, captureCode, captureMessage end
|
||||
local options = game.save.options
|
||||
|
||||
local ok, err = pcall(apply, game, validated, options)
|
||||
if ok then
|
||||
local restored, verifyCode = Checkpoint.capture(game)
|
||||
if restored and equalData(restored, validated) then return true end
|
||||
err = "restored state did not match checkpoint: " .. tostring(verifyCode)
|
||||
end
|
||||
|
||||
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)
|
||||
if not rolledBack then
|
||||
return false, "rollback_failed",
|
||||
"Checkpoint restore and rollback both failed: " .. tostring(rollbackErr)
|
||||
end
|
||||
return false, "restore_failed", "Checkpoint restoration failed: " .. tostring(err)
|
||||
end
|
||||
|
||||
return Checkpoint
|
||||
@@ -1130,4 +1130,17 @@ function Game:restoreSave(loaded, recovered)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reconstruct a previously validated runtime checkpoint without replaying the
|
||||
-- ordinary CONTINUE lifecycle. In particular, map onEnter scripts and
|
||||
-- save.loading/save.loaded events must not run a second time. Validation,
|
||||
-- identity checks and transactional rollback live in Checkpoint.lua.
|
||||
function Game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self:adoptSave(loaded)
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, loaded.player.facing,
|
||||
{ via = "checkpoint", checkpoint = true })
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
+98
-3
@@ -216,6 +216,13 @@ local function persistFs(fs)
|
||||
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||
end
|
||||
|
||||
-- Engine-owned persistence routing for subsystems that must follow the same
|
||||
-- standard/portable root as saves without exposing raw filesystem access to a
|
||||
-- mod. An explicitly injected headless filesystem still wins for tests.
|
||||
function SaveData.persistenceFs(fs)
|
||||
return persistFs(fs)
|
||||
end
|
||||
|
||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||
-- from this table so old options.lua files stay compatible.
|
||||
function SaveData.defaultOptions()
|
||||
@@ -479,6 +486,10 @@ end
|
||||
-- working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
-- At most one New Game can be the live candidate for a first public tool
|
||||
-- request. A single strong reference models that runtime fact without adding
|
||||
-- marker data to the save or retaining abandoned playthrough tables.
|
||||
local freshPlaythrough
|
||||
|
||||
local function slotDir(version) return "saves/" .. version end
|
||||
|
||||
@@ -815,6 +826,77 @@ end
|
||||
function SaveData.resetSlotState()
|
||||
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
|
||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||
freshPlaythrough = nil
|
||||
end
|
||||
|
||||
-- ------- opaque playthrough identity
|
||||
|
||||
-- An id must never perturb the engine's gameplay RNG: savestate tools need
|
||||
-- repeatable random outcomes, and allocating persistence scope is not gameplay.
|
||||
-- Combine wall/process time, a process-local sequence and a fresh table address
|
||||
-- into four hex words. This is an opaque collision-resistant identifier, not a
|
||||
-- secret or a player-visible value.
|
||||
local playthroughSeq = 0
|
||||
|
||||
local function word(n)
|
||||
return math.floor(tonumber(n) or 0) % 4294967296
|
||||
end
|
||||
|
||||
function SaveData.newPlaythroughId()
|
||||
playthroughSeq = playthroughSeq + 1
|
||||
local address = tostring({}):match("0x(%x+)") or "0"
|
||||
local addressLo = tonumber(address:sub(-8), 16) or 0
|
||||
local clock = math.floor((os.clock() or 0) * 1000000)
|
||||
return ("%08x%08x%08x%08x"):format(
|
||||
word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
|
||||
end
|
||||
|
||||
local function playthroughScope(version, injectedFs)
|
||||
version = version or GameVersion.get()
|
||||
local fs = persistFs(injectedFs)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or "legacy"
|
||||
end
|
||||
|
||||
local function rememberPlaythroughId(save, opts, injectedFs)
|
||||
local meta = type(save) == "table" and save.meta
|
||||
local id = type(meta) == "table" and meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
opts = opts or SaveData.loadOptions(injectedFs)
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
opts.playthroughIds[version][scope] = id
|
||||
return opts, changed
|
||||
end
|
||||
|
||||
-- Return an existing save identity or give a pre-identity save a stable one.
|
||||
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
|
||||
-- into progress, so installing a tool mod never rewrites the player's checkpoint.
|
||||
function SaveData.ensurePlaythroughId(save, injectedFs)
|
||||
if type(save) ~= "table" then return nil end
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
local id = save.meta.playthroughId
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version, injectedFs)
|
||||
local opts = SaveData.loadOptions(injectedFs)
|
||||
local isFresh = save == freshPlaythrough
|
||||
if isFresh then freshPlaythrough = nil end
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
id = not isFresh and byVersion and byVersion[scope] or nil
|
||||
if type(id) ~= "string" or id == "" then
|
||||
id = SaveData.newPlaythroughId()
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
opts.playthroughIds[version][scope] = id
|
||||
SaveData.saveOptions(opts, injectedFs)
|
||||
end
|
||||
save.meta.playthroughId = id
|
||||
return id
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
@@ -838,6 +920,7 @@ function SaveData.buildMeta(mods, previous)
|
||||
format = Version.saveFormat,
|
||||
engine = Version.engine,
|
||||
savedAt = os.time(),
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
mods = list,
|
||||
}
|
||||
end
|
||||
@@ -1048,7 +1131,15 @@ function SaveData.save(data, mods)
|
||||
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
local opts = data.options
|
||||
if data.meta and data.meta.playthroughId then
|
||||
opts = rememberPlaythroughId(data, data.options)
|
||||
end
|
||||
data.options = opts
|
||||
SaveData.saveOptions(opts)
|
||||
elseif data.meta and data.meta.playthroughId then
|
||||
local opts, changed = rememberPlaythroughId(data)
|
||||
if changed then SaveData.saveOptions(opts) end
|
||||
end
|
||||
if mods ~= nil or data.meta == nil then
|
||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||
@@ -1470,8 +1561,12 @@ function SaveData.newGame(boot)
|
||||
options = SaveData.loadOptions(),
|
||||
}
|
||||
-- a total conversion reshapes the skeleton (spawn, party, money)
|
||||
-- before anything reads it; unhooked this returns save unchanged
|
||||
return Runtime.call("save.new_game", function(s) return s end, save)
|
||||
-- before anything reads it; unhooked this returns save unchanged. Keep the
|
||||
-- "fresh playthrough" marker outside the serialized table so a later tool
|
||||
-- request can distinguish two unsaved New Games sharing one vanilla slot.
|
||||
save = Runtime.call("save.new_game", function(s) return s end, save)
|
||||
freshPlaythrough = save
|
||||
return save
|
||||
end
|
||||
|
||||
return SaveData
|
||||
|
||||
@@ -41,6 +41,13 @@ local function serialize(v, indent)
|
||||
error("cannot serialize " .. t)
|
||||
end
|
||||
|
||||
-- LuaJIT 2.1 can lose a just-added nested table entry when a GC step lands
|
||||
-- inside a compiled recursive serialization trace. The symptom is valid input
|
||||
-- becoming `{" followed by only the trailing comma, which then cannot be read
|
||||
-- back. Save encoding is infrequent and I/O-bound, so keep this correctness-
|
||||
-- critical recursion in the interpreter while leaving the game JIT enabled.
|
||||
if jit and jit.off then jit.off(serialize, true) end
|
||||
|
||||
function SaveSerializer.encode(data)
|
||||
return "return " .. serialize(data) .. "\n"
|
||||
end
|
||||
|
||||
@@ -569,6 +569,9 @@ end
|
||||
function Loader:_api(mod)
|
||||
local loader = self
|
||||
local modId = mod.manifest.id
|
||||
local Storage = engineRequire("src.mods.Storage")
|
||||
local storage = Storage and Storage.new(modId, loader.fs)
|
||||
local Checkpoint = engineRequire("src.core.Checkpoint")
|
||||
local api = {
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
@@ -658,6 +661,25 @@ function Loader:_api(mod)
|
||||
bucket[key] = value
|
||||
end,
|
||||
},
|
||||
-- Data-only state independent of the vanilla progress checkpoint. The
|
||||
-- engine binds version/playthrough/mod scope and portable persistence;
|
||||
-- callers never receive paths or a raw filesystem handle.
|
||||
storage = {
|
||||
context = function(_, game) return storage:context(game) end,
|
||||
write = function(_, game, key, value) return storage:write(game, key, value) end,
|
||||
read = function(_, game, key) return storage:read(game, key) end,
|
||||
list = function(_, game, prefix) return storage:list(game, prefix) end,
|
||||
delete = function(_, game, key) return storage:delete(game, key) end,
|
||||
},
|
||||
-- Runtime safety and reconstruction stay engine-owned. Checkpoints contain
|
||||
-- data only; no controller, stack, coroutine or renderer object crosses out.
|
||||
checkpoints = {
|
||||
inspect = function(_, game) return Checkpoint.inspect(game) end,
|
||||
capture = function(_, game) return Checkpoint.capture(game) end,
|
||||
restore = function(_, game, checkpoint)
|
||||
return Checkpoint.restore(game, checkpoint)
|
||||
end,
|
||||
},
|
||||
options = {
|
||||
define = function(_, schema)
|
||||
assert(type(schema) == "table", "options schema must be a table of rows")
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- Data-only per-mod persistence, scoped by game version and opaque playthrough.
|
||||
-- This module is engine-private; Loader exposes only the bound facade methods.
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local Storage = {}
|
||||
Storage.__index = Storage
|
||||
|
||||
local ROOT = "mod_storage"
|
||||
|
||||
local function failure(code, message)
|
||||
return nil, code, message
|
||||
end
|
||||
|
||||
local function validSegment(value)
|
||||
return type(value) == "string" and value ~= ""
|
||||
and value:match("^[%w_-]+$") ~= nil
|
||||
end
|
||||
|
||||
local function validKey(key, allowEmpty)
|
||||
if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end
|
||||
if key == "" then return true end
|
||||
if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then
|
||||
return false
|
||||
end
|
||||
for segment in key:gmatch("[^/]+") do
|
||||
if not validSegment(segment) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ensureParent(fs, path)
|
||||
local dir = path:match("^(.*)/[^/]+$")
|
||||
if dir and fs.createDirectory then fs.createDirectory(dir) end
|
||||
end
|
||||
|
||||
local function remove(fs, path)
|
||||
if fs.remove then fs.remove(path) end
|
||||
end
|
||||
|
||||
local function decodeAt(fs, path)
|
||||
if not (fs.getInfo and fs.getInfo(path)) then return nil end
|
||||
local body = fs.read and fs.read(path)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local data = SaveSerializer.decode(body)
|
||||
if not data then return nil end
|
||||
return data, body
|
||||
end
|
||||
|
||||
function Storage.new(modId, fs)
|
||||
assert(validSegment(modId), "Storage.new needs a safe mod id")
|
||||
return setmetatable({ modId = modId, injectedFs = fs }, Storage)
|
||||
end
|
||||
|
||||
function Storage:_scope(game)
|
||||
local save = game and game.save
|
||||
local meta = save and save.meta
|
||||
local version = save and save.version
|
||||
if not (save and validSegment(version)) then
|
||||
return failure("not_in_playthrough",
|
||||
"Storage is available only inside an identified playthrough.")
|
||||
end
|
||||
local playthroughId = meta and meta.playthroughId
|
||||
if not validSegment(playthroughId) then
|
||||
playthroughId = SaveData.ensurePlaythroughId(save, self.injectedFs)
|
||||
end
|
||||
if not validSegment(playthroughId) then
|
||||
return failure("not_in_playthrough",
|
||||
"Storage could not identify the active playthrough.")
|
||||
end
|
||||
local fs = SaveData.persistenceFs(self.injectedFs)
|
||||
if not (fs and fs.read and fs.write and fs.getInfo) then
|
||||
return failure("storage_unavailable", "The persistence backend is unavailable.")
|
||||
end
|
||||
local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/")
|
||||
return { gameVersion = version, playthroughId = playthroughId,
|
||||
base = base, fs = fs }
|
||||
end
|
||||
|
||||
function Storage:context(game)
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
return {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = scope.gameVersion,
|
||||
playthroughId = scope.playthroughId,
|
||||
}
|
||||
end
|
||||
|
||||
function Storage:_names(game, key, allowEmpty)
|
||||
if not validKey(key, allowEmpty) then
|
||||
return failure("invalid_key",
|
||||
"Storage keys use nonempty letters, numbers, underscore, dash and slash segments.")
|
||||
end
|
||||
local scope, code, message = self:_scope(game)
|
||||
if not scope then return nil, code, message end
|
||||
local path = scope.base .. (key ~= "" and ("/" .. key) or "")
|
||||
return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp"
|
||||
end
|
||||
|
||||
function Storage:write(game, key, value)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
if type(value) ~= "table" then
|
||||
return false, "encode_failed", "Storage values must be data-only tables."
|
||||
end
|
||||
local encodedOk, encoded = pcall(SaveSerializer.encode, value)
|
||||
if not encodedOk then
|
||||
return false, "encode_failed", "Storage value is not serializable data: "
|
||||
.. tostring(encoded)
|
||||
end
|
||||
|
||||
local fs = scope.fs
|
||||
ensureParent(fs, main)
|
||||
local _, previous = decodeAt(fs, main)
|
||||
if not previous then _, previous = decodeAt(fs, bak) end
|
||||
|
||||
local ok, err = fs.write(tmp, encoded)
|
||||
if not ok then
|
||||
return false, "write_failed", "Could not stage storage data: " .. tostring(err)
|
||||
end
|
||||
local staged = decodeAt(fs, tmp)
|
||||
if not staged then
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Staged storage data could not be verified."
|
||||
end
|
||||
|
||||
if previous then fs.write(bak, previous) end
|
||||
ok, err = fs.write(main, encoded)
|
||||
if not ok then
|
||||
remove(fs, tmp)
|
||||
return false, "write_failed", "Could not replace storage data: " .. tostring(err)
|
||||
end
|
||||
local verified = decodeAt(fs, main)
|
||||
if not verified then
|
||||
remove(fs, main)
|
||||
remove(fs, tmp)
|
||||
return false, "verify_failed", "Replacement storage data could not be verified."
|
||||
end
|
||||
|
||||
-- At rest both main and backup hold the newest verified record. If a later
|
||||
-- write dies after rolling this copy aside, one verified generation remains.
|
||||
fs.write(bak, encoded)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Storage:read(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return nil, main, bak end
|
||||
local fs = scope.fs
|
||||
local data, body = decodeAt(fs, main)
|
||||
if data then return data end
|
||||
|
||||
data, body = decodeAt(fs, tmp)
|
||||
if not data then data, body = decodeAt(fs, bak) end
|
||||
if not data then
|
||||
return nil, "not_found", "No valid stored value exists for this key."
|
||||
end
|
||||
|
||||
-- Best-effort healing. The recovered copy remains in tmp/bak if promotion
|
||||
-- cannot land, so returning it is still safe and the next read can retry.
|
||||
ensureParent(fs, main)
|
||||
if fs.write(main, body) then fs.write(bak, body) end
|
||||
remove(fs, tmp)
|
||||
return data
|
||||
end
|
||||
|
||||
function Storage:list(game, prefix)
|
||||
prefix = prefix or ""
|
||||
local scope, main, codeOrBak = self:_names(game, prefix, true)
|
||||
if not scope then return nil, main, codeOrBak end
|
||||
local fs = scope.fs
|
||||
if not fs.getDirectoryItems then
|
||||
return nil, "storage_unavailable", "The persistence backend cannot enumerate keys."
|
||||
end
|
||||
|
||||
local base = scope.base
|
||||
local start = prefix == "" and base or (base .. "/" .. prefix)
|
||||
local out = {}
|
||||
|
||||
local function walk(path, logical)
|
||||
local info = fs.getInfo(path)
|
||||
if not info then return end
|
||||
if info.type == "file" then
|
||||
if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end
|
||||
return
|
||||
end
|
||||
for _, child in ipairs(fs.getDirectoryItems(path) or {}) do
|
||||
local childLogical = logical == "" and child or (logical .. "/" .. child)
|
||||
walk(path .. "/" .. child, childLogical)
|
||||
end
|
||||
end
|
||||
|
||||
-- A prefix may identify one exact key or a directory of keys.
|
||||
if fs.getInfo(start .. ".lua") then
|
||||
out[#out + 1] = prefix
|
||||
else
|
||||
walk(start, prefix)
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
function Storage:delete(game, key)
|
||||
local scope, main, bak, tmp = self:_names(game, key, false)
|
||||
if not scope then return false, main, bak end
|
||||
local fs = scope.fs
|
||||
if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then
|
||||
return false, "not_found", "No stored value exists for this key."
|
||||
end
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
return Storage
|
||||
@@ -206,7 +206,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
|
||||
return out
|
||||
end
|
||||
|
||||
function OverworldState:enter(mapId, x, y, facing)
|
||||
function OverworldState:enter(mapId, x, y, facing, opts)
|
||||
Game = require("src.core.Game")
|
||||
Game.overworld = self
|
||||
Collision.load(Game.data) -- tile-pair (elevation) collisions
|
||||
@@ -227,7 +227,7 @@ function OverworldState:enter(mapId, x, y, facing)
|
||||
-- survives save/load: a loaded game may start inside a building whose
|
||||
-- exit mat is a LAST_MAP warp
|
||||
self.lastOutdoor = Game.save.lastOutdoor
|
||||
self:setMap(mapId, x, y, facing, { via = "boot" })
|
||||
self:setMap(mapId, x, y, facing, opts or { via = "boot" })
|
||||
-- boot/load: derive the flag from the tile the save left us standing on,
|
||||
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
||||
-- door mat can still walk straight back out (issue #378)
|
||||
@@ -282,7 +282,7 @@ end
|
||||
|
||||
function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
local fromMapId = self.map and self.map.id
|
||||
if fromMapId then
|
||||
if fromMapId and not (opts and opts.checkpoint) then
|
||||
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
|
||||
end
|
||||
-- ambient choreography is per-map: parallel runners die here, and the
|
||||
@@ -460,13 +460,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- (home/overworld.asm) -- a warp can land directly on one (the Route
|
||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||
self:checkForcedMovement()
|
||||
if not (opts and opts.checkpoint) then self:checkForcedMovement() end
|
||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||
-- idle on those cells waiting for a player step.
|
||||
self:checkSeafoamCurrent()
|
||||
if not (opts and opts.checkpoint) then self:checkSeafoamCurrent() end
|
||||
|
||||
-- snap the camera immediately: the overworld doesn't update while a
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
@@ -476,27 +476,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
|
||||
-- fires before the onEnter chain so a listener sees the map in the same
|
||||
-- state the map script does
|
||||
if not (opts and opts.checkpoint) then
|
||||
Runtime.emit("map.entered", {
|
||||
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
||||
via = (opts and opts.via)
|
||||
or (opts and opts.seamless and "connection")
|
||||
or (fromMapId and "warp" or "boot"),
|
||||
})
|
||||
end
|
||||
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
||||
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
||||
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
||||
-- the player B-cancels the floor menu without .UpdateWarp.
|
||||
if not (opts and opts.checkpoint) then
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
end
|
||||
end
|
||||
|
||||
self:rebuildNeighbors()
|
||||
Logger.info("map: %s at (%d,%d)", mapId, x, y)
|
||||
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
||||
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
||||
self:syncLastMapRewrite()
|
||||
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
||||
end
|
||||
|
||||
-- Neighbor maps drawn at the composed connection offsets: at least the
|
||||
|
||||
@@ -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,305 @@
|
||||
-- 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 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 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 = {}, box = {}, boxes = {}, defeatedTrainers = {},
|
||||
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")
|
||||
|
||||
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.options.volume = 9
|
||||
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.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")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user