mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Merge remote-tracking branch 'origin/dev' into feat/worldapi-start-wild-battle
This commit is contained in:
@@ -383,6 +383,9 @@ jobs:
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: install Pillow
|
||||
run: python3 -m pip install --upgrade pillow
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
|
||||
+61
-4
@@ -140,6 +140,62 @@ 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 and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||
reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -221,10 +277,11 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
@@ -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,132 @@
|
||||
# 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
|
||||
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## 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,133 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
4. binds an engine-owned wild/trainer completion continuation;
|
||||
5. installs the battle directly at the settled menu without replaying its intro;
|
||||
6. restores the RNG after reconstruction has finished; and
|
||||
7. recaptures and compares the complete checkpoint.
|
||||
|
||||
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||
their original values.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
the new kind and RNG field are additive. Overworld-only callers may continue to
|
||||
filter `capability.kind`. No-mod behavior is unchanged when checkpoints are
|
||||
unused.
|
||||
|
||||
## Verification
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
raw RNG result after reload;
|
||||
- corrupt content/continuation rejection before mutation;
|
||||
- injected post-install failure with full runtime and RNG rollback;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
@@ -1003,4 +1003,33 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
static thread_local std::string event;
|
||||
event.clear();
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
|
||||
"()Ljava/lang/String;");
|
||||
if (!method)
|
||||
env->ExceptionClear();
|
||||
else
|
||||
{
|
||||
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (value)
|
||||
{
|
||||
const char *utf = env->GetStringUTFChars(value, nullptr);
|
||||
if (utf)
|
||||
{
|
||||
event = utf;
|
||||
env->ReleaseStringUTFChars(value, utf);
|
||||
}
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
env->DeleteLocalRef(activity);
|
||||
return event.empty() ? nullptr : event.c_str();
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -1325,6 +1325,9 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
@@ -1377,6 +1380,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
@@ -1395,6 +1399,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String pollSecondaryDisplayTouch() {
|
||||
synchronized (secondaryTouches) {
|
||||
return secondaryTouches.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1461,6 +1472,7 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int activePointer = -1;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1482,6 +1494,52 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
secondaryTouches.clear();
|
||||
secondaryTouches.addLast("cancel,0,0");
|
||||
} else {
|
||||
secondaryTouches.addLast(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int logicalX(float x) {
|
||||
return Math.min(fw - 1, Math.max(0,
|
||||
(int) ((x - dst.left) * fw / dst.width())));
|
||||
}
|
||||
|
||||
private int logicalY(float y) {
|
||||
return Math.min(fh - 1, Math.max(0,
|
||||
(int) ((y - dst.top) * fh / dst.height())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(android.view.MotionEvent event) {
|
||||
synchronized (lock) {
|
||||
int action = event.getActionMasked();
|
||||
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
|
||||
&& dst.contains((int) event.getX(), (int) event.getY())) {
|
||||
activePointer = event.getPointerId(0);
|
||||
enqueueTouch("down," + logicalX(event.getX()) + ","
|
||||
+ logicalY(event.getY()));
|
||||
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||
&& activePointer >= 0) {
|
||||
int index = event.findPointerIndex(activePointer);
|
||||
if (index >= 0 && fw > 0) {
|
||||
enqueueTouch("up," + logicalX(event.getX(index)) + ","
|
||||
+ logicalY(event.getY(index)));
|
||||
}
|
||||
activePointer = -1;
|
||||
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
activePointer = -1;
|
||||
enqueueTouch("cancel,0,0");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
|
||||
@@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
|
||||
// reported to the player as a broken cartridge.
|
||||
case "stadium":
|
||||
destName = "picked_stadium.z64"
|
||||
for ext in ["z64", "n64", "v64"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
case "rom", "":
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
|
||||
//
|
||||
// It used to fall through to picked_rom.gb, so a caller asking for a
|
||||
// kind this build had never heard of got its file deleted and
|
||||
// reported as a broken cartridge -- the worst possible answer to
|
||||
// "I do not know that one". Returning false lets the caller find out
|
||||
// and offer its own fallback.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
@@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject {
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Which kinds presentPicker understands, comma separated.
|
||||
//
|
||||
// So a CALLER can ask before it calls. A mod that wants a kind this build
|
||||
// predates cannot otherwise tell "refused" from "the picker would not
|
||||
// open", and guessing wrong used to cost the player their ROM (see the
|
||||
// default case above). Asking first turns that into a fallback the caller
|
||||
// chooses rather than a file it loses.
|
||||
//
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
|
||||
@@ -84,6 +84,49 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
// like a picker that would not open -- so a caller with a fallback worth
|
||||
// showing needs to know which it is facing. A mod that guesses instead has
|
||||
// no way back: before the refusal landed, an unrecognised kind wrote
|
||||
// picked_rom.gb and the ROM importer deleted it.
|
||||
//
|
||||
// nil where there is no bridge at all, which reads the same as "no kinds".
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
|
||||
// than Objective-C++, so no Foundation type may be NAMED here -- writing
|
||||
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
|
||||
// plain C entry point and `id` comes from objc/runtime.h, so the string
|
||||
// is asked for its UTF8 bytes without ever being typed.
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id kinds = ((GRObj)objc_msgSend)(cls,
|
||||
sel_registerName("supportedPickerKinds"));
|
||||
if (kinds == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
@@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L)
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# spanish_ui
|
||||
|
||||
A Espanol translation of the game.
|
||||
|
||||
Generated with `python3 tools/modkit.py translation spanish_ui`. See
|
||||
`TRANSLATING.md` for how to work on it.
|
||||
|
||||
## Status
|
||||
|
||||
Nothing is translated yet: 601 strings are waiting in `lang/`.
|
||||
|
||||
| Catalog | Entries |
|
||||
|---|---|
|
||||
| `lang/dialogue.lua` | 6 |
|
||||
| `lang/strings.lua` | 577 |
|
||||
| `lang/species_names.lua` | 3 |
|
||||
| `lang/move_names.lua` | 4 |
|
||||
| `lang/item_names.lua` | 5 |
|
||||
| `lang/trainer_names.lua` | 1 |
|
||||
| `lang/status_labels.lua` | 5 |
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` - identity and the engine version range
|
||||
- `main.lua` - registers whatever is filled in and skips whatever is not
|
||||
- `lang/` - the catalogs; this is the whole job
|
||||
- `assets/font/` - your glyph sheet
|
||||
@@ -0,0 +1,111 @@
|
||||
# Translating into Espanol
|
||||
|
||||
Everything the player can read is one of two kinds of string, and they live
|
||||
in different places for a reason.
|
||||
|
||||
| lang/ file | What it is | Key |
|
||||
|---|---|---|
|
||||
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
|
||||
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
|
||||
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
|
||||
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
|
||||
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
|
||||
| `naming.lua` | The letter grid for entering names | - |
|
||||
|
||||
Fill in a value and it takes effect. Leave it `""` and that string stays in
|
||||
English, so the game is playable at every point along the way.
|
||||
|
||||
## Where the English is
|
||||
|
||||
The catalogs hold keys and *your* text, never the original English. The
|
||||
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
|
||||
catalog:
|
||||
|
||||
```
|
||||
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
|
||||
```
|
||||
|
||||
That directory is deliberately outside the mod. Extracted script text and
|
||||
the vanilla names are ROM content, and `modkit pack` zips everything under
|
||||
the mod directory, so a worksheet kept inside would end up in your release
|
||||
whatever a `.gitignore` said. Keep it beside the mod, never in it.
|
||||
|
||||
`lang/strings.lua` is the exception: those sources are the engine's own Lua
|
||||
rather than anything out of the ROM, so there the key *is* the English and
|
||||
you can translate straight from it.
|
||||
|
||||
## Start with the font, not the text
|
||||
|
||||
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||
rather than swapped in:
|
||||
|
||||
```lua
|
||||
-- lang/font.lua
|
||||
return {
|
||||
spanish_ui = {
|
||||
image = "assets/font/spanish_ui.png",
|
||||
base = 0x100, -- first code this page owns
|
||||
glyphsPerRow = 16,
|
||||
-- advance = 8, -- set this if your glyphs are not 8px wide
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
|
||||
return {
|
||||
["A"] = 0x100,
|
||||
["B"] = 0x101,
|
||||
}
|
||||
```
|
||||
|
||||
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
|
||||
black on white like `assets/generated/font.png`. Codes run left to right,
|
||||
top to bottom from `base`.
|
||||
|
||||
Sequences are matched **longest first**, so a multi-byte character and a
|
||||
multi-character ligature both work and neither shadows the other:
|
||||
|
||||
```lua
|
||||
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
|
||||
["ch"] = 0x121, -- two ASCII letters, one glyph
|
||||
```
|
||||
|
||||
## Line length is counted in glyphs
|
||||
|
||||
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
|
||||
costs one column, and the engine will never cut a character in half. Your
|
||||
own `\n` line breaks are respected exactly as written, so break lines where
|
||||
they read best rather than where they fit English.
|
||||
|
||||
If your glyphs are not 8px wide, set `advance` on the page and the box
|
||||
re-measures.
|
||||
|
||||
## Format directives must survive
|
||||
|
||||
Some sources carry `%s` or `%d`:
|
||||
|
||||
```lua
|
||||
["Wild %s\nappeared!"] = "...",
|
||||
```
|
||||
|
||||
Keep every directive, in a count that matches. Word order is yours to
|
||||
change; the engine substitutes in the order the directives appear, so if
|
||||
your language needs the name last, write the sentence with the `%s` last.
|
||||
A translation whose directive count does not match the English is refused
|
||||
at runtime and the English is drawn instead, with a line in the log saying
|
||||
so - it will not crash a battle.
|
||||
|
||||
## Checking your work
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate spanish_ui --base imported
|
||||
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
|
||||
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
|
||||
```
|
||||
|
||||
`--refresh` rewrites the catalogs from the current engine, keeping every
|
||||
translation you have already written and reporting what changed. Run it
|
||||
after pulling a new engine version.
|
||||
@@ -0,0 +1,11 @@
|
||||
Put your glyph sheet here.
|
||||
|
||||
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||
run left to right and top to bottom starting at the page's `base`, so the
|
||||
first cell is `base`, the second `base + 1`, and so on.
|
||||
|
||||
`assets/generated/font.png` in the player's cache is the vanilla sheet at
|
||||
the same scale; open it alongside yours to match weight and baseline.
|
||||
|
||||
Declare the sheet in `lang/font.lua` and map sequences to codes in
|
||||
`lang/charmap.lua`.
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Which byte sequence draws which glyph code.
|
||||
--
|
||||
-- Sequences are matched longest-first, so a multi-byte character and a
|
||||
-- multi-character ligature both work: "ch" can be one glyph even though
|
||||
-- "c" is also mapped. Codes here must land inside a page declared in
|
||||
-- lang/font.lua.
|
||||
return {
|
||||
-- ["A"] = 0x100,
|
||||
-- ["B"] = 0x101,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Script text
|
||||
--
|
||||
-- Keyed by the original text label. The English is in the comment.
|
||||
|
||||
return {
|
||||
["_FixMartText"] = "",
|
||||
["_FixRouteTrainerAfterText"] = "",
|
||||
["_FixRouteTrainerBattleText"] = "",
|
||||
["_FixRouteTrainerEndText"] = "",
|
||||
["_FixTownGreeterText"] = "",
|
||||
["_FixTownSignText"] = "",
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Glyph pages this translation adds. Delete the entry if the vanilla
|
||||
-- alphabet already covers your language.
|
||||
--
|
||||
-- base is the first glyph code the page owns. 0x100 and up is free space
|
||||
-- above the vanilla $60/$80 pages, so this adds an alphabet rather than
|
||||
-- replacing one. Set `advance` if your glyphs are not 8px wide.
|
||||
return {
|
||||
-- spanish_ui = {
|
||||
-- image = "assets/font/spanish_ui.png",
|
||||
-- base = 0x100,
|
||||
-- glyphsPerRow = 16,
|
||||
-- },
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Item names
|
||||
--
|
||||
-- Item names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_BADGE_1"] = "",
|
||||
["FIX_BADGE_2"] = "",
|
||||
["FIX_BALL"] = "",
|
||||
["FIX_POTION"] = "",
|
||||
["FIX_TM"] = "",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Move names
|
||||
--
|
||||
-- Move names for Espanol.
|
||||
|
||||
return {
|
||||
["FIX_CUT"] = "",
|
||||
["FIX_EMBERISH"] = "",
|
||||
["FIX_SCRATCH"] = "",
|
||||
["FIX_TACKLE"] = "",
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
-- The naming screen's letter grid. Return an empty table to keep the
|
||||
-- English alphabet.
|
||||
--
|
||||
-- Each entry is a row of cells; a cell is whatever sequence your charmap
|
||||
-- maps, so a multi-byte character is one cell. The row holding a single
|
||||
-- "lower case" / "UPPER CASE" cell is the case switch, and the cell
|
||||
-- spelled "ED" is the confirm.
|
||||
--
|
||||
-- The screen is 160x144 and NamingScreen draws cell `c` of row `r` at
|
||||
-- (c * 16, 32 + r * 16), so the grid is capped at **9 columns and 6 rows**:
|
||||
-- a 10th column lands at x=160 and a 7th row at y=144, both off screen.
|
||||
-- That leaves 44 usable cells, exactly what vanilla uses, so Spanish
|
||||
-- letters have to displace something rather than being added.
|
||||
--
|
||||
-- What gives way is vanilla's `× ( ) : ; [ ]` row. Those are legal in a
|
||||
-- Gen-1 nickname but nobody reaches for them, whereas Ñ is not optional in
|
||||
-- Spanish -- and here it sits in its alphabetical place after N, which is
|
||||
-- where a Spanish speaker will look for it. Space, <PK> and <MN> are kept.
|
||||
--
|
||||
-- These glyphs exist in the Spanish cartridge's font ($CA Ñ, $BF Á, $C7 É,
|
||||
-- $C9 Í, $CC Ó, $CE Ú, $C2 Ü and their lowercase). On an English ROM they
|
||||
-- do not, so main.lua checks the running game's charmap first and keeps the
|
||||
-- English grid rather than drawing blank cells.
|
||||
return {
|
||||
upper = {
|
||||
{ "A", "B", "C", "D", "E", "F", "G", "H", "I" },
|
||||
{ "J", "K", "L", "M", "N", "Ñ", "O", "P", "Q" },
|
||||
{ "R", "S", "T", "U", "V", "W", "X", "Y", "Z" },
|
||||
{ "Á", "É", "Í", "Ó", "Ú", "Ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "lower case" },
|
||||
},
|
||||
lower = {
|
||||
{ "a", "b", "c", "d", "e", "f", "g", "h", "i" },
|
||||
{ "j", "k", "l", "m", "n", "ñ", "o", "p", "q" },
|
||||
{ "r", "s", "t", "u", "v", "w", "x", "y", "z" },
|
||||
{ "á", "é", "í", "ó", "ú", "ü", " ", "<PK>", "<MN>" },
|
||||
{ "-", "?", "!", "♂", "♀", "/", ".", ",", "ED" },
|
||||
{ "UPPER CASE" },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Species names
|
||||
--
|
||||
-- Species names for Espanol.
|
||||
|
||||
return {
|
||||
["FIXMON_A"] = "",
|
||||
["FIXMON_B"] = "",
|
||||
["FIXMON_C"] = "",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Status labels
|
||||
--
|
||||
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
|
||||
|
||||
return {
|
||||
["BRN"] = "",
|
||||
["FRZ"] = "",
|
||||
["PAR"] = "",
|
||||
["PSN"] = "",
|
||||
["SLP"] = "",
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
-- Engine text
|
||||
--
|
||||
-- Keyed by the English source, which is also what draws if you leave
|
||||
-- an entry empty. Keep any %s / %d directives.
|
||||
|
||||
return {
|
||||
["%s\nflew up high!"] = "¡%s\nvoló muy alto!",
|
||||
["%s\ndug a hole!"] = "¡%s\ncavó un hoyo!",
|
||||
["%s\nmade a whirlwind!"] = "¡%s\ncreó un torbellino!",
|
||||
["%s\ntook in sunlight!"] = "¡%s\nabsorbió luz!",
|
||||
["%s\nlowered its head!"] = "¡%s\nbajó la cabeza!",
|
||||
["%s\nis glowing!"] = "¡%s\nestá brillando!",
|
||||
["The hooked\n%s\nattacked!"] = "¡El %s\nenganchado atacó!",
|
||||
["Wild %s\nappeared!"] = "¡Un %s\nsalvaje apareció!",
|
||||
["%s wants\nto fight!"] = "¡%s\nquiere luchar!",
|
||||
["The GHOST\nappeared!"] = "¡Apareció el\nFANTASMA!",
|
||||
["Go! %s!"] = "¡Ve, %s!",
|
||||
["Do it! %s!"] = "¡Hazlo, %s!",
|
||||
["Get'm! %s!"] = "¡A por él, %s!",
|
||||
["The enemy's weak!\nGet'm! %s!"] = "¡Está débil!\n¡A por él, %s!",
|
||||
["%s is out of\nuseable POKéMON!"] = "¡%s no tiene\nPOKéMON útiles!",
|
||||
["%s blacked\nout!"] = "¡%s se\ndebilitó!",
|
||||
["%s sent\nout %s!"] = "¡%s envió\na %s!",
|
||||
["PA: You're out of\nSAFARI BALLs!\nGame over!"] = "AV: ¡No te quedan\nSAFARI BALLs!\n¡Fin del juego!",
|
||||
["%s is too\nscared to move!"] = "¡%s tiene\ndemasiado miedo!",
|
||||
["%s has no\nmoves left!"] = "¡%s no tiene\nmovimientos!",
|
||||
["The move is\ndisabled!"] = "¡El movimiento\nestá anulado!",
|
||||
["No PP left for\nthis move!"] = "¡No quedan PP para\neste movimiento!",
|
||||
["But, it failed!"] = "¡Pero falló!",
|
||||
["%s\nlearned\n%s!"] = "¡%s\naprendió\n%s!",
|
||||
["POKé BALL"] = "POKé BALL",
|
||||
["%s used\nPOKé BALL!"] = "¡%s usó\nPOKé BALL!",
|
||||
["All right!\n%s was\ncaught!"] = "¡Bien!\n¡%s fue\ncapturado!",
|
||||
["GHOST: Get out...\nGet out..."] = "FANTASMA: Fuera...\nFuera...",
|
||||
["%s with-\ndrew %s!"] = "¡%s retiró\na %s!",
|
||||
["%s\nmust recharge!"] = "¡%s debe\nrecargarse!",
|
||||
["%s\nis fast asleep!"] = "¡%s está\nprofundamente dormido!",
|
||||
["%s\nis confused!"] = "¡%s está\nconfuso!",
|
||||
["%s\nwoke up!"] = "¡%s se\ndespertó!",
|
||||
["%s\nis frozen solid!"] = "¡%s está\ncongelado!",
|
||||
["%s\ncan't move!"] = "¡%s no\npuede moverse!",
|
||||
["%s\nflinched!"] = "¡%s se\namedrentó!",
|
||||
["It hurt itself in\nits confusion!"] = "¡Se hirió a sí\nmismo por confusión!",
|
||||
["%s\nused %s!"] = "¡%s usó\n%s!",
|
||||
["%s\nis charging up!"] = "¡%s está\ncargando energía!",
|
||||
["%s's\nattack missed!"] = "¡El ataque de %s\nfalló!",
|
||||
["%s's\nattack continues!"] = "¡El ataque de %s\ncontinúa!",
|
||||
["%s\nis storing energy!"] = "¡%s está\nacumulando energía!",
|
||||
["%s\nunleashed energy!"] = "¡%s liberó\nsu energía!",
|
||||
["%s's\nSUBSTITUTE broke!"] = "¡El SUSTITUTO de\n%s se rompió!",
|
||||
["The SUBSTITUTE\ntook damage for\n%s!"] = "¡El SUSTITUTO\nrecibió el daño\nde %s!",
|
||||
["%s's\nRAGE is building!"] = "¡La FURIA de %s\nva creciendo!",
|
||||
["%s\nfainted!"] = "¡%s se\ndebilitó!",
|
||||
["%s gained\n%d EXP. Points!"] = "¡%s ganó\n%d P. EXP.!",
|
||||
["%s gained\nwith EXP.ALL,\v%d EXP. Points!"] = "¡%s ganó\ncon EXP.TODOS,\v%d P. EXP.!",
|
||||
["%s gained\na boosted\v%d EXP. Points!"] = "¡%s ganó\nun extra de\v%d P. EXP.!",
|
||||
["%s grew\nto level %d!"] = "¡%s subió\nal nivel %d!",
|
||||
["%s is\nabout to use"] = "%s va a usar",
|
||||
["%s!"] = "¡%s!",
|
||||
["Will %s\nchange POKéMON?"] = "¿%s va a\ncambiar de POKéMON?",
|
||||
["%s defeated\n%s!"] = "¡%s venció\na %s!",
|
||||
["%s got ¥%d\nfor winning!"] = "¡%s ganó\n¥%d!",
|
||||
["%s learned\n%s!"] = "¡%s aprendió\n%s!",
|
||||
["{RIVAL}: Yeah! Am\nI great or what?"] = "{RIVAL}: ¡Sí! ¿Soy\ngenial o qué?",
|
||||
["Use next POKéMON?"] = "¿Sacar al siguiente?",
|
||||
["Got away safely!"] = "¡Escapaste!",
|
||||
["Can't escape!"] = "¡No puedes escapar!",
|
||||
["There's no will\nto fight!"] = "¡No hay ganas de\nluchar!",
|
||||
["%s used\nSAFARI BALL!"] = "¡%s usó\nSAFARI BALL!",
|
||||
["%s threw some\nBAIT."] = "%s echó\nCEBO.",
|
||||
["%s threw a\nROCK."] = "%s tiró una\nPIEDRA.",
|
||||
["Wild %s\nis eating!"] = "¡El %s\nsalvaje come!",
|
||||
["Wild %s\nis angry!"] = "¡El %s\nsalvaje se enfadó!",
|
||||
["Wild %s\nran!"] = "¡El %s\nsalvaje huyó!",
|
||||
["No! There's no\nrunning from a\vtrainer battle!"] = "¡No! ¡No puedes\nhuir de un combate\vcontra un entrenador!",
|
||||
["You missed the\nPOKéMON!"] = "¡Fallaste el tiro!",
|
||||
["Darn! The POKéMON\nbroke free!"] = "¡Vaya! ¡El POKéMON\nse escapó!",
|
||||
["Aww! It appeared\nto be caught!"] = "¡Oh! ¡Parecía que\nestaba capturado!",
|
||||
["Shoot! It was so\nclose too!"] = "¡Vaya! ¡Estuvo\nmuy cerca!",
|
||||
["Do you want to\ngive a nickname\nto %s?"] = "¿Quieres poner un\nmote a\n%s?",
|
||||
["NICKNAME?"] = "MOTE?",
|
||||
["New POKéDEX data\nwill be added for\n%s!"] = "¡Se añadirán datos\nnuevos a la POKéDEX\nde %s!",
|
||||
["someone's PC"] = "el PC de alguien",
|
||||
["%s was\ntransferred to\n%s!"] = "¡%s fue\ntransferido a\n%s!",
|
||||
["But every BOX\nis full!"] = "¡Pero todas las\nCAJAS están llenas!",
|
||||
["%s used\n%s!"] = "¡%s usó\n%s!",
|
||||
["The trainer\nblocked the BALL!"] = "¡El entrenador\nbloqueó la BALL!",
|
||||
["Don't be a thief!"] = "¡No seas ladrón!",
|
||||
["It dodged the\nthrown BALL!"] = "¡Esquivó la BALL!",
|
||||
["This POKéMON\ncan't be caught!"] = "¡Este POKéMON no\nse puede capturar!",
|
||||
["%s is\nalready out!"] = "¡%s ya\nestá fuera!",
|
||||
["%s picked up\n¥%d!"] = "¡%s recogió\n¥%d!",
|
||||
["FIGHT"] = "LUCHAR",
|
||||
["ITEM"] = "OBJETO",
|
||||
["RUN"] = "HUIR",
|
||||
["BALLx"] = "BALLx",
|
||||
["BAIT"] = "CEBO",
|
||||
["THROW ROCK"] = "TIRAR PIEDRA",
|
||||
["disabled!"] = "¡anulado!",
|
||||
["TYPE/"] = "TIPO/",
|
||||
["It doesn't affect\n%s!"] = "¡No afecta a\n%s!",
|
||||
["Critical hit!"] = "¡Golpe crítico!",
|
||||
["One-hit KO!"] = "¡KO en un golpe!",
|
||||
["It's super\neffective!"] = "¡Es muy eficaz!",
|
||||
["It's not very\neffective..."] = "¡No es muy\neficaz...",
|
||||
["Hit the enemy\n%d times!"] = "¡Golpeó al enemigo\n%d veces!",
|
||||
["Hit %d times!"] = "¡Golpeó %d veces!",
|
||||
["%s's\nhit with recoil!"] = "¡%s sufrió\nel retroceso!",
|
||||
["%s is\nprotected by MIST!"] = "¡%s está\nprotegido por NIEBLA!",
|
||||
["Nothing happened!"] = "¡No pasó nada!",
|
||||
["%s's\n%s\ngreatly rose!"] = "¡%s\nmejoró mucho su\n%s!",
|
||||
["%s's\n%s rose!"] = "¡%s mejoró\nsu %s!",
|
||||
["%s's\n%s fell!"] = "¡%s bajó\nsu %s!",
|
||||
["%s's\n%s\ngreatly fell!"] = "¡%s\nbajó mucho su\n%s!",
|
||||
["Fire defrosted\n%s!"] = "¡El fuego descongeló\na %s!",
|
||||
["%s\nbecame confused!"] = "¡%s se\nconfundió!",
|
||||
["%s\nwas seeded!"] = "¡%s recibió\nla DRENADORA!",
|
||||
["%s\nstarted sleeping!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nregained health!"] = "¡%s recuperó\nsalud!",
|
||||
["%s's\nprotected against\nspecial attacks!"] = "¡%s está\nprotegido de los\nataques especiales!",
|
||||
["%s\ngained armor!"] = "¡%s ganó\narmadura!",
|
||||
["%s's\nshrouded in mist!"] = "¡%s se\ncubrió de niebla!",
|
||||
["%s's\ngetting pumped!"] = "¡%s se\nestá animando!",
|
||||
["All STATUS changes\nare eliminated!"] = "¡Los cambios de\nESTADO desaparecen!",
|
||||
["%s\nhas a SUBSTITUTE!"] = "¡%s tiene\nun SUSTITUTO!",
|
||||
["Too weak to make\na SUBSTITUTE!"] = "¡Muy débil para\nhacer un SUSTITUTO!",
|
||||
["It created a\nSUBSTITUTE!"] = "¡Creó un SUSTITUTO!",
|
||||
["Converted type to\n%s's!"] = "¡Cambió su tipo al\nde %s!",
|
||||
["%s\ntransformed into\n%s!"] = "¡%s se\ntransformó en\n%s!",
|
||||
["%s's\n%s was\ndisabled!"] = "¡El %s\nde %s\nfue anulado!",
|
||||
["No effect!"] = "¡Sin efecto!",
|
||||
["Sucked health from\n%s!"] = "¡Absorbió salud de\n%s!",
|
||||
["%s's\ndream was eaten!"] = "¡Devoró el sueño\nde %s!",
|
||||
["%s\nkept going and\ncrashed!"] = "¡%s siguió\nadelante y se\nestrelló!",
|
||||
["Coins scattered\neverywhere!"] = "¡Las monedas se\ndesparramaron!",
|
||||
["%s\nran away scared!"] = "¡%s huyó\nasustado!",
|
||||
["%s\nwas blown away!"] = "¡%s salió\nvolando!",
|
||||
["%s\nran from battle!"] = "¡%s huyó\ndel combate!",
|
||||
["It didn't affect\n%s!"] = "¡No afectó a\n%s!",
|
||||
["%s\nis unaffected!"] = "¡%s no se\nvio afectado!",
|
||||
["The MIRROR MOVE\nfailed!"] = "¡El MOVIMIENTO\nESPEJO falló!",
|
||||
["%s\nfell asleep!"] = "¡%s se\nquedó dormido!",
|
||||
["%s\nwas frozen solid!"] = "¡%s se\ncongeló!",
|
||||
["%s's\nhurt by poison!"] = "¡El veneno hiere a\n%s!",
|
||||
["%s's\nbadly poisoned!"] = "¡%s está\ngravemente envenenado!",
|
||||
["%s\nwas poisoned!"] = "¡%s fue\nenvenenado!",
|
||||
["%s's\nhurt by the burn!"] = "¡La quemadura hiere\na %s!",
|
||||
["%s\nwas burned!"] = "¡%s se\nquemó!",
|
||||
["%s's\nfully paralyzed!"] = "¡%s está\ntotalmente paralizado!",
|
||||
["%s's\nparalyzed! It may\nnot attack!"] = "¡%s está\nparalizado! ¡Puede\nque no ataque!",
|
||||
["%s's\ndisabled no more!"] = "¡%s ya no\nestá anulado!",
|
||||
["%s\nsnapped out of\nconfusion!"] = "¡%s salió\nde su confusión!",
|
||||
["LEECH SEED saps\n%s!"] = "¡La DRENADORA\nabsorbe a %s!",
|
||||
["%s\nwas afflicted\nby %s!"] = "¡%s sufre\n%s!",
|
||||
["%s's\nprotected against\nstat changes!"] = "¡%s está\nprotegido de los\ncambios de estado!",
|
||||
["What will"] = "¿Qué va a hacer",
|
||||
[" do?"] = "?",
|
||||
["You can't get off\nhere."] = "No puedes bajarte\naquí.",
|
||||
["%s got off\nthe BICYCLE."] = "%s se bajó\nde la BICICLETA.",
|
||||
["%s got on\nthe BICYCLE!"] = "¡%s se subió\na la BICICLETA!",
|
||||
["No cycling\nallowed here."] = "No se puede montar\naquí.",
|
||||
["No good! It's not\neven near water."] = "¡No sirve! No hay\nagua cerca.",
|
||||
["OAK: %s!\nThis isn't the\ntime to use that!"] = "OAK: ¡%s!\n¡No es momento\nde usar eso!",
|
||||
["The TOWN MAP is\nunreadable here."] = "El MAPA PUEBLO no\nse puede leer aquí.",
|
||||
["Yes! ITEMFINDER\nindicates there's\nan item nearby."] = "¡Sí! El BUSCAOBJ.\nindica que hay algo\ncerca.",
|
||||
["Nope! ITEMFINDER\nisn't responding."] = "¡No! El BUSCAOBJ.\nno responde.",
|
||||
["Booted up a TM!"] = "¡Se activó una MT!",
|
||||
["It contained\n%s!"] = "¡Contenía\n%s!",
|
||||
["USE"] = "USAR",
|
||||
["TOSS"] = "TIRAR",
|
||||
["That's too impor-\ntant to toss!"] = "¡Es demasiado\nimportante!",
|
||||
["Threw away\n%s."] = "Tiraste\n%s.",
|
||||
["PRESS A BUTTON"] = "PULSA UN BOTON",
|
||||
["ESC TO CANCEL"] = "ESC PARA CANCELAR",
|
||||
["%s :L%d"] = "%s :N%d",
|
||||
["STATS"] = "DATOS",
|
||||
["CANCEL"] = "CANCELAR",
|
||||
["What? There are\nno POKéMON here!"] = "¿Qué? ¡Aquí no hay\nningún POKéMON!",
|
||||
["You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."] = "No puedes llevar\nmás POKéMON.\fGuarda alguno\nprimero.",
|
||||
["BOX %d (WITHDRAW)"] = "CAJA %d (RETIRAR)",
|
||||
["%s is\ntaken out.\vGot %s."] = "Retirado\n%s.\vRecibes %s.",
|
||||
["You can't deposit\nthe last POKéMON!"] = "¡No puedes guardar\nel último POKéMON!",
|
||||
["Oops! This Box is\nfull of POKéMON."] = "¡Uups! Esta CAJA\nestá llena.",
|
||||
["You need at least\none POKéMON!"] = "¡Necesitas al menos\nun POKéMON!",
|
||||
["BOX %d is full!"] = "¡La CAJA %d está\nllena!",
|
||||
["%s was\nstored in Box %s."] = "%s se\nguardó en la CAJA %s.",
|
||||
["BOX %d (RELEASE)"] = "CAJA %d (SOLTAR)",
|
||||
["Once released,\n%s is\ngone forever. OK?"] = "Si lo sueltas,\n%s se\nirá para siempre. ¿OK?",
|
||||
["%s was\nreleased outside.\fBye %s!"] = "%s fue\nliberado.\f¡Adiós, %s!",
|
||||
["%sBOX %2d"] = "%sCAJA %2d",
|
||||
["When you change a\nPOKéMON BOX, data\nwill be saved. OK?"] = "Al cambiar de CAJA\nse guardarán los\ndatos. ¿OK?",
|
||||
["What?"] = "¿Qué?",
|
||||
["BOX No."] = "CAJA No.",
|
||||
["BOX No.%d"] = "CAJA No.%d",
|
||||
["Empty."] = "Vacía.",
|
||||
[":L%d No.%03d"] = ":N%d No.%03d",
|
||||
["Printed BOX %d!\fSaved as\n%s\vin the save\nfolder."] = "¡CAJA %d impresa!\fGuardada como\n%s\ven la carpeta de\nguardado.",
|
||||
["Printer error!\n%s"] = "¡Error de impresión!\n%s",
|
||||
["WITHDRAW <PK><MN>"] = "RETIRAR <PK><MN>",
|
||||
["DEPOSIT <PK><MN>"] = "GUARDAR <PK><MN>",
|
||||
["RELEASE <PK><MN>"] = "SOLTAR <PK><MN>",
|
||||
["CHANGE BOX"] = "CAMBIAR CAJA",
|
||||
["PRINT BOX"] = "IMPRIMIR CAJA",
|
||||
["SEE YA!"] = "HASTA LUEGO!",
|
||||
["YES"] = "SI",
|
||||
["NO"] = "NO",
|
||||
["GAME FREAK"] = "",
|
||||
["Nintendo"] = "",
|
||||
["Creatures inc."] = "",
|
||||
["GAME FREAK inc."] = "",
|
||||
["T H E E N D"] = "F I N",
|
||||
["HT %d′%02d″"] = "AL %d′%02d″",
|
||||
["WT %.1flb"] = "PE %.1flb",
|
||||
["Data unknown."] = "Datos desconocidos.",
|
||||
["<Diploma>"] = "<Diploma>",
|
||||
["Player"] = "Jugador",
|
||||
["Huh? %s\nstopped evolving!"] = "¿Eh? ¡%s\ndejó de evolucionar!",
|
||||
["Congratulations!\nYour %s\nevolved into\n%s!"] = "¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["evolving!"] = "evolucionando",
|
||||
["POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"] = "POKéDEX Vistos:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Capturados:{NUM:wDexRatingNumMonsOwned, 1, 3}",
|
||||
["POKéDEX Rating{COLON}"] = "Nota POKéDEX{COLON}",
|
||||
["Keep it up!"] = "¡Sigue así!",
|
||||
["LEVEL/"] = "NIVEL/",
|
||||
["TYPE1/"] = "TIPO1/",
|
||||
["TYPE2/"] = "TIPO2/",
|
||||
["HALL OF FAME"] = "SALON DE LA FAMA",
|
||||
["PLAY TIME"] = "TIEMPO",
|
||||
["MONEY"] = "DINERO",
|
||||
["bois club games"] = "bois club games",
|
||||
["GENGAR VS NIDORINO"] = "",
|
||||
["bois club"] = "bois club",
|
||||
["Nothing here."] = "Aquí no hay nada.",
|
||||
["%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f"] = "¡%s está\nintentando aprender\v%s!\f¡Pero %s\nno puede aprender\vmás de 4!\f",
|
||||
["Delete an older\nmove to make room\vfor %s?"] = "¿Borrar un movi-\nmiento antiguo para\vaprender %s?",
|
||||
["HM techniques\ncan't be deleted!"] = "¡Los movimientos MO\nno se pueden borrar!",
|
||||
["Abandon learning\n%s?"] = "¿Dejar de aprender\n%s?",
|
||||
["1, 2 and... Poof!\f%s forgot\n%s!\fAnd...\f%s learned\n%s!"] = "¡1, 2 y... plaf!\f¡%s olvidó\n%s!\f¡Y...\f%s aprendió\n%s!",
|
||||
["%s\ndid not learn\v%s!"] = "¡%s no\naprendió\v%s!",
|
||||
["Which move should"] = "¿Qué movimiento",
|
||||
["be forgotten?"] = "hay que olvidar?",
|
||||
["YOUR NAME?"] = "TU NOMBRE?",
|
||||
["NEW NAME"] = "NUEVO NOMBRE",
|
||||
["Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"] = "¡Hola!\n¡Bienvenido al\vmundo POKéMON!\fMe llamo OAK.\nMe llaman el\vPROF. POKéMON.",
|
||||
["This world is\ninhabited by\vcreatures called\vPOKéMON!"] = "¡Este mundo está\nhabitado por unas\vcriaturas llamadas\vPOKéMON!",
|
||||
["\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."] = "\fPara algunos, los\nPOKéMON son masco-\vtas. Otros luchan\vcon ellos.\fYo...\fEstudio los POKéMON\ncomo profesión.",
|
||||
["{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"] = "¡{PLAYER}!\f¡Tu propia leyenda\nPOKéMON está a\vpunto de comenzar!\f¡Un mundo de sueños\ny aventuras con\vPOKéMON te espera!\v¡Vamos!",
|
||||
["First, what is\nyour name?"] = "¿Cómo te llamas?",
|
||||
["This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"] = "Este es mi nieto.\nHa sido tu rival\vdesde que erais\vbebés.\f...Mmm, ¿cómo se\nllamaba?",
|
||||
["HIS NAME?"] = "SU NOMBRE?",
|
||||
["_OakSpeechText2A"] = "",
|
||||
["TEXT SPEED"] = "VEL TEXTO",
|
||||
["BATTLE ANIMATION"] = "ANIMACIONES",
|
||||
["OFF"] = "NO",
|
||||
["ON"] = "SI",
|
||||
["BATTLE STYLE"] = "ESTILO COMBATE",
|
||||
["SET"] = "FIJO",
|
||||
["SHIFT"] = "CAMBIO",
|
||||
["BATTLE LAYOUT"] = "DISENO COMBATE",
|
||||
["WIDE"] = "ANCHO",
|
||||
["OG"] = "OG",
|
||||
["RULESET"] = "REGLAS",
|
||||
["MUSIC VOL"] = "VOL MUSICA",
|
||||
["SFX VOL"] = "VOL SONIDO",
|
||||
["PIKACHU VOL"] = "VOL PIKACHU",
|
||||
["MUSIC FILTER"] = "FILTRO MUSICA",
|
||||
["COLORS"] = "COLORES",
|
||||
["TILT"] = "INCLINACION",
|
||||
["GBC FX"] = "EFECTO GBC",
|
||||
["ZOOM"] = "ZOOM",
|
||||
["VOID FILL"] = "RELLENO VACIO",
|
||||
["VIDEO MODE"] = "MODO VIDEO",
|
||||
["MAX FPS"] = "FPS MAXIMO",
|
||||
["GAME SPEED"] = "VELOCIDAD JUEGO",
|
||||
["MODS"] = "MODS",
|
||||
["%d INSTALLED"] = "%d INSTALADOS",
|
||||
["CONTROLS"] = "CONTROLES",
|
||||
["TOUCH PAD"] = "CONTROL TACTIL",
|
||||
["SURE? AGAIN"] = "SEGURO? OTRA VEZ",
|
||||
["AUTO HIDE PAD"] = "OCULTAR AUTO",
|
||||
["A blinding FLASH\nlights the area!"] = "¡Un DESTELLO\nilumina la zona!",
|
||||
["No SURFing here!"] = "¡Aquí no se puede\nSURFEAR!",
|
||||
["Nothing to CUT!"] = "¡Nada que CORTAR!",
|
||||
["{RAM:wNameBuffer} used\nSTRENGTH."] = "{RAM:wNameBuffer} usó\nFUERZA.",
|
||||
["{RAM:wNameBuffer} can\nmove boulders."] = "{RAM:wNameBuffer} puede\nmover rocas.",
|
||||
["It won't have\nany effect."] = "No tendrá ningún\nefecto.",
|
||||
["%s's HP\nwas restored!"] = "¡Los PS de %s\nse recuperaron!",
|
||||
["SWITCH"] = "CAMBIAR",
|
||||
["FLY"] = "VUELO",
|
||||
["FLASH"] = "DESTELLO",
|
||||
["CUT"] = "CORTE",
|
||||
["SURF"] = "SURF",
|
||||
["STRENGTH"] = "FUERZA",
|
||||
["SOFTBOILED"] = "HUEVO SUERTE",
|
||||
["TELEPORT"] = "TELETRANSPORTE",
|
||||
["DIG"] = "EXCAVAR",
|
||||
["Use TM on which\nPOKéMON?"] = "¿Usar la MT en qué\nPOKéMON?",
|
||||
["Bring out which\nPOKéMON?"] = "¿Qué POKéMON\nquieres sacar?",
|
||||
["Choose a POKéMON."] = "Elige un POKéMON.",
|
||||
["No POKéMON!"] = "¡Ningún POKéMON!",
|
||||
["ABLE"] = "PUEDE",
|
||||
["NOT ABLE"] = "NO PUEDE",
|
||||
["FNT"] = "DEB",
|
||||
["Move to where?"] = "¿Mover a dónde?",
|
||||
["Use on which one?"] = "¿Usar en cuál?",
|
||||
["You can't carry\nany more items."] = "No puedes llevar\nmás objetos.",
|
||||
["Withdrew\n%s."] = "Retirado\n%s.",
|
||||
["No room left to\nstore items."] = "No queda sitio para\nguardar objetos.",
|
||||
["%s was\nstored via PC."] = "%s se\nguardó en el PC.",
|
||||
["Toss %s?"] = "¿Tirar %s?",
|
||||
["Threw away %s."] = "Tiraste %s.",
|
||||
["WITHDRAW ITEM"] = "RETIRAR OBJETO",
|
||||
["DEPOSIT ITEM"] = "GUARDAR OBJETO",
|
||||
["TOSS ITEM"] = "TIRAR OBJETO",
|
||||
["LOG OFF"] = "SALIR",
|
||||
["SEEN %d OWNED %d"] = "VISTOS %d CAPT. %d",
|
||||
["DATA"] = "DATOS",
|
||||
["CRY"] = "VOZ",
|
||||
["AREA"] = "ZONA",
|
||||
["PRNT"] = "IMPR",
|
||||
["Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder."] = "¡Datos de %s\nimpresos!\fGuardado como\n%s\ven la carpeta de\nguardado.",
|
||||
["QUIT"] = "SALIR",
|
||||
["%s (%s)"] = "%s (%s)",
|
||||
["%s x%d"] = "%s x%d",
|
||||
["%s to box %d"] = "%s a caja %d",
|
||||
["LOAD REPORT"] = "CARGAR PARTIDA",
|
||||
["A:CONTINUE"] = "A:CONTINUAR",
|
||||
["You don't have\nenough money."] = "No tienes dinero\nsuficiente.",
|
||||
["%s?\nThat will be\n¥%d. OK?"] = "¿%s?\nSon ¥%d.\n¿OK?",
|
||||
["Here you are!\nThank you!"] = "¡Aquí tienes!\n¡Gracias!",
|
||||
["I can't put a\nprice on that."] = "No puedo ponerle\nprecio a eso.",
|
||||
["I can pay you\n¥%d for that."] = "Te doy ¥%d\npor eso.",
|
||||
["BUY"] = "COMPRAR",
|
||||
["SELL"] = "VENDER",
|
||||
["%s lined up!\nScored %d coins!"] = "¡%s alineados!\n¡%d fichas!",
|
||||
["Darn!\nRan out of coins!"] = "¡Vaya!\n¡Sin fichas!",
|
||||
["Not enough\ncoins!"] = "¡Fichas\ninsuficientes!",
|
||||
["SLOT MACHINE"] = "MAQUINA TRAGAPERRAS",
|
||||
["COINS %4d"] = "FICHAS %4d",
|
||||
["POKéDEX"] = "POKéDEX",
|
||||
["POKéMON"] = "POKéMON",
|
||||
["SAVE"] = "GUARDAR",
|
||||
["PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d"] = "JUGADOR %s\nMEDALLAS %d\nPOKéDEX %3d\nTIEMPO %6d:%02d",
|
||||
["\fWould you like to\nSAVE the game?"] = "\f¿Quieres GUARDAR\nla partida?",
|
||||
["Now saving..."] = "Guardando...",
|
||||
["%s saved\nthe game!"] = "¡%s guardó\nla partida!",
|
||||
["OPTION"] = "OPCION",
|
||||
["LINK"] = "LINK",
|
||||
["RETURN TO MAIN\nMENU?"] = "¿VOLVER AL MENU\nPRINCIPAL?",
|
||||
["BALL"] = "BALL",
|
||||
["STATUS/"] = "ESTADO/",
|
||||
["OT/"] = "EO/",
|
||||
["EXP POINTS"] = "P. EXP.",
|
||||
["LEVEL UP"] = "SUBE NIVEL",
|
||||
["PP"] = "PP",
|
||||
["SCORE %d"] = "PUNTOS %d",
|
||||
["New record!"] = "¡Nuevo récord!",
|
||||
["HI %d"] = "MAX %d",
|
||||
["A: done"] = "A: listo",
|
||||
["PLAYER"] = "JUGADOR",
|
||||
["BADGES"] = "MEDALLAS",
|
||||
["TIME"] = "TIEMPO",
|
||||
["CONTINUE"] = "CONTINUAR",
|
||||
["NEW GAME"] = "NUEVA PARTIDA",
|
||||
["EXIT GAME"] = "SALIR DEL JUEGO",
|
||||
["POKéMON RED"] = "",
|
||||
["2026 bois club games"] = "",
|
||||
["OT/%s"] = "EO/%s",
|
||||
["NAME/%s"] = "NOMBRE/%s",
|
||||
["In battle"] = "En combate",
|
||||
["Wild battle"] = "Combate salvaje",
|
||||
["Trainer battle"] = "Combate entrenador",
|
||||
["Link battle"] = "Combate link",
|
||||
["Title screen"] = "Pantalla de título",
|
||||
["Level %d"] = "Nivel %d",
|
||||
["What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!"] = "¿Qué?\n¡%s está\nevolucionando!\f¡Enhorabuena!\n¡Tu %s\nevolucionó a\n%s!",
|
||||
["Not even a nibble!"] = "¡Ni un mordisco!",
|
||||
["Oh!\nIt's a bite!"] = "¡Oh!\n¡Ha picado!",
|
||||
["It's a sculpture\nof DIGLETT."] = "Es una escultura\nde DIGLETT.",
|
||||
["Crammed full of\nPOKéMON books!"] = "¡Repleto de libros\nsobre POKéMON!",
|
||||
["There's a slew of\nPOKéMON stuff!"] = "¡Hay un montón de\ncosas POKéMON!",
|
||||
["An elevator!"] = "¡Un ascensor!",
|
||||
["INDIGO PLATEAU"] = "MESETA ANIL",
|
||||
["POKéMON LEAGUE HQ"] = "SEDE DE LA LIGA\nPOKéMON",
|
||||
["You can't carry\nany more items!"] = "¡No puedes llevar\nmás objetos!",
|
||||
["%s found\n%s!"] = "¡%s encontró\n%s!",
|
||||
["%s found\n%d coins!"] = "¡%s encontró\n%d fichas!",
|
||||
["OUT OF ORDER\nThis is broken."] = "FUERA DE SERVICIO\nEsto está roto.",
|
||||
["OUT TO LUNCH\nThis is reserved."] = "CERRADO POR COMIDA\nEsto está reservado.",
|
||||
["Someone's keys!\nThey'll be back."] = "¡Las llaves de\nalguien! Volverá.",
|
||||
["A COIN CASE is\nrequired!"] = "¡Se necesita un\nMONEDERO!",
|
||||
["You don't have\nany coins!"] = "¡No tienes fichas!",
|
||||
["{RAM}\nPOKéMON GYM\nLEADER: {RAM}"] = "{RAM}\nGIMNASIO POKéMON\nLIDER: {RAM}",
|
||||
["Nope, there's\nonly trash here."] = "No, aquí solo hay\nbasura.",
|
||||
["Darn! It needs a\nCARD KEY!"] = "¡Vaya! ¡Necesita\nuna LLAVE MAGNET.!",
|
||||
["Bingo!"] = "¡Bingo!",
|
||||
["\nThe CARD KEY\nopened the door!"] = "\n¡La LLAVE MAGNET.\nabrió la puerta!",
|
||||
["Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"] = "¡Hay un interruptor\nbajo la basura!\f¡Se abrió el 1er\ncierre eléctrico!",
|
||||
["The 2nd electric\nlock opened!\fThe motorized door\nopened!"] = "¡Se abrió el 2o\ncierre eléctrico!\f¡La puerta se\nabrió!",
|
||||
["Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"] = "¡No! Aquí solo hay\nbasura.\f¡Los cierres se\nreiniciaron!",
|
||||
["TELEPORTER is\ndisplayed on the\nPC monitor."] = "El TELETRANSPORTE\naparece en el\nmonitor del PC.",
|
||||
["{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"] = "¡{PLAYER} activó el\nSeparador de Células\ndel TELETRANSPORTE!",
|
||||
["BILL's favorite\nPOKéMON list!"] = "¡La lista de POKéMON\nfavoritos de BILL!",
|
||||
["{PLAYER} got on\n{RAM:wNameBuffer}!"] = "¡{PLAYER} se subió\na {RAM:wNameBuffer}!",
|
||||
["{RAM:wNameBuffer} hacked\naway with CUT!"] = "¡{RAM:wNameBuffer} cortó\ncon CORTE!",
|
||||
["Gyaoo!"] = "¡Gyaoo!",
|
||||
["Hi there!\nMay I help you?"] = "¡Hola!\n¿Puedo ayudarte?",
|
||||
["SOMEONE'S PC"] = "EL PC DE ALGUIEN",
|
||||
["PROF.OAK's PC"] = "EL PC DEL PROF.OAK",
|
||||
["POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:"] = "La POKéDEX está\nasí:\f{NUM:hDexRatingNumMonsSeen} POKéMON vistos\n{NUM:hDexRatingNumMonsOwned} POKéMON capturados\fNota del\nPROF.OAK:",
|
||||
["We hope to see\nyou again!"] = "¡Esperamos verte\nde nuevo!",
|
||||
["Welcome to our\nPOKéMON CENTER!"] = "¡Bienvenido a\nnuestro CENTRO\nPOKéMON!",
|
||||
["Shall we heal your\nPOKéMON?"] = "¿Curamos a tus\nPOKéMON?",
|
||||
["OK. We'll need\nyour POKéMON."] = "Bien. Necesitamos\ntus POKéMON.",
|
||||
["Your POKéMON are\nfighting fit!"] = "¡Tus POKéMON están\nen plena forma!",
|
||||
["Welcome to the\nCable Club!"] = "¡Bienvenido al Club\nde Cable!",
|
||||
["We're making\npreparations.\vPlease wait."] = "Estamos preparando\ntodo.\vEspera un momento.",
|
||||
["Please apply here.\fBefore opening\nthe link, we have\vto save the game."] = "Solicítalo aquí.\fAntes de abrir el\nlink hay que\vguardar la partida.",
|
||||
["Please come\nagain!"] = "¡Vuelve pronto!",
|
||||
["I like shorts!\nThey're comfy and\neasy to wear!"] = "¡Me gustan los\npantalones cortos!\n¡Son cómodos!",
|
||||
["%s received\nthe %s!"] = "¡%s recibió\nel %s!",
|
||||
["%s received\n%s!"] = "¡%s recibió\n%s!",
|
||||
["REPEL's effect\nwore off."] = "El efecto del REPEL\nse ha pasado.",
|
||||
["Go right ahead!"] = "¡Adelante!",
|
||||
["You don't have the\nBOULDERBADGE yet!"] = "¡Aún no tienes la\nMEDALLA ROCA!",
|
||||
["Oh! That is the\n{RAM}!"] = "¡Oh! ¡Eso es el\n{RAM}!",
|
||||
["You don't have the\n{RAM} yet!"] = "¡Aún no tienes el\n{RAM}!",
|
||||
["You need a\nBICYCLE for the\nCycling Road!"] = "¡Necesitas una\nBICICLETA para el\nCarril Bici!",
|
||||
["The boulder fell\nthrough the hole!"] = "¡La roca cayó por\nel agujero!",
|
||||
["PA: Ding-dong!\nTime's up!"] = "AV: ¡Ding-dong!\n¡Se acabó el tiempo!",
|
||||
["PA: Your SAFARI\nGAME is over!"] = "AV: ¡Tu JUEGO\nSAFARI ha terminado!",
|
||||
["PA: You're out of\nSAFARI BALLs!"] = "AV: ¡No te quedan\nSAFARI BALLs!",
|
||||
["{PLAYER} got\n%s!"] = "¡{PLAYER} consiguió\n%s!",
|
||||
["There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!"] = "¡No hay sitio para\nmás POKéMON!\v¡%s fue\venviado a la CAJA\vPOKéMON %s del PC!",
|
||||
["contribution is not a table"] = "",
|
||||
[" [%s %s.%s]"] = " [%s %s.%s]",
|
||||
["Link battle needs\nthe same mods on\nboth games."] = "El combate link\nnecesita los mismos\nmods en los dos\njuegos.",
|
||||
["Your %s can't\nbattle on the\nother game."] = "Tu %s no puede\nluchar en el otro\njuego.",
|
||||
["Their %s isn't\nin this game.\n(%s)"] = "Su %s no está\nen este juego.\n(%s)",
|
||||
["%s wants\nto battle!"] = "¡%s quiere\nluchar!",
|
||||
["Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?"] = "¡Link desincroni-\nzado!\n%s difiere.\f¿Están los dos\njuegos con los\nmismos mods?",
|
||||
["%s ran from\nthe battle!"] = "¡%s huyó del\ncombate!",
|
||||
["Items can't be\nused in a link\nbattle!"] = "¡No se pueden usar\nobjetos en un\ncombate link!",
|
||||
["%s is out of\nPOKéMON!\f%s wins!"] = "¡%s no tiene\nPOKéMON!\f¡%s gana!",
|
||||
["%s left the\nbattle."] = "%s dejó el\ncombate.",
|
||||
["%s ran out of\ntime!"] = "¡%s se quedó\nsin tiempo!",
|
||||
["Time's up! You\nforfeit the match."] = "¡Se acabó el tiempo!\nPierdes el combate.",
|
||||
["%s's %s can't\nbattle on this\ngame."] = "El %s de %s\nno puede luchar en\neste juego.",
|
||||
["%s's %s can't\nbattle on this\ngame.\n(%s)"] = "El %s de %s\nno puede luchar en\neste juego.\n(%s)",
|
||||
["%s vs %s!"] = "¡%s contra %s!",
|
||||
["Link error:\n%s"] = "Error de link:\n%s",
|
||||
["Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?"] = "El juego en línea\nva sin mods para\nlos dos jugadores.\f¿Desactivar %s\ny reiniciar?",
|
||||
["The link was\nbroken."] = "Se ha perdido el\nlink.",
|
||||
["Link battle\ncan't start."] = "El combate link no\npuede empezar.",
|
||||
["The trade stopped:\n%s."] = "El intercambio se\ndetuvo:\n%s.",
|
||||
["The trade was\ncancelled."] = "El intercambio se\nha cancelado.",
|
||||
["Trade completed!\f%s received\n%s!"] = "¡Intercambio hecho!\f¡%s recibió\n%s!",
|
||||
["LINK CABLE (LAN)"] = "CABLE LINK (LAN)",
|
||||
["ONLINE MATCH"] = "PARTIDA EN LINEA",
|
||||
["TOURNAMENT"] = "TORNEO",
|
||||
["HOST A GAME"] = "CREAR PARTIDA",
|
||||
["JOIN A GAME"] = "UNIRSE A PARTIDA",
|
||||
["UDP port %s"] = "Puerto UDP %s",
|
||||
["HOST ONLINE"] = "CREAR EN LINEA",
|
||||
["JOIN ONLINE"] = "UNIRSE EN LINEA",
|
||||
["Tell your friend"] = "Dile a tu amigo",
|
||||
["the code:"] = "el código:",
|
||||
["Waiting for join..."] = "Esperando...",
|
||||
["A: connect B: back"] = "A: conectar B: atrás",
|
||||
["Calling..."] = "Llamando...",
|
||||
["Friend joins at:"] = "Tu amigo entra en:",
|
||||
["Port: %s"] = "Puerto: %s",
|
||||
["TRADE"] = "INTERCAMBIO",
|
||||
["BATTLE"] = "COMBATE",
|
||||
["LEVELS:"] = "NIVELES:",
|
||||
["A: continue B: back"] = "A: seguir B: atrás",
|
||||
["Checking the"] = "Comprobando el",
|
||||
["other game..."] = "otro juego...",
|
||||
["Waiting for the"] = "Esperando a que",
|
||||
["host to choose..."] = "el anfitrión elija...",
|
||||
["A: trade anyway"] = "A: intercambiar igual",
|
||||
["YOURS"] = "TUYO",
|
||||
["THEIRS"] = "SUYO",
|
||||
["X: not on theirs"] = "X: no en el suyo",
|
||||
["A: trade B: cancel"] = "A: cambiar B: cancelar",
|
||||
["Exchanging data..."] = "Intercambiando...",
|
||||
["can't reach relay %s:%d\n(%s)"] = "",
|
||||
["That code wasn't\nfound."] = "Ese código no se\nha encontrado.",
|
||||
["That game already\nhas two players."] = "Esa partida ya\ntiene dos jugadores.",
|
||||
["That code has\nexpired."] = "Ese código ha\ncaducado.",
|
||||
["Couldn't join:\n%s"] = "No se pudo unir:\n%s",
|
||||
["no answer from\n%s"] = "",
|
||||
["That tournament\nhas already begun."] = "Ese torneo ya ha\nempezado.",
|
||||
["Can't host:\nneed %d Pokemon\nLv %s-%s."] = "No puedes crearlo:\nnecesitas %d Pokemon\nNv %s-%s.",
|
||||
["Couldn't host\nthat tournament."] = "No se pudo crear\nese torneo.",
|
||||
["Your party needs\n%d Pokemon, Lv\n%s-%s."] = "Tu equipo necesita\n%d Pokemon, Nv\n%s-%s.",
|
||||
["Couldn't join\nthat tournament."] = "No se pudo unir a\nese torneo.",
|
||||
["Link error:\nversion mismatch\nwith opponent."] = "Error de link:\nversión distinta a\nla del rival.",
|
||||
["The tournament\nconnection was\nlost."] = "Se perdió la\nconexión del torneo.",
|
||||
["Can't watch this\nmatch."] = "No se puede ver\neste combate.",
|
||||
["HOST"] = "CREAR",
|
||||
["JOIN"] = "UNIRSE",
|
||||
["START: create"] = "START: crear",
|
||||
["A: join B: back"] = "A: unirse B: atrás",
|
||||
["B: cancel"] = "B: cancelar",
|
||||
["TOURNAMENT %s"] = "TORNEO %s",
|
||||
["ROUND %d"] = "RONDA %d",
|
||||
["%s (bye)"] = "%s (pasa)",
|
||||
["%s%s vs %s%s"] = "%s%s contra %s%s",
|
||||
["(organizing --"] = "(organizando --",
|
||||
["not playing)"] = "no juega)",
|
||||
["Waiting for"] = "Esperando a que",
|
||||
["players to join:"] = "entren jugadores:",
|
||||
["A: START B: cancel"] = "A: START B: cancelar",
|
||||
["%s is the"] = "¡%s es el",
|
||||
["champion!"] = "campeón!",
|
||||
["A: continue"] = "A: continuar",
|
||||
["{PLAYER} played the\nPOKé FLUTE."] = "{PLAYER} tocó la\nFLAUTA POKé.",
|
||||
["Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!"] = "Tocaste la FLAUTA\nPOKé.\f¡Qué melodía tan\npegadiza!",
|
||||
["%s played the\nPOKé FLUTE."] = "%s tocó la\nFLAUTA POKé.",
|
||||
["All sleeping\nPOKéMON woke up!"] = "¡Todos los POKéMON\ndormidos despertaron!",
|
||||
["%s's\nhits will never\nmiss!"] = "¡Los golpes de %s\nnunca fallarán!",
|
||||
["The wild POKéMON\nran away!"] = "¡El POKéMON salvaje\nhuyó!",
|
||||
["%s's PP\nwas restored!"] = "¡Los PP de %s\nse recuperaron!",
|
||||
["%s's\nstatus returned\nto normal!"] = "¡El estado de %s\nvolvió a la\nnormalidad!",
|
||||
["%s\nis revitalized!"] = "¡%s se ha\nrevitalizado!",
|
||||
["%s\nis refusing!"] = "¡%s se\nniega!",
|
||||
["%s's %s\nrose!"] = "¡El %s de %s\nsubió!",
|
||||
["%s's PP\nincreased!"] = "¡Los PP de %s\naumentaron!",
|
||||
["%s can't\nlearn that move!"] = "¡%s no puede\naprender ese\nmovimiento!",
|
||||
["It knows that\nmove already!"] = "¡Ya conoce ese\nmovimiento!",
|
||||
["Coin count:\n%d"] = "Fichas:\n%d",
|
||||
["NO MODS INSTALLED"] = "NO HAY MODS",
|
||||
["SAVE CURRENT AS.."] = "GUARDAR ACTUAL..",
|
||||
["OPTIONS.."] = "OPCIONES..",
|
||||
["PERMISSIONS.."] = "PERMISOS..",
|
||||
["VIEW ERROR.."] = "VER ERROR..",
|
||||
["BACK"] = "ATRAS",
|
||||
["APPLY & RESTART"] = "APLICAR Y REINICIAR",
|
||||
["DISCARD CHANGES"] = "DESCARTAR CAMBIOS",
|
||||
["DATA & API ONLY"] = "SOLO DATOS Y API",
|
||||
["DISABLE BOTH?"] = "DESACTIVAR AMBOS?",
|
||||
["PROFILE NAME?"] = "NOMBRE DEL PERFIL?",
|
||||
["RENAME?"] = "RENOMBRAR?",
|
||||
["RESET DEFAULTS"] = "VALORES POR DEFECTO",
|
||||
["NO CHANGES"] = "SIN CAMBIOS",
|
||||
["A:OK"] = "A:OK",
|
||||
["B:DONE (NO RESTART)"] = "B:LISTO (SIN REINICIAR)",
|
||||
["MOD MANAGER"] = "GESTOR DE MODS",
|
||||
["Choose a mod .zip"] = "Elige un .zip de mod",
|
||||
["Choose a .sav save file"] = "Elige un archivo .sav",
|
||||
["An update is available"] = "Hay una actualización",
|
||||
["Name save slot"] = "Nombra la ranura",
|
||||
["Enter to save - Esc to cancel - empty clears"] = "Enter para guardar - Esc para cancelar - vacío la borra",
|
||||
["Add a mod index"] = "Añadir un índice de mods",
|
||||
["Paste the index URL, or its owner/repo."] = "Pega la URL del índice, o su owner/repo.",
|
||||
["Enter to add - Esc to cancel"] = "Enter para añadir - Esc para cancelar",
|
||||
["Import a ROM to play"] = "Importa una ROM para jugar",
|
||||
["RED"] = "ROJO",
|
||||
["BLUE"] = "AZUL",
|
||||
["YELLOW"] = "AMARILLO",
|
||||
["FIND MODS"] = "BUSCAR MODS",
|
||||
["%d of 3 ready"] = "%d de 3 listos",
|
||||
["Or drop the .gb/.gbc file here."] = "O arrastra aquí el archivo .gb/.gbc.",
|
||||
["ROM imported"] = "ROM importada",
|
||||
["That ROM could not be imported."] = "No se pudo importar esa ROM.",
|
||||
["Open folder"] = "Abrir carpeta",
|
||||
["%d badges - %s - %d caught"] = "%d medallas - %s - %d capturados",
|
||||
["%d of %d enabled"] = "%d de %d activados",
|
||||
["Or drop a mod .zip onto the window."] = "O arrastra un .zip de mod a la ventana.",
|
||||
["No mods installed - drop a mod .zip here to add one."] = "No hay mods - arrastra aquí un .zip para añadir uno.",
|
||||
["Refreshed - %d mods listed"] = "Actualizado - %d mods listados",
|
||||
["Added %s"] = "Añadido %s",
|
||||
["Index removed"] = "Índice eliminado",
|
||||
["Downloading %s..."] = "Descargando %s...",
|
||||
["Installed %s %s"] = "Instalado %s %s",
|
||||
["%d mods listed"] = "%d mods listados",
|
||||
["%d of %d mods"] = "%d de %d mods",
|
||||
["Mods here are listed, not reviewed - read the source and trust the author."] = "Los mods aquí se listan, no se revisan - lee el código y confía en el autor.",
|
||||
["No mod index added"] = "No hay índice de mods",
|
||||
["Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."] = "Añade un índice para explorar mods. Un índice es una lista publicada; pega su URL o su owner/repo.",
|
||||
["Search mods"] = "Buscar mods",
|
||||
["This index lists no mods yet."] = "Este índice aún no lista mods.",
|
||||
["No mods match that search."] = "Ningún mod coincide con esa búsqueda.",
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Trainer class names
|
||||
--
|
||||
-- Trainer class names for Espanol.
|
||||
|
||||
return {
|
||||
["OPP_FIX_YOUNGSTER"] = "",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
-- spanish_ui: a translation of the game into Espanol.
|
||||
--
|
||||
-- Nothing here is translated yet. Every table under lang/ starts with
|
||||
-- empty strings; fill one in and it takes effect on the next boot, and
|
||||
-- anything still empty keeps rendering in English. That means a
|
||||
-- half-finished translation is always playable, so you can ship early and
|
||||
-- fill the long tail in later.
|
||||
--
|
||||
-- Read TRANSLATING.md before the first edit; the font is the part people
|
||||
-- get wrong.
|
||||
return function(mod)
|
||||
-- mod:read is the supported way into your own directory; the catalogs are
|
||||
-- plain Lua tables, so read and run them rather than require()ing them.
|
||||
local function catalog(name)
|
||||
local rel = "lang/" .. name .. ".lua"
|
||||
local body = mod:read(rel)
|
||||
if not body then return {} end
|
||||
local chunk, err = loadstring(body, rel)
|
||||
if not chunk then
|
||||
mod.log:warn("%s has a syntax error: %s", rel, tostring(err))
|
||||
return {}
|
||||
end
|
||||
local ok, table_ = pcall(chunk)
|
||||
if not ok or type(table_) ~= "table" then
|
||||
mod.log:warn("%s did not return a table: %s", rel, tostring(table_))
|
||||
return {}
|
||||
end
|
||||
return table_
|
||||
end
|
||||
|
||||
-- An empty value means "not translated yet", never "translate to blank".
|
||||
local function each(name, apply)
|
||||
local n = 0
|
||||
for key, value in pairs(catalog(name)) do
|
||||
if type(value) == "string" and value ~= "" then
|
||||
apply(key, value)
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- ---- glyphs -------------------------------------------------------
|
||||
-- Register the sheet BEFORE anything asks for a glyph on it. base is
|
||||
-- the first code the page owns; 0x100 and up is free space above the
|
||||
-- vanilla pages, so a new alphabet never collides with them.
|
||||
for id, page in pairs(catalog("font")) do
|
||||
mod.content.font:register(id, page)
|
||||
end
|
||||
-- charmap: which byte sequence draws which code
|
||||
for seq, code in pairs(catalog("charmap")) do
|
||||
mod.content.font:register("charmap:" .. seq, { seq = seq, code = code })
|
||||
end
|
||||
|
||||
-- ---- text ---------------------------------------------------------
|
||||
local counts = {}
|
||||
counts.dialogue = each("dialogue", function(id, value)
|
||||
mod.content.text:override(id, value)
|
||||
end)
|
||||
counts.strings = each("strings", function(source, value)
|
||||
mod.content.strings:override(source, value)
|
||||
end)
|
||||
counts.species = each("species_names", function(id, value)
|
||||
mod.content.pokemon:patch(id, { name = value })
|
||||
end)
|
||||
counts.moves = each("move_names", function(id, value)
|
||||
mod.content.moves:patch(id, { name = value })
|
||||
end)
|
||||
counts.items = each("item_names", function(id, value)
|
||||
mod.content.items:patch(id, { name = value })
|
||||
end)
|
||||
counts.trainers = each("trainer_names", function(id, value)
|
||||
mod.content.trainers:patch(id, { name = value })
|
||||
end)
|
||||
counts.statuses = each("status_labels", function(id, value)
|
||||
mod.content.statuses:patch(id, { label = value })
|
||||
end)
|
||||
|
||||
-- ---- name entry ---------------------------------------------------
|
||||
-- The naming screen's letter grid. Leave lang/naming.lua returning nil
|
||||
-- to keep the English alphabet.
|
||||
local grid = catalog("naming")
|
||||
if grid.upper then
|
||||
-- Only offer the accented cells when the running cartridge can actually
|
||||
-- draw them. A Spanish ROM has Ñ and the accented vowels in its font
|
||||
-- and the manifest maps them; an English one does not, and an
|
||||
-- unmappable cell renders blank -- a naming screen with six empty keys
|
||||
-- is worse than an English one. So check the charmap and fall back.
|
||||
local function drawable(cells, ctx)
|
||||
local font = ((ctx.game or {}).data or {}).font
|
||||
local charmap = font and font.charmap
|
||||
if not charmap then return false end
|
||||
local have = {}
|
||||
for _, entry in ipairs(charmap) do have[entry.seq] = true end
|
||||
for _, row in ipairs(cells) do
|
||||
for _, cell in ipairs(row) do
|
||||
-- Only the non-ASCII cells are at risk; A-Z and punctuation are
|
||||
-- on every page.
|
||||
if cell:byte(1) and cell:byte(1) > 127 and not have[cell] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local warned = false
|
||||
mod.hooks:on("ui.naming.grid", function(base, ctx)
|
||||
local want = ctx.lower and grid.lower or grid.upper
|
||||
if not want then return base end
|
||||
if not drawable(want, ctx) then
|
||||
if not warned then
|
||||
warned = true
|
||||
mod.log:info("naming grid: this ROM has no accented glyphs, "
|
||||
.. "keeping the English alphabet")
|
||||
end
|
||||
return base
|
||||
end
|
||||
return want
|
||||
end)
|
||||
end
|
||||
|
||||
mod.events:on("game.ready", function()
|
||||
local total = 0
|
||||
for _, n in pairs(counts) do total = total + n end
|
||||
mod.log:info("Espanol: %d strings translated", total)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "spanish_ui",
|
||||
"name": "Espanol (interfaz)",
|
||||
"version": "0.1.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"game_version": ">=0.0.0-dev <1.0.0",
|
||||
"category": "LANGUAGE",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"incompatible": [],
|
||||
"experimental": false,
|
||||
"description": "Spanish for the app's own settings and menus. The game's text comes from your ROM and is untouched, so an English cartridge stays an English adventure with Spanish menus."
|
||||
}
|
||||
+7
-4
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the LÖVE2D Pokémon Red port (macOS-friendly).
|
||||
#
|
||||
# Assumes scripts/setup.sh has been run once (generated data present and
|
||||
# LÖVE installed). Extra arguments are passed through to LÖVE.
|
||||
# Assumes scripts/setup.sh has been run once for at least one game (generated
|
||||
# data present and LÖVE installed). Extra arguments are passed through to LÖVE.
|
||||
#
|
||||
# Link play is peer-to-peer (lua-enet, bundled with LÖVE): one player
|
||||
# picks HOST A GAME in START > LINK and reads out the address shown;
|
||||
@@ -15,8 +15,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ -f "$ROOT/data/generated/maps.lua" ] \
|
||||
|| fail "generated data missing, run scripts/setup.sh first"
|
||||
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
||||
fail "generated data missing, run scripts/setup.sh first"
|
||||
fi
|
||||
|
||||
find_love() {
|
||||
command -v love >/dev/null 2>&1 && { echo "love"; return; }
|
||||
|
||||
@@ -66,6 +66,7 @@ run_tier() {
|
||||
|
||||
# ------- ROM-free tiers: these are what CI runs
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
|
||||
@@ -98,6 +98,15 @@ function BattleState:bgMode()
|
||||
return "white"
|
||||
end
|
||||
|
||||
-- Resume a semantic checkpoint directly at the command menu. Unlike enter(),
|
||||
-- this deliberately does not replay the battle transition, intro queues,
|
||||
-- cries, happiness changes, or battle-start events.
|
||||
function BattleState:resumeCheckpoint()
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
require("src.core.Music").playBattle(self.data,
|
||||
self.musicKind or self:computeMusicKind())
|
||||
end
|
||||
|
||||
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
||||
-- that the battle reads as the foreground rather than competing with a fully
|
||||
-- lit map behind it.
|
||||
@@ -424,7 +433,7 @@ function StatBox:draw()
|
||||
{ Strings("SPEED"), s.speed },
|
||||
{ Strings("SPECIAL"), s.special } }
|
||||
for i, r in ipairs(rows) do
|
||||
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
||||
Font.draw(Strings(r[1]), 88, 24 + (i - 1) * 16)
|
||||
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -1411,6 +1420,26 @@ function BattleState:computeMusicKind()
|
||||
return "wild"
|
||||
end
|
||||
|
||||
-- a mod-set per-trainer battle theme (trainers.battleTheme, an audio.songs
|
||||
-- id); nil for vanilla trainers, so the kind default is untouched (#782)
|
||||
function BattleState:battleTheme()
|
||||
local trainer = self.trainer
|
||||
if trainer and trainer.battleTheme then return trainer.battleTheme end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the battle-theme cue for this battle: the mod-set trainer battleTheme
|
||||
-- when the class has one, else the kind default. The single choke point
|
||||
-- both the transition-wipe start (OverworldController:pushBattle) and
|
||||
-- enter() route through, so a per-trainer override can't drift between
|
||||
-- them. self.musicKind is set by enter(); pushBattle runs before that,
|
||||
-- so compute it here when absent.
|
||||
function BattleState:playBattleTheme()
|
||||
require("src.core.Music").playBattle(self.data,
|
||||
self.musicKind or self:computeMusicKind(),
|
||||
self.trainer and self.trainer.id, self:battleTheme())
|
||||
end
|
||||
|
||||
-- side tables mirror the singles battlers; called before every
|
||||
-- battler-switch notification so sides[i].battlers[1] stays honest
|
||||
function BattleState:syncSides()
|
||||
@@ -1462,7 +1491,6 @@ function BattleState:enter()
|
||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||
return
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
self.musicKind = self:computeMusicKind()
|
||||
if self.isGymLeader then
|
||||
require("src.world.PikachuFollower")
|
||||
@@ -1472,7 +1500,7 @@ function BattleState:enter()
|
||||
-- (audio/play_battle_music.asm runs before the transition, and
|
||||
-- Music.play no-ops on the same song); this covers battles pushed
|
||||
-- without a transition (link battles, scripted pushes)
|
||||
Music.playBattle(self.data, self.musicKind)
|
||||
self:playBattleTheme()
|
||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
||||
@@ -5587,9 +5615,9 @@ function BattleState:drawTextArea()
|
||||
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
elseif self.phase == "menu" then
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
@@ -5599,7 +5627,7 @@ function BattleState:drawTextArea()
|
||||
-- THROW ROCK RUN" from (2,14)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
|
||||
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
|
||||
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
|
||||
-- digits, space padded -- right after the "BALLx" label at columns
|
||||
@@ -5610,9 +5638,9 @@ function BattleState:drawTextArea()
|
||||
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
||||
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
Font.draw(Strings("FIGHT"), 80, 112)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("RUN"), 128, 128)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
end
|
||||
elseif self.phase == "moveSelect" then
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
-- Semantic, data-only capture for settled single-player battle checkpoints.
|
||||
-- Reconstruction lives here too; public mods only see the opaque checkpoint
|
||||
-- facade in Loader.
|
||||
|
||||
local BattleCheckpoint = {}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BUILTIN_RULESETS = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
}
|
||||
|
||||
local function rulesets(game)
|
||||
return game.data.rulesets or BUILTIN_RULESETS
|
||||
end
|
||||
|
||||
local function rulesetId(game, record)
|
||||
for id, candidate in pairs(rulesets(game)) do
|
||||
if candidate == record then return id end
|
||||
end
|
||||
end
|
||||
|
||||
local BATTLER_FIELDS = {
|
||||
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
|
||||
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
|
||||
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
|
||||
"chargeReady", "invulnerable", "mustRecharge",
|
||||
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
||||
"trapMove", "trapDamage", "fainted",
|
||||
"aiLayer2",
|
||||
}
|
||||
|
||||
local MOVE_REFERENCE_FIELDS = {
|
||||
charging = "chargingSlot",
|
||||
thrashMove = "thrashMoveSlot",
|
||||
rageMove = "rageMoveSlot",
|
||||
}
|
||||
|
||||
local BATTLE_FIELDS = {
|
||||
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
|
||||
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
for index, candidate in ipairs(party or {}) do
|
||||
if candidate == mon then return index end
|
||||
end
|
||||
end
|
||||
|
||||
local function indexSet(set, party)
|
||||
local out = {}
|
||||
for mon, present in pairs(set or {}) do
|
||||
if present then
|
||||
local index = partyIndex(party, mon)
|
||||
if index then out[#out + 1] = index end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
local function captureBattler(battler, index, copy)
|
||||
local out = {
|
||||
index = index,
|
||||
curStatsFromMon = battler.curStats == battler.mon.stats,
|
||||
curTypesFromDefinition = battler.curTypes == battler.def.types,
|
||||
curMovesFromMon = battler.curMoves == battler.mon.moves,
|
||||
}
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if battler[field] ~= nil then out[field] = battler[field] end
|
||||
end
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
local reference = battler[field]
|
||||
if reference ~= nil then
|
||||
for slot, move in ipairs(battler.curMoves or {}) do
|
||||
if move == reference then out[slotField] = slot break end
|
||||
end
|
||||
if out[slotField] == nil then return nil end
|
||||
end
|
||||
end
|
||||
return copy(out)
|
||||
end
|
||||
|
||||
local function integer(value, min, max)
|
||||
return type(value) == "number" and value % 1 == 0
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
if type(move) ~= "table" or type(move.id) ~= "string"
|
||||
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validateMon(data, mon)
|
||||
return type(mon) == "table" and type(mon.species) == "string"
|
||||
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
|
||||
and type(mon.hp) == "number" and type(mon.stats) == "table"
|
||||
and validateMoveList(data, mon.moves)
|
||||
end
|
||||
|
||||
local function validateBattler(data, battler, maxIndex)
|
||||
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
|
||||
return false
|
||||
end
|
||||
if type(battler.curMoves) ~= "table" then return false end
|
||||
if battler.stages ~= nil then
|
||||
if type(battler.stages) ~= "table" then return false end
|
||||
for _, stage in pairs(battler.stages) do
|
||||
if not integer(stage, -6, 6) then return false end
|
||||
end
|
||||
end
|
||||
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
if battler[slotField] ~= nil
|
||||
and not integer(battler[slotField], 1, #battler.curMoves) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return validateMoveList(data, battler.curMoves)
|
||||
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
|
||||
end
|
||||
|
||||
local function clone(value, copy)
|
||||
if type(value) ~= "table" then return value end
|
||||
return assert(copy(value))
|
||||
end
|
||||
|
||||
local function captureMimicRestores(battle)
|
||||
local out = {}
|
||||
for _, restore in ipairs(battle.mimicRestores or {}) do
|
||||
local side = restore.battler == battle.player and "player"
|
||||
or restore.battler == battle.enemy and "enemy" or nil
|
||||
local slot
|
||||
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
|
||||
if move == restore.entry then slot = index break end
|
||||
end
|
||||
if not side or not slot or type(restore.id) ~= "string" then return nil end
|
||||
out[#out + 1] = { side = side, slot = slot, id = restore.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleCheckpoint.validate(game, checkpoint)
|
||||
local model = checkpoint.runtime and checkpoint.runtime.battle
|
||||
local rngState = checkpoint.rng and checkpoint.rng.love
|
||||
if type(model) ~= "table" or type(model.origin) ~= "table"
|
||||
or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
|
||||
end
|
||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Battle continuation data is unsupported or inconsistent."
|
||||
end
|
||||
if type(model.rulesetId) ~= "string"
|
||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||
end
|
||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
or model.origin.trainerClass ~= model.oppClass
|
||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Trainer continuation data is incomplete or inconsistent."
|
||||
end
|
||||
local party = checkpoint.save.party
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
return nil, "invalid_content", "Wild opponent state is invalid."
|
||||
end
|
||||
else
|
||||
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
|
||||
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
|
||||
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
|
||||
or not integer(model.enemyIndex, 1, #model.enemyParty)
|
||||
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
|
||||
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
|
||||
end
|
||||
for _, mon in ipairs(model.enemyParty) do
|
||||
if not validateMon(game.data, mon) then
|
||||
return nil, "invalid_content", "Trainer opponent state is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(model.mimicRestores) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
||||
end
|
||||
for _, restore in ipairs(model.mimicRestores) do
|
||||
local battler = restore.side == "player" and model.player
|
||||
or restore.side == "enemy" and model.enemy or nil
|
||||
if not battler or not integer(restore.slot, 1, #battler.curMoves)
|
||||
or type(restore.id) ~= "string"
|
||||
or type(game.data.moves[restore.id]) ~= "table" then
|
||||
return nil, "invalid_content", "Mimic restore state is invalid."
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function applyBattler(target, captured, copy)
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
|
||||
if captured[field] ~= nil then
|
||||
target[field] = clone(captured[field], copy)
|
||||
else
|
||||
target[field] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
target.curStats = captured.curStatsFromMon and target.mon.stats
|
||||
or assert(copy(captured.curStats))
|
||||
target.curTypes = captured.curTypesFromDefinition and target.def.types
|
||||
or assert(copy(captured.curTypes))
|
||||
target.curMoves = captured.curMovesFromMon and target.mon.moves
|
||||
or assert(copy(captured.curMoves))
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function restoreIndexSet(indices, party)
|
||||
local out = {}
|
||||
for _, index in ipairs(indices or {}) do out[party[index]] = true end
|
||||
return next(out) and out or nil
|
||||
end
|
||||
|
||||
function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
|
||||
end
|
||||
|
||||
battle.player = BattleState.makeBattler(game.data,
|
||||
game.save.party[model.player.index], true, game.save)
|
||||
applyBattler(battle.player, model.player, copy)
|
||||
local enemyMon
|
||||
if model.kind == "trainer" then
|
||||
enemyMon = battle.enemyParty[model.enemy.index]
|
||||
else
|
||||
enemyMon = assert(copy(model.enemyMon))
|
||||
end
|
||||
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
|
||||
applyBattler(battle.enemy, model.enemy, copy)
|
||||
|
||||
battle.mimicRestores = {}
|
||||
for _, restore in ipairs(model.mimicRestores or {}) do
|
||||
local battler = restore.side == "player" and battle.player or battle.enemy
|
||||
battle.mimicRestores[#battle.mimicRestores + 1] = {
|
||||
battler = battler,
|
||||
entry = battler.curMoves[restore.slot],
|
||||
id = restore.id,
|
||||
}
|
||||
end
|
||||
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
|
||||
|
||||
for _, field in ipairs(BATTLE_FIELDS) do
|
||||
if model[field] ~= nil then
|
||||
battle[field] = clone(model[field], copy)
|
||||
else
|
||||
battle[field] = nil
|
||||
end
|
||||
end
|
||||
battle.kind = model.kind
|
||||
battle.ruleset = rulesets(game)[model.rulesetId]
|
||||
battle.checkpointOrigin = assert(copy(model.origin))
|
||||
battle.participants = restoreIndexSet(model.participants, game.save.party)
|
||||
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
|
||||
battle.sides = assert(copy(model.sides))
|
||||
battle.sides[1].battlers = { battle.player }
|
||||
battle.sides[2].battlers = { battle.enemy }
|
||||
battle.field = assert(copy(model.field))
|
||||
battle.field.sides = battle.sides
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.frame = 0
|
||||
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
|
||||
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
|
||||
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
|
||||
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
|
||||
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
|
||||
error("battle continuation reconstruction is unavailable", 0)
|
||||
end
|
||||
if type(game.restoreCheckpointBattle) ~= "function" then
|
||||
error("game has no battle checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointBattle(battle)
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
|
||||
setState(checkpoint.rng.love)
|
||||
return battle
|
||||
end
|
||||
|
||||
local function captureExtensions(battle, copy)
|
||||
local sides = {}
|
||||
for i = 1, 2 do
|
||||
local side = battle.sides and battle.sides[i] or {}
|
||||
local encoded, err = copy({
|
||||
index = i,
|
||||
screens = side.screens or {},
|
||||
hazards = side.hazards or {},
|
||||
tokens = side.tokens or {},
|
||||
})
|
||||
if not encoded then return nil, err end
|
||||
sides[i] = encoded
|
||||
end
|
||||
local field, err = copy({
|
||||
weather = battle.field and battle.field.weather or nil,
|
||||
tokens = battle.field and battle.field.tokens or {},
|
||||
})
|
||||
if not field then return nil, err end
|
||||
return sides, field
|
||||
end
|
||||
|
||||
function BattleCheckpoint.capture(game, battle, progress, copy)
|
||||
local getState = love and love.math and love.math.getRandomState
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(getState) ~= "function" or type(setState) ~= "function" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"This runtime cannot preserve deterministic battle randomness."
|
||||
end
|
||||
local ok, rngState = pcall(getState)
|
||||
if not ok or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"The gameplay random-number state could not be captured."
|
||||
end
|
||||
|
||||
local origin, originErr = copy(battle.checkpointOrigin)
|
||||
if not origin then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"The battle completion path is not data-only: " .. tostring(originErr)
|
||||
end
|
||||
local sides, fieldOrErr = captureExtensions(battle, copy)
|
||||
if not sides then
|
||||
return nil, "battle_extension_unsafe",
|
||||
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
|
||||
end
|
||||
local field = fieldOrErr
|
||||
|
||||
local liveParty = game.save.party
|
||||
local playerIndex = partyIndex(liveParty, battle.player.mon)
|
||||
if not playerIndex then
|
||||
return nil, "battle_state_invalid",
|
||||
"The active player battler is not in the current party."
|
||||
end
|
||||
|
||||
local model = {
|
||||
kind = battle.kind,
|
||||
rulesetId = rulesetId(game, battle.ruleset),
|
||||
origin = origin,
|
||||
player = captureBattler(battle.player, playerIndex, copy),
|
||||
participants = indexSet(battle.participants, liveParty),
|
||||
leveledUp = indexSet(battle.leveledUp, liveParty),
|
||||
sides = sides,
|
||||
field = field,
|
||||
mimicRestores = captureMimicRestores(battle),
|
||||
}
|
||||
if not model.rulesetId then
|
||||
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
|
||||
end
|
||||
if not model.player then
|
||||
return nil, "battle_state_invalid", "Player move references are inconsistent."
|
||||
end
|
||||
if not model.mimicRestores then
|
||||
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
|
||||
end
|
||||
if battle.kind == "trainer" then
|
||||
model.enemyParty = copy(battle.enemyParty)
|
||||
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
|
||||
else
|
||||
model.enemyMon = copy(battle.enemy.mon)
|
||||
model.enemy = captureBattler(battle.enemy, 1, copy)
|
||||
end
|
||||
if not model.enemy then
|
||||
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
|
||||
end
|
||||
for _, fieldName in ipairs(BATTLE_FIELDS) do
|
||||
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
|
||||
end
|
||||
|
||||
model = copy(model)
|
||||
if not model then
|
||||
return nil, "battle_state_invalid",
|
||||
"Battle state contains non-serializable runtime data."
|
||||
end
|
||||
local player = progress.player
|
||||
return {
|
||||
overworld = {
|
||||
map = player.map, x = player.x, y = player.y,
|
||||
facing = player.facing, surfing = player.surfing and true or false,
|
||||
},
|
||||
battle = model,
|
||||
}, { love = rngState }
|
||||
end
|
||||
|
||||
return BattleCheckpoint
|
||||
@@ -0,0 +1,415 @@
|
||||
-- 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 BattleState = require("src.battle.BattleState")
|
||||
local BattleCheckpoint = require("src.core.BattleCheckpoint")
|
||||
|
||||
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
|
||||
|
||||
local function scriptsBusy(ow)
|
||||
return running(ow.runner) or nonempty(ow.parallelRunners)
|
||||
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
|
||||
or nonempty(ow.scriptMoves)
|
||||
end
|
||||
|
||||
local BATTLE_BUSY_FIELDS = {
|
||||
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
|
||||
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
|
||||
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
|
||||
}
|
||||
|
||||
local function inspectBattle(ow, battle)
|
||||
if battle.kind == "link" then
|
||||
return refusal("battle", "link_battle_unsupported",
|
||||
"Network battles cannot be checkpointed.")
|
||||
end
|
||||
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
|
||||
or battle.noCatch then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle variant does not have a checkpoint contract.")
|
||||
end
|
||||
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
|
||||
return refusal("battle", "battle_variant_unsupported",
|
||||
"This battle kind does not have a checkpoint contract.")
|
||||
end
|
||||
local origin = battle.checkpointOrigin
|
||||
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
|
||||
or "trainer_encounter"
|
||||
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
|
||||
return refusal("battle", "battle_origin_unsupported",
|
||||
"The battle completion path cannot be reconstructed safely.")
|
||||
end
|
||||
if scriptsBusy(ow) then
|
||||
return refusal("battle", "script_busy",
|
||||
"A suspended or queued script cannot be checkpointed.")
|
||||
end
|
||||
if battle.phase ~= "menu" or nonempty(battle.queue) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the player command menu before creating a checkpoint.")
|
||||
end
|
||||
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
|
||||
if battle[field] ~= nil and battle[field] ~= false then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for the current battle action to finish.")
|
||||
end
|
||||
end
|
||||
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
|
||||
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for an ordinary player decision before creating a checkpoint.")
|
||||
end
|
||||
for _, battler in ipairs({ battle.player, battle.enemy }) do
|
||||
if battler.shownHP ~= battler.mon.hp
|
||||
or battler.shownStatus ~= battler.mon.status
|
||||
or battler.drainFloor ~= nil or battler.drainHold ~= nil
|
||||
or battler.faintQueued then
|
||||
return refusal("battle", "battle_phase_busy",
|
||||
"Wait for battle status and HP presentation to settle.")
|
||||
end
|
||||
end
|
||||
return { canCapture = true, canRestore = true, kind = "battle" }
|
||||
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 getmetatable(top) == BattleState then
|
||||
return inspectBattle(ow, top)
|
||||
end
|
||||
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 scriptsBusy(ow) 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
|
||||
|
||||
local function captureRng()
|
||||
local getState = love and love.math and love.math.getRandomState
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(getState) ~= "function" or type(setState) ~= "function" then return nil end
|
||||
local ok, state = pcall(getState)
|
||||
if ok and type(state) == "string" and state ~= "" then
|
||||
return { love = state }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreRng(rng)
|
||||
if rng == nil then return end -- legacy format-1 overworld checkpoint
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(rng) ~= "table" or type(rng.love) ~= "string"
|
||||
or type(setState) ~= "function" then
|
||||
error("checkpoint RNG restore is unavailable", 0)
|
||||
end
|
||||
setState(rng.love)
|
||||
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
|
||||
|
||||
if capability.kind == "battle" then
|
||||
local battle = game.stack:top()
|
||||
local runtime, rngOrCode, battleMessage =
|
||||
BattleCheckpoint.capture(game, battle, progress, dataCopy)
|
||||
if not runtime then return nil, rngOrCode, battleMessage end
|
||||
return {
|
||||
format = Checkpoint.FORMAT,
|
||||
kind = "battle",
|
||||
identity = {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = game.save.version,
|
||||
playthroughId = game.save.meta.playthroughId,
|
||||
},
|
||||
save = progress,
|
||||
runtime = runtime,
|
||||
rng = rngOrCode,
|
||||
}
|
||||
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,
|
||||
} },
|
||||
rng = captureRng(),
|
||||
}
|
||||
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" and checkpoint.kind ~= "battle" then
|
||||
return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not 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 copy.rng ~= nil and (type(copy.rng) ~= "table"
|
||||
or type(copy.rng.love) ~= "string" or copy.rng.love == "") then
|
||||
return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt."
|
||||
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
|
||||
if copy.kind == "battle" then
|
||||
local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy)
|
||||
if not battleOk then return nil, battleCode, battleMessage end
|
||||
elseif copy.runtime.battle ~= nil then
|
||||
return nil, "invalid_checkpoint",
|
||||
"Overworld checkpoint contains unexpected battle state."
|
||||
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)
|
||||
if checkpoint.kind == "battle" then
|
||||
BattleCheckpoint.restore(game, checkpoint, dataCopy)
|
||||
else
|
||||
restoreRng(checkpoint.rng)
|
||||
end
|
||||
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
|
||||
|
||||
local function firstDifference(a, b, path)
|
||||
path = path or "$"
|
||||
if type(a) ~= type(b) then return path .. " (type)" end
|
||||
if type(a) ~= "table" then
|
||||
if a ~= b then return path end
|
||||
return nil
|
||||
end
|
||||
for key, value in pairs(a) do
|
||||
if b[key] == nil and value ~= nil then
|
||||
return path .. "." .. tostring(key) .. " (missing)"
|
||||
end
|
||||
local found = firstDifference(value, b[key], path .. "." .. tostring(key))
|
||||
if found then return found end
|
||||
end
|
||||
for key, value in pairs(b) do
|
||||
if a[key] == nil and value ~= nil then
|
||||
return path .. "." .. tostring(key) .. " (unexpected)"
|
||||
end
|
||||
end
|
||||
return nil
|
||||
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 validated.rng == nil then restored.rng = nil end
|
||||
if restored and equalData(restored, validated) then return true end
|
||||
err = restored and ("restored state differed at "
|
||||
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
|
||||
or ("restored state could not be captured: " .. 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
|
||||
@@ -84,6 +84,15 @@ function Data:applyVersionedFieldData()
|
||||
-- Yellow caches carry the wrong demo species too. The fixed import
|
||||
-- manifest below stamps RATTATA for fresh imports.
|
||||
self.field.oldManBattle = { species = "RATTATA", level = 5 }
|
||||
-- The Oak-speech show-off mon is the player's Pikachu in Yellow
|
||||
-- (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo)
|
||||
-- but caches imported before the manifest carried demoSpecies fell
|
||||
-- back to Red's NIDORINO (#915). The fixed import manifest below
|
||||
-- stamps PIKACHU for fresh imports; fill it here for stale caches.
|
||||
local oakSpeech = self.field.oakSpeech
|
||||
if type(oakSpeech) == "table" and not oakSpeech.demoSpecies then
|
||||
oakSpeech.demoSpecies = "PIKACHU"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1130,4 +1130,28 @@ 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
|
||||
|
||||
-- Install a reconstructed battle without calling BattleState:enter(), whose
|
||||
-- transition, intro queues and battle-start side effects already happened in
|
||||
-- the checkpointed timeline.
|
||||
function Game:restoreCheckpointBattle(battle)
|
||||
if self.stack:top() ~= self.overworld then
|
||||
error("battle checkpoint requires a reconstructed overworld base", 0)
|
||||
end
|
||||
self.stack.states[#self.stack.states + 1] = battle
|
||||
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
|
||||
end
|
||||
|
||||
return Game
|
||||
|
||||
+4
-3
@@ -368,11 +368,12 @@ function Music.setSurfing(data, surfing)
|
||||
if play then Music.play(data, play, nil, { reason = "map" }) end
|
||||
end
|
||||
|
||||
-- battle themes; kind = "wild"|"trainer"|"gym"|"final"
|
||||
function Music.playBattle(data, kind, trainerId)
|
||||
-- battle themes; kind = "wild"|"trainer"|"gym"|"final". `song`, when
|
||||
-- given, overrides the kind's default -- a mod-set trainer battleTheme.
|
||||
function Music.playBattle(data, kind, trainerId, song)
|
||||
local b = data.audio and data.audio.battle
|
||||
if b then
|
||||
Music.play(data, b[kind] or b.wild, nil,
|
||||
Music.play(data, song or b[kind] or b.wild, nil,
|
||||
{ reason = "battle", kind = kind, trainerId = trainerId })
|
||||
end
|
||||
end
|
||||
|
||||
+151
-5
@@ -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()
|
||||
@@ -351,6 +358,22 @@ local function readTable(fs, name)
|
||||
return SaveSerializer.decode(body)
|
||||
end
|
||||
|
||||
-- Deep-copy a value folded in from the on-disk decode so the returned
|
||||
-- options table never aliases the file's nested tables (SaveData must not
|
||||
-- depend on src/mods/Merge.lua for this). Options data is plain tables of
|
||||
-- strings/numbers/booleans/tables, so a cycle guard is belt-and-braces.
|
||||
local function deepCopy(v, seen)
|
||||
if type(v) ~= "table" then return v end
|
||||
seen = seen or {}
|
||||
if seen[v] then return seen[v] end
|
||||
local copy = {}
|
||||
seen[v] = copy
|
||||
for k, val in pairs(v) do
|
||||
copy[deepCopy(k, seen)] = deepCopy(val, seen)
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
-- the stub filesystem some headless harnesses inject has no remove; a
|
||||
-- lingering tmp/bak there is harmless
|
||||
local function remove(fs, name)
|
||||
@@ -364,13 +387,48 @@ end
|
||||
-- options round-trip headless (no love global).
|
||||
function SaveData.saveOptions(opts, fs)
|
||||
fs = persistFs(fs)
|
||||
-- #932: options.lua is a WHOLE-FILE rewrite, so a caller that hands over a
|
||||
-- PARTIAL table (just the keys it changed) would silently drop every key it
|
||||
-- does not mention -- launcher-only keys like lastVersion, and keys the
|
||||
-- launcher set (battleBg, tilt...) all fall back to defaults. Read the
|
||||
-- on-disk file FIRST and fold caller-absent values underneath, so a delta
|
||||
-- write changes only what it names.
|
||||
--
|
||||
-- A table holding EVERY defaultOptions key is a full snapshot
|
||||
-- (loadOptions() results, game.save.options, the RESET REBINDS /
|
||||
-- activeProfile-drop paths) and stays authoritative: its absent keys are
|
||||
-- deliberate deletions, so nothing folds for it. Partial tables get every
|
||||
-- on-disk key they do not provide folded in (deep-copied so the caller's
|
||||
-- table is never aliased). This is the reconciling rule: bindings and
|
||||
-- activeProfile -- not defaultOptions members -- can be deleted by their
|
||||
-- sites precisely because those sites always write full tables.
|
||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||
local isFull = type(opts) == "table"
|
||||
if isFull then
|
||||
for k in pairs(SaveData.defaultOptions()) do
|
||||
if opts[k] == nil then isFull = false break end
|
||||
end
|
||||
end
|
||||
if not isFull then
|
||||
local merged = {}
|
||||
if type(opts) == "table" then
|
||||
for k, v in pairs(opts) do merged[k] = v end
|
||||
end
|
||||
if type(onDisk) == "table" then
|
||||
for k, v in pairs(onDisk) do
|
||||
if k ~= "modOptions" and merged[k] == nil then
|
||||
merged[k] = deepCopy(v)
|
||||
end
|
||||
end
|
||||
end
|
||||
opts = merged
|
||||
end
|
||||
opts = SaveData.mergeOptions(opts)
|
||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||
-- underneath (newest value winning per key) so one caller's partial
|
||||
-- write cannot clobber another mod's persisted keys. Every other
|
||||
-- option stays on the shallow path.
|
||||
local onDisk = readTable(fs, OPTIONS_FILENAME)
|
||||
if onDisk and type(onDisk.modOptions) == "table" then
|
||||
if type(onDisk) == "table" and type(onDisk.modOptions) == "table" then
|
||||
local merged = {}
|
||||
for modId, bucket in pairs(onDisk.modOptions) do
|
||||
merged[modId] = bucket
|
||||
@@ -479,6 +537,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 +877,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 +971,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 +1182,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 +1612,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
|
||||
|
||||
+15
-13
@@ -156,18 +156,22 @@ local function allRequiredFilesExist(version)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- A developer checkout / Python build leaves Red's generated data in the
|
||||
-- physfs SOURCE at the un-prefixed root (the checked-out data/generated and
|
||||
-- assets/generated); it is always current and never moves into red/. Only
|
||||
-- Red ships this way (Blue/Yellow are import-only). The check goes through
|
||||
-- love.filesystem directly so the red/ cache prefix cannot hide the source
|
||||
-- tree, and the realDirectory test keeps a save-dir cache from counting.
|
||||
local function sourceTreeHasData()
|
||||
-- A developer checkout / Python build leaves generated data in the physfs
|
||||
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
|
||||
-- Imported Red caches still live under red/. Check source paths directly so
|
||||
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
|
||||
-- from counting as current source data.
|
||||
local function sourceTreeHasData(version)
|
||||
if not love.filesystem.getRealDirectory then return false end
|
||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||
for _, path in ipairs(REQUIRED_FILES) do
|
||||
if love.filesystem.getInfo(path, "file") == nil then return false end
|
||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||
end
|
||||
local real = love.filesystem.getRealDirectory(REQUIRED_FILES[1])
|
||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||
end
|
||||
local path = prefix .. REQUIRED_FILES[1]
|
||||
local real = love.filesystem.getRealDirectory(path)
|
||||
return real == love.filesystem.getSource()
|
||||
end
|
||||
|
||||
@@ -244,10 +248,8 @@ function RomImporter.isReady(version)
|
||||
-- save-directory copy that would otherwise shadow it at runtime.
|
||||
purgeSaveDirCache()
|
||||
end
|
||||
-- Red generated data in the physfs source (developer checkout / Python
|
||||
-- build) is always current; Blue is import-only and falls through to the
|
||||
-- version-marker gate.
|
||||
if version == "red" and sourceTreeHasData() then return true end
|
||||
-- Generated data in a developer checkout / Python build is always current.
|
||||
if sourceTreeHasData(version) then return true end
|
||||
local saved = CacheFs.prefix
|
||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||
local marker = CacheFs.read(MARKER_PATH)
|
||||
|
||||
@@ -171,4 +171,26 @@ function Json.decode(s)
|
||||
return nil, v
|
||||
end
|
||||
|
||||
-- For an HTTP response that was meant to carry JSON but did not. Returns nil
|
||||
-- when `s` starts like a JSON object or array (the only shapes the update and
|
||||
-- index endpoints publish), otherwise a short message naming what the server
|
||||
-- actually sent -- so callers surface "the response was an HTML page/plain
|
||||
-- text, not JSON (it starts with ...)" instead of leaking the decoder's
|
||||
-- low-level "unexpected character 'E'" assert at the first byte of an error
|
||||
-- page or plain-text outage message.
|
||||
function Json.describeUnexpected(s)
|
||||
if type(s) ~= "string" then
|
||||
return "the response had no body to decode"
|
||||
end
|
||||
local first = s:match("^%s*(.)")
|
||||
if first == "{" or first == "[" then return nil end
|
||||
local preview = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if preview == "" then
|
||||
return "the response was empty, not JSON"
|
||||
end
|
||||
if #preview > 60 then preview = preview:sub(1, 57) .. "..." end
|
||||
local kind = (first == "<") and "an HTML page" or "plain text"
|
||||
return ("the response was %s, not JSON (it starts with %q)"):format(kind, preview)
|
||||
end
|
||||
|
||||
return Json
|
||||
|
||||
+88
-82
@@ -10,6 +10,7 @@ local Net = require("src.link.Net")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Session = require("src.link.Session")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -44,10 +45,8 @@ local function forceLevelLabel(v)
|
||||
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
|
||||
end
|
||||
|
||||
-- stages before any Net object is meaningfully "this session's link" --
|
||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
||||
-- closed checks below skip these rather than keying off self.net's
|
||||
-- presence alone
|
||||
-- stages before a successful transport has become this link's Session;
|
||||
-- terminal checks skip them rather than keying off self.net's presence
|
||||
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
|
||||
|
||||
-- how long the host waits for a v2 hello before deciding the peer predates
|
||||
@@ -70,6 +69,16 @@ local function ipDigits(ip)
|
||||
return digits
|
||||
end
|
||||
|
||||
local function openSession(role, connect)
|
||||
local transport = Net.new()
|
||||
if connect(transport) then
|
||||
return Session.new(transport, { role = role, kind = "link" })
|
||||
end
|
||||
local detail = transport.error or "?"
|
||||
transport:close()
|
||||
return nil, detail
|
||||
end
|
||||
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
@@ -89,12 +98,15 @@ end
|
||||
-- "connecting with this code", same as if the player had typed it in
|
||||
function LinkState.newJoinOnline(game, code)
|
||||
local self = LinkState.new(game)
|
||||
self.net = Net.new()
|
||||
if self.net:joinOnline(nil, code) then
|
||||
local session, detail = openSession("guest", function(transport)
|
||||
return transport:joinOnline(nil, code)
|
||||
end)
|
||||
if session then
|
||||
self.net = session
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
end
|
||||
return self
|
||||
end
|
||||
@@ -163,21 +175,13 @@ end
|
||||
-- take the peer's hello out of the inbox without eating anything that
|
||||
-- shares the batch with it
|
||||
function LinkState:pollHello()
|
||||
local msgs = self.net:poll()
|
||||
local keep, got = {}, false
|
||||
for _, msg in ipairs(msgs) do
|
||||
if msg.type == "hello" and not self.peerHello then
|
||||
self.peerHello = msg
|
||||
self.peerName = msg.name
|
||||
got = true
|
||||
else
|
||||
keep[#keep + 1] = msg
|
||||
end
|
||||
local message
|
||||
if not self.peerHello then message = self.net:take("hello") end
|
||||
if message then
|
||||
self.peerHello = message
|
||||
self.peerName = message.name
|
||||
end
|
||||
for i = #keep, 1, -1 do
|
||||
table.insert(self.net.inbox, 1, keep[i])
|
||||
end
|
||||
return got, #keep > 0
|
||||
return message ~= nil, self.net:hasPending()
|
||||
end
|
||||
|
||||
function LinkState:sendHello(mode)
|
||||
@@ -220,13 +224,15 @@ function LinkState:update(dt)
|
||||
local input = self.game.input
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
local status = self.net:getStatus()
|
||||
if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith(Strings("Link error:\n%s",
|
||||
(self.net.error or "?"):sub(1, 60)))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
-- the peer vanished without a bye (only once the session FIFO drains,
|
||||
-- so a final message travelling with the disconnect still counts)
|
||||
if self.net.closed and #self.net.inbox == 0
|
||||
if status == "closed"
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
@@ -269,12 +275,15 @@ function LinkState:update(dt)
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.net = Net.new()
|
||||
if self.index == 1 then
|
||||
if self.net:host() then
|
||||
local session, detail = openSession("host", function(transport)
|
||||
return transport:host()
|
||||
end)
|
||||
if session then
|
||||
self.net = session
|
||||
self.stage = "hosting"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
end
|
||||
else
|
||||
self.stage = "addrEntry"
|
||||
@@ -289,11 +298,14 @@ function LinkState:update(dt)
|
||||
self.index = 2
|
||||
elseif input:wasPressed("a") then
|
||||
if self.index == 1 then
|
||||
self.net = Net.new()
|
||||
if self.net:hostOnline() then
|
||||
local session, detail = openSession("host", function(transport)
|
||||
return transport:hostOnline()
|
||||
end)
|
||||
if session then
|
||||
self.net = session
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
@@ -327,11 +339,14 @@ function LinkState:update(dt)
|
||||
CodeEntry.right(self.codeEntry)
|
||||
elseif input:wasPressed("a") then
|
||||
local code = CodeEntry.text(self.codeEntry)
|
||||
self.net = Net.new()
|
||||
if self.net:joinOnline(nil, code) then
|
||||
local session, detail = openSession("guest", function(transport)
|
||||
return transport:joinOnline(nil, code)
|
||||
end)
|
||||
if session then
|
||||
self.net = session
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -367,10 +382,15 @@ function LinkState:update(dt)
|
||||
+ self.addr[base + 2] * 10
|
||||
+ self.addr[base + 3])
|
||||
end
|
||||
if self.net:join(table.concat(octets, ".")) then
|
||||
local address = table.concat(octets, ".")
|
||||
local session, detail = openSession("guest", function(transport)
|
||||
return transport:join(address)
|
||||
end)
|
||||
if session then
|
||||
self.net = session
|
||||
self.stage = "joining"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -428,19 +448,11 @@ function LinkState:update(dt)
|
||||
|
||||
elseif self.stage == "waitMode" then -- guest waits for host's pick
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "hello" then
|
||||
self.peerHello = msg
|
||||
self.peerName = msg.name
|
||||
-- the host's next messages (party, ...) can share this batch;
|
||||
-- put them back so the new stage's poll sees them
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
self:decideCompat(msg.mode, false)
|
||||
break
|
||||
end
|
||||
local message = self.net:take("hello")
|
||||
if message then
|
||||
self.peerHello = message
|
||||
self.peerName = message.name
|
||||
self:decideCompat(message.mode, false)
|
||||
end
|
||||
|
||||
elseif self.stage == "notice" then
|
||||
@@ -456,40 +468,34 @@ function LinkState:update(dt)
|
||||
|
||||
elseif self.stage == "battleWait" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "party" then
|
||||
-- the host owns this rule (same as mode); the guest only learns
|
||||
-- it here, off the host's own party message
|
||||
if not self.isHost then self.forceLevel = msg.forceLevel end
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local opts = {
|
||||
myParty = Protocol.packParty(self.game.save.party),
|
||||
theirParty = msg.mons,
|
||||
theirName = self.peerName or "FOE",
|
||||
seed = self.isHost and self.linkSeed or msg.seed,
|
||||
verdict = self.verdict,
|
||||
strict = Handshake.strict(self.verdict),
|
||||
forceLevel = self.forceLevel,
|
||||
}
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, opts)
|
||||
else
|
||||
battle, why = LinkBattle.newGuest(self.game, self.net, opts)
|
||||
end
|
||||
if not battle then
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
|
||||
return
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "battleRunning"
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
break
|
||||
local message = self.net:take("party")
|
||||
if message then
|
||||
-- the host owns this rule (same as mode); the guest only learns
|
||||
-- it here, off the host's own party message
|
||||
if not self.isHost then self.forceLevel = message.forceLevel end
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local opts = {
|
||||
myParty = Protocol.packParty(self.game.save.party),
|
||||
theirParty = message.mons,
|
||||
theirName = self.peerName or "FOE",
|
||||
seed = self.isHost and self.linkSeed or message.seed,
|
||||
verdict = self.verdict,
|
||||
strict = Handshake.strict(self.verdict),
|
||||
forceLevel = self.forceLevel,
|
||||
}
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, opts)
|
||||
else
|
||||
battle, why = LinkBattle.newGuest(self.game, self.net, opts)
|
||||
end
|
||||
if not battle then
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
|
||||
return
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "battleRunning"
|
||||
end
|
||||
|
||||
elseif self.stage == "battleRunning" then
|
||||
|
||||
+5
-6
@@ -207,7 +207,7 @@ function Net:send(msg)
|
||||
end
|
||||
if self.peerEnd then -- loopback: re-encode through json like the wire
|
||||
local decoded = Json.decode(Json.encode(msg))
|
||||
if decoded and not self.peerEnd.closed then
|
||||
if decoded ~= nil and not self.peerEnd.closed then
|
||||
table.insert(self.peerEnd.inbox, decoded)
|
||||
end
|
||||
return
|
||||
@@ -256,13 +256,12 @@ end
|
||||
|
||||
function Net:handleTCPLine(line)
|
||||
local msg = Json.decode(line)
|
||||
if not msg then
|
||||
if msg == nil then
|
||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||
return
|
||||
end
|
||||
if not handleGenericRelayControl(self, msg) then
|
||||
table.insert(self.inbox, msg)
|
||||
end
|
||||
if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end
|
||||
table.insert(self.inbox, msg)
|
||||
end
|
||||
|
||||
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
||||
@@ -352,7 +351,7 @@ function Net:update()
|
||||
end
|
||||
elseif event.type == "receive" then
|
||||
local msg = Json.decode(event.data)
|
||||
if msg then
|
||||
if msg ~= nil then
|
||||
table.insert(self.inbox, msg)
|
||||
else
|
||||
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
local Session = {}
|
||||
Session.__index = Session
|
||||
|
||||
local VALID_ROLES = { host = true, guest = true }
|
||||
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
|
||||
|
||||
function Session.new(transport, options)
|
||||
assert(type(transport) == "table", "Session.new requires a transport")
|
||||
assert(type(options) == "table", "Session.new requires options")
|
||||
assert(VALID_ROLES[options.role], "Session role must be host or guest")
|
||||
assert(type(options.kind) == "string" and options.kind ~= "",
|
||||
"Session kind must be a non-empty string")
|
||||
for _, method in ipairs(REQUIRED_METHODS) do
|
||||
assert(type(transport[method]) == "function",
|
||||
"Session transport requires " .. method)
|
||||
end
|
||||
|
||||
local self = setmetatable({
|
||||
_transport = transport,
|
||||
_role = options.role,
|
||||
_kind = options.kind,
|
||||
_inbox = {},
|
||||
_status = "connecting",
|
||||
_terminal = nil,
|
||||
_transportCloseCalled = false,
|
||||
paired = false,
|
||||
closed = false,
|
||||
error = nil,
|
||||
code = nil,
|
||||
address = nil,
|
||||
target = nil,
|
||||
}, Session)
|
||||
self:_syncMetadata()
|
||||
self:_refreshStatus()
|
||||
return self
|
||||
end
|
||||
|
||||
function Session:_syncMetadata()
|
||||
local transport = self._transport
|
||||
self.paired = transport.paired == true
|
||||
self.code = transport.code
|
||||
self.address = transport.address
|
||||
self.target = transport.target
|
||||
end
|
||||
|
||||
function Session:_refreshStatus()
|
||||
if not self._terminal then
|
||||
self._status = self.paired and "paired" or "connecting"
|
||||
self.closed = false
|
||||
self.error = nil
|
||||
return
|
||||
end
|
||||
if #self._inbox > 0 then
|
||||
self._status = "draining"
|
||||
self.closed = false
|
||||
self.error = nil
|
||||
return
|
||||
end
|
||||
self._status = self._terminal.status
|
||||
self.closed = true
|
||||
self.error = self._terminal.status == "failed"
|
||||
and (self._terminal.detail or self._terminal.reason) or nil
|
||||
end
|
||||
|
||||
function Session:_latchTerminal(status, reason, detail)
|
||||
if self._terminal then return false end
|
||||
self._terminal = { status = status, reason = reason, detail = detail }
|
||||
self:_refreshStatus()
|
||||
return true
|
||||
end
|
||||
|
||||
function Session:_closeTransport()
|
||||
if self._transportCloseCalled then return true end
|
||||
self._transportCloseCalled = true
|
||||
local ok, detail = pcall(self._transport.close, self._transport)
|
||||
return ok, ok and nil or tostring(detail)
|
||||
end
|
||||
|
||||
function Session:getRole() return self._role end
|
||||
function Session:getKind() return self._kind end
|
||||
function Session:getStatus() return self._status end
|
||||
function Session:getFailure()
|
||||
if not self._terminal or self._terminal.status ~= "failed" then
|
||||
return nil, nil
|
||||
end
|
||||
return self._terminal.reason, self._terminal.detail
|
||||
end
|
||||
function Session:hasPending() return #self._inbox > 0 end
|
||||
|
||||
function Session:send(message)
|
||||
if self._terminal then return nil end
|
||||
return self._transport:send(message)
|
||||
end
|
||||
|
||||
function Session:update()
|
||||
if self._terminal then
|
||||
self:_refreshStatus()
|
||||
return
|
||||
end
|
||||
|
||||
local failureReason, failureDetail
|
||||
local updateOk, updateDetail = pcall(self._transport.update, self._transport)
|
||||
self:_syncMetadata()
|
||||
if not updateOk then
|
||||
failureReason, failureDetail = "transport_error", tostring(updateDetail)
|
||||
elseif self._transport.error then
|
||||
failureReason = "transport_error"
|
||||
failureDetail = tostring(self._transport.error)
|
||||
end
|
||||
|
||||
local pollOk, messages = pcall(self._transport.poll, self._transport)
|
||||
if not pollOk then
|
||||
if not failureReason then
|
||||
failureReason, failureDetail = "transport_error", tostring(messages)
|
||||
end
|
||||
elseif type(messages) ~= "table" then
|
||||
if not failureReason then
|
||||
failureReason, failureDetail = "transport_error",
|
||||
"transport poll returned non-table"
|
||||
end
|
||||
else
|
||||
for index = 1, #messages do
|
||||
local message = messages[index]
|
||||
if type(message) ~= "table" or type(message.type) ~= "string" then
|
||||
if not failureReason then
|
||||
failureReason = "protocol_error"
|
||||
failureDetail = ("message %d must be a table with string type")
|
||||
:format(index)
|
||||
end
|
||||
break
|
||||
end
|
||||
self._inbox[#self._inbox + 1] = message
|
||||
end
|
||||
end
|
||||
|
||||
self:_syncMetadata()
|
||||
if failureReason then
|
||||
self:_latchTerminal("failed", failureReason, failureDetail)
|
||||
self:_closeTransport()
|
||||
elseif self._transport.closed then
|
||||
local closeOk, closeDetail = self:_closeTransport()
|
||||
if closeOk then
|
||||
self:_latchTerminal("closed")
|
||||
else
|
||||
self:_latchTerminal("failed", "transport_error", closeDetail)
|
||||
end
|
||||
end
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
local function finishRead(self)
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
function Session:take(messageType)
|
||||
assert(type(messageType) == "string", "Session.take requires a message type")
|
||||
for index, message in ipairs(self._inbox) do
|
||||
if message.type == messageType then
|
||||
local found = table.remove(self._inbox, index)
|
||||
finishRead(self)
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Session:pollOne()
|
||||
if #self._inbox == 0 then return nil end
|
||||
local message = table.remove(self._inbox, 1)
|
||||
finishRead(self)
|
||||
return message
|
||||
end
|
||||
|
||||
function Session:poll()
|
||||
local messages = self._inbox
|
||||
self._inbox = {}
|
||||
finishRead(self)
|
||||
return messages
|
||||
end
|
||||
|
||||
function Session:close()
|
||||
if self._status == "closed" or self._status == "failed" then return end
|
||||
if self._terminal then
|
||||
self:_closeTransport()
|
||||
self:_refreshStatus()
|
||||
return
|
||||
end
|
||||
local ok, detail = self:_closeTransport()
|
||||
if ok then
|
||||
self:_latchTerminal("closed")
|
||||
else
|
||||
self:_latchTerminal("failed", "transport_error", detail)
|
||||
end
|
||||
self:_syncMetadata()
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
return Session
|
||||
+50
-51
@@ -14,6 +14,7 @@ local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local Net = require("src.link.Net")
|
||||
local Session = require("src.link.Session")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -132,12 +133,23 @@ end
|
||||
-- host / join
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function openSession(role)
|
||||
local transport = Net.new()
|
||||
if transport:connectTCP(Net.defaultRelayAddress()) then
|
||||
return Session.new(transport, { role = role, kind = "tournament" })
|
||||
end
|
||||
local detail = transport.error or "?"
|
||||
transport:close()
|
||||
return nil, detail
|
||||
end
|
||||
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
local session, detail = openSession("host")
|
||||
if not session then
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
return
|
||||
end
|
||||
self.net = session
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
self.isCreator = true
|
||||
self.participating = self.settings.participating
|
||||
@@ -156,11 +168,12 @@ function Tournament:startHosting()
|
||||
end
|
||||
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
local session, detail = openSession("guest")
|
||||
if not session then
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
return
|
||||
end
|
||||
self.net = session
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
self.isCreator = false
|
||||
self.participating = true -- joining is always to compete; only hosting can opt out
|
||||
@@ -256,22 +269,14 @@ function Tournament:sendHello(mode)
|
||||
end
|
||||
|
||||
function Tournament:pollHello()
|
||||
local msgs = self.net:poll()
|
||||
local keep, got = {}, false
|
||||
for _, msg in ipairs(msgs) do
|
||||
if msg.type == "hello" and not self.peerHello then
|
||||
self.peerHello = msg
|
||||
got = true
|
||||
else
|
||||
keep[#keep + 1] = msg
|
||||
end
|
||||
end
|
||||
for i = #keep, 1, -1 do
|
||||
table.insert(self.net.inbox, 1, keep[i])
|
||||
end
|
||||
return got
|
||||
local message
|
||||
if not self.peerHello then message = self.net:take("hello") end
|
||||
if message then self.peerHello = message end
|
||||
return message ~= nil
|
||||
end
|
||||
|
||||
-- The relay assigns a side for each match; this can differ from the immutable
|
||||
-- tournament creator/joiner role held by Session.
|
||||
function Tournament:enterMatch(msg)
|
||||
self.isHost = (msg.role == "host")
|
||||
self.opponentName = msg.opponent
|
||||
@@ -356,12 +361,13 @@ function Tournament:update(dt)
|
||||
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
local status = self.net:getStatus()
|
||||
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
and self.stage ~= "codeEntry" then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
|
||||
return
|
||||
end
|
||||
if self.net.closed and self.stage ~= "done" then
|
||||
if status == "closed" and self.stage ~= "done" then
|
||||
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||
return
|
||||
end
|
||||
@@ -370,24 +376,23 @@ function Tournament:update(dt)
|
||||
if self.stage == "matchHello" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
self:pollHello()
|
||||
if self.peerHello then self:beginMatchBattle() end
|
||||
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
|
||||
if self.peerHello then
|
||||
self:beginMatchBattle()
|
||||
return
|
||||
end
|
||||
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end
|
||||
return
|
||||
elseif self.stage == "matchWaitParty" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "party" then
|
||||
self.pendingBattleOpts.theirParty = msg.mons
|
||||
while self.net:hasPending() do
|
||||
local message = self.net:pollOne()
|
||||
if message.type == "party" then
|
||||
self.pendingBattleOpts.theirParty = message.mons
|
||||
if self.isHost then
|
||||
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
|
||||
else
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
self.pendingBattleOpts.seed = message.seed
|
||||
end
|
||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||
-- idiom truncates a call to its first result, so the second return
|
||||
-- (the specific reason) was always dropped and every failure showed
|
||||
-- the generic fallback instead of "same mods on both games" etc.
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
@@ -398,27 +403,22 @@ function Tournament:update(dt)
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
end
|
||||
-- anything after `party` in this same batch belongs to the
|
||||
-- battle now, not to Tournament -- put it back for its own poll()
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
self.activeBattle = battle
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "matchRunning"
|
||||
return
|
||||
else
|
||||
self:handleMessage(msg)
|
||||
self:handleMessage(message)
|
||||
end
|
||||
end
|
||||
return
|
||||
elseif self.stage == "spectateWait" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "spectate" and msg.msg.type == "party" then
|
||||
local inner = msg.msg
|
||||
if msg.side == "host" then
|
||||
while self.net:hasPending() do
|
||||
local message = self.net:pollOne()
|
||||
if message.type == "spectate" and message.msg.type == "party" then
|
||||
local inner = message.msg
|
||||
if message.side == "host" then
|
||||
self.spectate.hostParty = inner.mons
|
||||
self.spectate.seed = inner.seed
|
||||
else
|
||||
@@ -426,8 +426,10 @@ function Tournament:update(dt)
|
||||
end
|
||||
if self.spectate.hostParty and self.spectate.guestParty then
|
||||
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
|
||||
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
|
||||
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
|
||||
hostParty = self.spectate.hostParty,
|
||||
guestParty = self.spectate.guestParty,
|
||||
hostName = self.spectate.hostName,
|
||||
guestName = self.spectate.guestName,
|
||||
seed = self.spectate.seed,
|
||||
forceLevel = levelForWire(self.settings.forceLevel),
|
||||
})
|
||||
@@ -435,16 +437,13 @@ function Tournament:update(dt)
|
||||
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||
return
|
||||
end
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
self.activeBattle = battle
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "spectateRunning"
|
||||
return
|
||||
end
|
||||
else
|
||||
self:handleMessage(msg)
|
||||
self:handleMessage(message)
|
||||
end
|
||||
end
|
||||
return
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -191,8 +191,10 @@ end
|
||||
-- Never throws: a truncated download, an HTML error page, or a feed from a
|
||||
-- future schema all come back as a message the panel can print.
|
||||
function ModIndex.parse(jsonText, Json)
|
||||
Json = Json or require("src.link.Json")
|
||||
local notJson = Json.describeUnexpected(jsonText)
|
||||
if notJson then return nil, notJson end
|
||||
local ok, result, err = pcall(function()
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc, decodeErr = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" then
|
||||
return nil, decodeErr or "index.json is not an object"
|
||||
|
||||
@@ -140,8 +140,10 @@ end
|
||||
-- Decode a releases array (GET /repos/.../releases) into a sorted list
|
||||
-- (newest first). Releases without a .zip asset are dropped. Never throws.
|
||||
function ModUpdate.parseReleases(jsonText, modId, Json)
|
||||
Json = Json or require("src.link.Json")
|
||||
local notJson = Json.describeUnexpected(jsonText)
|
||||
if notJson then return nil, notJson end
|
||||
local ok, result, err = pcall(function()
|
||||
Json = Json or require("src.link.Json")
|
||||
local doc, decodeErr = Json.decode(jsonText)
|
||||
if type(doc) ~= "table" then
|
||||
return nil, decodeErr or "releases json is not an array"
|
||||
|
||||
@@ -577,6 +577,9 @@ R.trainers = {
|
||||
aiMods = f.opt(f.any),
|
||||
aiClass = f.opt(f.id("ai_classes")),
|
||||
brain = f.opt(f.fn),
|
||||
-- Per-trainer battle theme (an audio.songs id): overrides the
|
||||
-- kind-based default (wild/trainer/gym/final) for this trainer's
|
||||
-- battles. The victory jingle stays kind-based.
|
||||
battleTheme = f.opt(f.id("music")),
|
||||
},
|
||||
example = 'mod.content.trainers:patch("OPP_BROCK", { baseMoney = 99 })',
|
||||
|
||||
@@ -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
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
local ffi = nil
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
end
|
||||
|
||||
do
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
local ok
|
||||
ok, ffi = pcall(require, "ffi")
|
||||
if not (ok and ffi) then
|
||||
log("ffi unavailable (not LuaJIT); second display disabled")
|
||||
else
|
||||
@@ -19,6 +21,7 @@ do
|
||||
int love_android_secondary_ready();
|
||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||
void love_android_secondary_enable(int on);
|
||||
const char *love_android_poll_secondary_touch();
|
||||
]])
|
||||
local okLib, lib = pcall(ffi.load, "love")
|
||||
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
|
||||
@@ -51,6 +54,17 @@ function SecondScreen.push(imageData, w, h)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||
-- coordinates are in the submitted frame's pixel space.
|
||||
function SecondScreen.pollTouch()
|
||||
if not C then return nil end
|
||||
local ok, event = pcall(function()
|
||||
return C.love_android_poll_secondary_touch()
|
||||
end)
|
||||
if not ok or event == nil or event == ffi.NULL then return nil end
|
||||
return ffi.string(event)
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
|
||||
@@ -94,7 +94,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
||||
-- height/weight print only once owned, like the description
|
||||
@@ -105,8 +105,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
if e.heightM then
|
||||
Font.draw((("GR. %.1fm"):format(e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 64, 44)
|
||||
Font.draw((("GEW. %.1fkg"):format(e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 64, 54)
|
||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
|
||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
|
||||
@@ -265,7 +265,8 @@ function OakSpeech.new(game, onDone)
|
||||
or "assets/generated/intro/shrink2.png")
|
||||
-- RedSprite: the walking sprite the pic shrinks into (frame 0 =
|
||||
-- standing, facing down)
|
||||
local red = game.data.sprites and game.data.sprites.SPRITE_RED
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites) or {}
|
||||
local red = game.data.sprites and game.data.sprites[playerSprites.walk or "SPRITE_RED"] or game.data.sprites.SPRITE_RED
|
||||
self.walkSheet = tryImage(red and red.image)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -585,8 +585,14 @@ function OptionsMenu:update(dt)
|
||||
end
|
||||
|
||||
function OptionsMenu:draw()
|
||||
-- Through Strings, like every other label on this menu. CANCEL is
|
||||
-- appended AFTER the rows hook (see the header), which is what keeps a mod
|
||||
-- from orphaning the exit -- but it also means a translation mod never sees
|
||||
-- this string, and cannot: there is no row for it to rewrite. So the one
|
||||
-- word a Spanish player could not read on a fully translated OPTIONS menu
|
||||
-- was the way out of it.
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
|
||||
"CANCEL", #self.rows + 1)
|
||||
Strings("CANCEL"), #self.rows + 1)
|
||||
end
|
||||
|
||||
return OptionsMenu
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ local function askQuantity(game, list, count, id, cb)
|
||||
cb(1)
|
||||
return
|
||||
end
|
||||
list.footer = "How many?"
|
||||
list.footer = Strings("How many?")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
game.stack:push(QuantityBox.new(game, {
|
||||
max = count,
|
||||
|
||||
+37
-8
@@ -107,7 +107,12 @@ local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
-- resolve through Assets so a mod's derived art (save/mod-derived/)
|
||||
-- wins here the way it does for every other generated sheet -- but
|
||||
-- load uncached, because on NX the per-version overlay redirects the
|
||||
-- open itself and a cached image would leak across Yellow/Blue boots
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
require("src.render.Assets").resolve(path))
|
||||
return ok and img or nil
|
||||
end
|
||||
|
||||
@@ -151,13 +156,28 @@ function TitleState.new(game, opts)
|
||||
-- branding comes from field.title with the shipped art as fallback, so
|
||||
-- a total conversion rebrands the title without replacing the screen
|
||||
local title = (game.data.field and game.data.field.title) or {}
|
||||
-- field.title itself is extraction data the field schema never exposes;
|
||||
-- boot.title is the mod-reachable half of the same seam, so its keys
|
||||
-- override here (a localized ribbon, a rebranded logo)
|
||||
local boot = game.data.field and game.data.field.boot
|
||||
if boot and type(boot.title) == "table" then
|
||||
local merged = {}
|
||||
for key, value in pairs(title) do merged[key] = value end
|
||||
for key, value in pairs(boot.title) do merged[key] = value end
|
||||
title = merged
|
||||
end
|
||||
self.title = title
|
||||
self.logo = tryImage(imagePath(title.logo)
|
||||
or "assets/logo/pokemon_logo.png")
|
||||
-- versionRibbon is the file-12 key; version is the importer's
|
||||
-- versionRibbon is the file-12 key; version is the importer's. The
|
||||
-- vanilla sheet is two fragments the draw pass repositions, so an
|
||||
-- explicit ribbon (a conversion's or a translation's continuous art)
|
||||
-- draws whole instead.
|
||||
self.versionFull = imagePath(title.versionRibbon) ~= nil
|
||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||
or "assets/generated/title/red_version.png")
|
||||
self.player = tryImage("assets/generated/title/player.png")
|
||||
self.player = tryImage(imagePath(title.player)
|
||||
or "assets/generated/title/player.png")
|
||||
self.blue = GameVersion.isBlue()
|
||||
self.yellow = GameVersion.isYellow()
|
||||
or title.layout == "yellow_pikachu"
|
||||
@@ -347,8 +367,12 @@ function ContinueInfo:draw()
|
||||
-- box at (4,7), 8x14 content; labels double-spaced from (5,9)
|
||||
Font.drawBox(4, 7, 16, 10)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PLAYER"), 40, 72)
|
||||
Font.draw((save.player and save.player.name) or "RED", 96, 72)
|
||||
-- the name follows the label's real width (one space after it), so a
|
||||
-- localized label longer than PLAYER's six glyphs cannot run into it
|
||||
local playerLabel = Strings("PLAYER")
|
||||
Font.draw(playerLabel, 40, 72)
|
||||
Font.draw((save.player and save.player.name) or "RED",
|
||||
math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72)
|
||||
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
||||
Font.draw(Strings("BADGES"), 40, 88)
|
||||
Font.draw(("%2d"):format(badges), 128, 88)
|
||||
@@ -401,8 +425,10 @@ function TitleState:openMenu()
|
||||
end
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
menu.titleUiBox = { 0, 0, 12, th - 1 }
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
||||
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
||||
-- recolor zone follows the box's real width instead of the vanilla 13.
|
||||
menu.titleUiBox = { 0, 0, menu.tw - 1, th - 1 }
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
@@ -490,7 +516,10 @@ function TitleState:draw()
|
||||
-- the Yellow fallback layout draws no ribbon at all.
|
||||
if self.version and not self.yellow then
|
||||
local iw, ih = self.version:getDimensions()
|
||||
if self.blue then
|
||||
if self.versionFull then
|
||||
-- a continuous ribbon (versionRibbon) centers as one piece
|
||||
love.graphics.draw(self.version, math.floor((160 - iw) / 2), 64)
|
||||
elseif self.blue then
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
||||
else
|
||||
|
||||
@@ -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
|
||||
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"),
|
||||
})
|
||||
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.
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
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
|
||||
@@ -760,8 +764,8 @@ function OverworldState:pushBattle(battle)
|
||||
local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0
|
||||
-- the battle theme starts with the wipe, not after it
|
||||
-- (audio/play_battle_music.asm runs before the transition)
|
||||
if battle.computeMusicKind then
|
||||
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
||||
if battle.playBattleTheme then
|
||||
battle:playBattleTheme()
|
||||
end
|
||||
|
||||
-- The fade back in from white on the way out is BattleState:finish()'s
|
||||
@@ -969,8 +973,14 @@ function OverworldState:update(dt)
|
||||
-- the bird carries the player in on landing, with its own
|
||||
-- SFX_FLY (EnterMapAnim .flyAnimation)
|
||||
self.arriveWarp = "fly"
|
||||
-- keep the sprite hidden through the warp fade-out (#916): flyAnim
|
||||
-- just went nil but flyArrive is not armed until startWarpTo's
|
||||
-- midpoint, and the overworld keeps drawing beneath the veil, so
|
||||
-- without this the trainer pops back in at the old cell for 32 frames
|
||||
self.playerHidden = true
|
||||
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
|
||||
else
|
||||
self.playerHidden = false
|
||||
self.player.inputLocked = false
|
||||
end
|
||||
return
|
||||
@@ -1002,6 +1012,10 @@ function OverworldState:update(dt)
|
||||
self.player.spinFrames = nil
|
||||
self.player.spinRise = nil
|
||||
self.player.inputLocked = false
|
||||
-- keep the sprite hidden through the warp fade-out (#916): the spin is
|
||||
-- over but the arrival spin-drop is not armed until startWarpTo's
|
||||
-- midpoint, so without this the standing trainer shows under the veil
|
||||
self.playerHidden = true
|
||||
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
||||
return
|
||||
end
|
||||
@@ -3045,6 +3059,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter",
|
||||
map = self.map.id,
|
||||
npcId = npc.id,
|
||||
trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty or 1,
|
||||
event = header and header.event or nil,
|
||||
}
|
||||
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
||||
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
||||
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
||||
@@ -3582,6 +3604,10 @@ function OverworldState:onStepComplete()
|
||||
end
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "wild_encounter",
|
||||
map = self.map.id,
|
||||
}
|
||||
-- map.ghostBattles: unidentifiable without the named item (the
|
||||
-- Pokemon Tower's Silph Scope)
|
||||
local ghost = Map.ghostBattles(self.map.def)
|
||||
@@ -3986,6 +4012,39 @@ function OverworldState:afterBattle(result, battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- Rebind the data-only continuation attached to a supported battle checkpoint.
|
||||
-- The overworld was reconstructed first, so transient input/NPC freezes from
|
||||
-- the original encounter are intentionally not resumed.
|
||||
function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
local game = battle and battle.game
|
||||
if not game or type(origin) ~= "table" or not self.map
|
||||
or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
if origin.kind == "wild_encounter" and battle.kind == "wild" then
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
return true
|
||||
end
|
||||
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
|
||||
or origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)
|
||||
or type(origin.npcId) ~= "string" then
|
||||
return false
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
game.save.defeatedTrainers[origin.npcId] = true
|
||||
if origin.event then game.save.flags[origin.event] = true end
|
||||
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
|
||||
end
|
||||
self:afterBattle(result, battle)
|
||||
self.engaging = false
|
||||
local npc = self.npcPool and self.npcPool[origin.npcId]
|
||||
if npc then npc.frozen = false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- warps
|
||||
-- -------------------------------------------------------------------------
|
||||
@@ -4136,6 +4195,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
self.arriveWarp = nil
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
|
||||
-- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines
|
||||
-- down, so the player is never drawable mid-fade nor standing bare on the
|
||||
-- landing frame (#916)
|
||||
self.playerHidden = false
|
||||
-- The warp we land ON stays inert for the completed-step check until we
|
||||
-- physically step off it, so a warp whose destination cell is itself a
|
||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||
@@ -4860,7 +4924,8 @@ function OverworldState:drawWorld()
|
||||
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
||||
end
|
||||
for _, e in ipairs(self.entities) do
|
||||
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
|
||||
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||
and e == self.player) then
|
||||
e:draw(cam.x, cam.y)
|
||||
-- tall grass overdraws the sprite's feet (GB sprite priority);
|
||||
-- the overdraw is BG tiles, so it rides the shake offset too
|
||||
@@ -4911,7 +4976,8 @@ function OverworldState:drawWorld()
|
||||
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
|
||||
end
|
||||
for _, e in ipairs(self.entities) do
|
||||
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
|
||||
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||
and e == self.player) then
|
||||
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local Assets = require("src.render.Assets")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
@@ -14,6 +15,45 @@ local WorldAPI = {}
|
||||
WorldAPI.__index = WorldAPI
|
||||
|
||||
local NO_OVERWORLD = "no overworld"
|
||||
local overviewShades = {}
|
||||
|
||||
Assets.register(function() overviewShades = {} end)
|
||||
|
||||
local function mapTileRows(map)
|
||||
local tileset = map.tileset
|
||||
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
|
||||
local cached = overviewShades[tileset.image]
|
||||
if not cached then
|
||||
local ok, pixels = pcall(Assets.imageData, tileset.image)
|
||||
if not ok then return nil end
|
||||
cached = { pixels = pixels, shades = {} }
|
||||
overviewShades[tileset.image] = cached
|
||||
end
|
||||
local rows, perRow = {}, tileset.tilesPerRow
|
||||
for ty = 0, map.heightCells * 2 - 1 do
|
||||
local row = {}
|
||||
for tx = 0, map.widthCells * 2 - 1 do
|
||||
local tile = map:tileAt(tx, ty)
|
||||
local shade = cached.shades[tile]
|
||||
if shade == nil then
|
||||
local sum = 0
|
||||
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
|
||||
sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722
|
||||
end
|
||||
end
|
||||
shade = tostring(math.max(0, math.min(3,
|
||||
math.floor((1 - sum / 64) * 3 + 0.5))))
|
||||
cached.shades[tile] = shade
|
||||
end
|
||||
row[#row + 1] = shade
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
@@ -44,6 +84,29 @@ function WorldAPI:current()
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
function WorldAPI:mapOverview()
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||
local map, rows = ow.map, {}
|
||||
for y = 0, map.heightCells - 1 do
|
||||
local row = {}
|
||||
for x = 0, map.widthCells - 1 do
|
||||
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
|
||||
or map:isWaterCell(x, y) and "~"
|
||||
or map:isWalkableCell(x, y) and "." or " "
|
||||
end
|
||||
rows[#rows + 1] = table.concat(row)
|
||||
end
|
||||
local tileRows = mapTileRows(map)
|
||||
return { mapId = map.id, width = map.widthCells,
|
||||
height = map.heightCells, rows = rows, tileRows = tileRows,
|
||||
tileWidth = tileRows and map.widthCells * 2,
|
||||
tileHeight = tileRows and map.heightCells * 2 }
|
||||
end
|
||||
|
||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||
-- lands the player without one, like a scripted warp.
|
||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ROM-free regression tests for source-build version detection and routing."""
|
||||
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import TestCase, main, mock
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import build_rom_data # noqa: E402
|
||||
|
||||
|
||||
class BuildRomDataCliTest(TestCase):
|
||||
def run_builder(self, sha1, *extra):
|
||||
manifest = {"romSha1": sha1, "symbols": {}}
|
||||
rom = SimpleNamespace(sha1=sha1)
|
||||
with mock.patch.object(build_rom_data, "RomImage", return_value=rom), \
|
||||
mock.patch.object(build_rom_data, "load_manifest", return_value=manifest), \
|
||||
mock.patch.object(build_rom_data, "build") as build, \
|
||||
mock.patch.object(build_rom_data.os, "makedirs"), \
|
||||
redirect_stdout(StringIO()), redirect_stderr(StringIO()):
|
||||
result = build_rom_data.main([
|
||||
"--rom", "fixture.gb", "--only", "constants", *extra])
|
||||
return result, build
|
||||
|
||||
def test_blue_rom_selects_blue_manifest_and_cache_paths(self):
|
||||
result, build = self.run_builder(
|
||||
build_rom_data.CANONICAL_BLUE_SHA1)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "blue/data/generated")
|
||||
self.assertEqual(args[4], "blue/assets/generated")
|
||||
|
||||
def test_red_rom_keeps_historical_root_paths(self):
|
||||
result, build = self.run_builder(build_rom_data.CANONICAL_RED_SHA1)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "data/generated")
|
||||
self.assertEqual(args[4], "assets/generated")
|
||||
|
||||
def test_explicit_output_paths_are_preserved(self):
|
||||
result, build = self.run_builder(
|
||||
build_rom_data.CANONICAL_YELLOW_SHA1,
|
||||
"--out", "/tmp/custom-data", "--assets", "/tmp/custom-assets")
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
args = build.call_args.args
|
||||
self.assertEqual(args[3], "/tmp/custom-data")
|
||||
self.assertEqual(args[4], "/tmp/custom-assets")
|
||||
|
||||
def test_unknown_rom_is_rejected_before_build(self):
|
||||
unknown = "0" * 40
|
||||
result, build = self.run_builder(unknown)
|
||||
|
||||
self.assertEqual(result, 1)
|
||||
build.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Battle checkpoints are exposed only at a settled, reconstructable player
|
||||
-- decision boundary. This suite is ROM-free and exercises the public engine
|
||||
-- checkpoint capability against the fixture battle implementation.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint boundary")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = save.player.map },
|
||||
player = {
|
||||
cellX = save.player.x, cellY = save.player.y,
|
||||
facing = save.player.facing, surfing = false,
|
||||
},
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase = "menu"
|
||||
battle.queue = {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter" }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, overworld, battle
|
||||
end
|
||||
|
||||
local game, overworld, battle = makeGame()
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled standard wild battle is a checkpoint boundary")
|
||||
|
||||
local function refused(mutator, code, label)
|
||||
local game2, ow2, battle2 = makeGame()
|
||||
mutator(game2, ow2, battle2)
|
||||
local capability = Checkpoint.inspect(game2)
|
||||
T.check(capability.canCapture == false and capability.reason == code,
|
||||
label .. ": " .. tostring(capability.reason))
|
||||
end
|
||||
|
||||
refused(function(_, _, b) b.phase = "messages" end,
|
||||
"battle_phase_busy", "message phase is rejected")
|
||||
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
|
||||
"battle_phase_busy", "nonempty action queue is rejected")
|
||||
refused(function(_, _, b) b.waitFrames = 1 end,
|
||||
"battle_phase_busy", "partial wait is rejected")
|
||||
refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
|
||||
"battle_phase_busy", "unfinished HP display synchronization is rejected")
|
||||
refused(function(_, _, b) b.player.mustRecharge = true end,
|
||||
"battle_phase_busy", "automatic locked action is rejected")
|
||||
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
|
||||
"script_busy", "suspended script beneath battle is rejected")
|
||||
refused(function(_, _, b) b.checkpointOrigin = nil end,
|
||||
"battle_origin_unsupported", "unknown completion closure is rejected")
|
||||
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
|
||||
"battle_variant_unsupported", "Safari battle is rejected")
|
||||
refused(function(_, _, b) b.ghost = true end,
|
||||
"battle_variant_unsupported", "ghost battle is rejected")
|
||||
refused(function(_, _, b) b.demo = true end,
|
||||
"battle_variant_unsupported", "old-man demo is rejected")
|
||||
refused(function(_, _, b) b.kind = "link" end,
|
||||
"link_battle_unsupported", "link battle is rejected")
|
||||
|
||||
-- Ordinary overworld behavior remains unchanged by the battle branch.
|
||||
game.stack.states[2] = nil
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "overworld",
|
||||
}, "settled overworld remains supported")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Data-only capture of a settled battle checkpoint, including deterministic
|
||||
-- gameplay RNG and normalized object-reference sets.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint capture")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
|
||||
local randomState = "fixture-rng-A"
|
||||
love.math.getRandomState = function() return randomState end
|
||||
love.math.setRandomState = function(state) randomState = state end
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 20),
|
||||
Pokemon.new(Data, "FIXMON_C", 15),
|
||||
}
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing = self.player.facing
|
||||
target.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1",
|
||||
}
|
||||
else
|
||||
battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local game, battle = makeGame("wild")
|
||||
battle.turnCount = 7
|
||||
battle.runAttempts = 2
|
||||
battle.payDay = 45
|
||||
battle.player.stages.attack = 2
|
||||
battle.player.confusedTurns = 3
|
||||
battle.player.curTypes = { "FIRE", "FLYING" }
|
||||
local originalMoveId = battle.player.curMoves[1].id
|
||||
battle.player.curMoves[1].id = "FIX_CUT"
|
||||
battle.player.curMoves[1].mimic = true
|
||||
battle.mimicRestores = {
|
||||
{ battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId },
|
||||
}
|
||||
battle.enemy.mon.hp = battle.enemy.mon.hp - 4
|
||||
battle.enemy.shownHP = battle.enemy.mon.hp
|
||||
battle.enemy.stages.defense = -1
|
||||
battle.enemy.aiLayer2 = 1
|
||||
battle.enemy.thrashMove = battle.enemy.curMoves[1]
|
||||
battle.enemy.thrashTurns = 2
|
||||
battle.participants = { [game.save.party[1]] = true,
|
||||
[game.save.party[2]] = true }
|
||||
battle.leveledUp = { [game.save.party[2]] = true }
|
||||
battle.sideToxic = { enemy = 3 }
|
||||
battle.sides[1].screens.reflect = { turns = 2 }
|
||||
battle.field.weather = { id = "fixture-rain", turns = 4 }
|
||||
|
||||
local snapshot, code, message = Checkpoint.capture(game)
|
||||
T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message))
|
||||
T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle")
|
||||
if snapshot and snapshot.kind == "battle" then
|
||||
T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured")
|
||||
T.same(snapshot.runtime.overworld,
|
||||
{ map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false },
|
||||
"return overworld point is captured")
|
||||
T.same(snapshot.runtime.battle.origin,
|
||||
{ kind = "wild_encounter", map = "FIX_TOWN" },
|
||||
"semantic continuation origin is data-only")
|
||||
T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured")
|
||||
T.eq(snapshot.runtime.battle.rulesetId, "gen1_faithful",
|
||||
"battle mechanics ruleset identity is captured")
|
||||
T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured")
|
||||
T.eq(snapshot.runtime.battle.player.stages.attack, 2,
|
||||
"player stat stages are captured")
|
||||
T.eq(snapshot.runtime.battle.player.confusedTurns, 3,
|
||||
"player volatile status is captured")
|
||||
T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" },
|
||||
"transformed battle types are captured")
|
||||
T.same(snapshot.runtime.battle.mimicRestores,
|
||||
{ { side = "player", slot = 1, id = originalMoveId } },
|
||||
"Mimic restore pointers normalize to side and move slot")
|
||||
T.eq(snapshot.runtime.battle.enemy.stages.defense, -1,
|
||||
"enemy stat stages are captured")
|
||||
T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1,
|
||||
"enemy AI selection layer is captured")
|
||||
T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1,
|
||||
"move-instance references normalize to move slots")
|
||||
T.eq(snapshot.runtime.battle.enemy.thrashMove, nil,
|
||||
"live move-instance references are not serialized as detached copies")
|
||||
T.same(snapshot.runtime.battle.participants, { 1, 2 },
|
||||
"Pokemon-keyed participants normalize to party indices")
|
||||
T.same(snapshot.runtime.battle.leveledUp, { 2 },
|
||||
"Pokemon-keyed level-up set normalizes to party indices")
|
||||
T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 },
|
||||
"data-only side extensions are captured")
|
||||
T.same(snapshot.runtime.battle.field.weather,
|
||||
{ id = "fixture-rain", turns = 4 },
|
||||
"data-only field extensions are captured")
|
||||
local encoded = SaveSerializer.encode(snapshot)
|
||||
T.check(type(encoded) == "string" and #encoded > 0,
|
||||
"battle checkpoint passes the canonical data-only serializer")
|
||||
|
||||
snapshot.save.money = 1
|
||||
snapshot.runtime.battle.player.stages.attack = -6
|
||||
T.check(game.save.money ~= 1, "checkpoint progress is detached")
|
||||
T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached")
|
||||
end
|
||||
|
||||
local trainerGame, trainer = makeGame("trainer")
|
||||
trainer.enemyIndex = 1
|
||||
trainer.aiUses = 2
|
||||
local trainerSnapshot = Checkpoint.capture(trainerGame)
|
||||
T.eq(trainerSnapshot and trainerSnapshot.kind, "battle",
|
||||
"ordinary trainer battle captures")
|
||||
if trainerSnapshot and trainerSnapshot.kind == "battle" then
|
||||
T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER",
|
||||
"trainer class is captured")
|
||||
T.eq(trainerSnapshot.runtime.battle.partyIndex, 1,
|
||||
"trainer roster index is captured")
|
||||
T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty,
|
||||
"complete enemy roster is captured")
|
||||
end
|
||||
|
||||
local extensionGame, extensionBattle = makeGame("wild")
|
||||
extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end }
|
||||
local unsafe, unsafeCode = Checkpoint.capture(extensionGame)
|
||||
T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe",
|
||||
"callback-bearing battle extensions are rejected, not stripped")
|
||||
|
||||
love.math.getRandomState = nil
|
||||
local rngGame = makeGame("wild")
|
||||
local noRng, rngCode = Checkpoint.capture(rngGame)
|
||||
T.check(noRng == nil and rngCode == "rng_state_unavailable",
|
||||
"battle capture fails closed without serializable gameplay RNG")
|
||||
|
||||
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
|
||||
T.finish()
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Engine-owned battle continuations replace unserializable onFinish closures
|
||||
-- after a persistent checkpoint reconstructs the overworld and battle.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint continuation")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local function fakeOverworld()
|
||||
local npc = { id = "FIX_TOWN_obj_1", frozen = true }
|
||||
local ow = setmetatable({
|
||||
map = { id = "FIX_TOWN" },
|
||||
npcPool = { [npc.id] = npc },
|
||||
engaging = true,
|
||||
}, { __index = OverworldState })
|
||||
ow.afterBattle = function(self, result, battle)
|
||||
self.after = { result = result, battle = battle }
|
||||
end
|
||||
ow.checkVictoryRewards = function(self, class, party)
|
||||
self.reward = { class = class, party = party }
|
||||
end
|
||||
return ow, npc
|
||||
end
|
||||
|
||||
local wildOw = fakeOverworld()
|
||||
local wildGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local wild = { game = wildGame, kind = "wild" }
|
||||
T.check(wildOw:restoreBattleContinuation(wild,
|
||||
{ kind = "wild_encounter", map = "FIX_TOWN" }) == true,
|
||||
"ordinary wild continuation binds")
|
||||
wild.onFinish("run")
|
||||
T.same(wildOw.after, { result = "run", battle = wild },
|
||||
"wild continuation returns through canonical afterBattle")
|
||||
|
||||
local trainerOw, trainerNpc = fakeOverworld()
|
||||
local trainerGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local trainer = {
|
||||
game = trainerGame, kind = "trainer",
|
||||
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
}
|
||||
local trainerOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN",
|
||||
npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1,
|
||||
event = "EVENT_BEAT_FIX_TRAINER",
|
||||
}
|
||||
T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true,
|
||||
"ordinary trainer continuation binds")
|
||||
trainer.onFinish("win")
|
||||
T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true,
|
||||
"trainer win stamps the stable object id")
|
||||
T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true,
|
||||
"trainer win stamps the header event")
|
||||
T.same(trainerOw.reward,
|
||||
{ class = "OPP_FIX_YOUNGSTER", party = 1 },
|
||||
"trainer win runs canonical victory rewards")
|
||||
T.same(trainerOw.after, { result = "win", battle = trainer },
|
||||
"trainer win returns through canonical afterBattle")
|
||||
T.check(trainerOw.engaging == false and trainerNpc.frozen == false,
|
||||
"reconstructed trainer completion leaves overworld input unfrozen")
|
||||
|
||||
local lossOw, lossNpc = fakeOverworld()
|
||||
local lossGame = { save = { defeatedTrainers = {}, flags = {} } }
|
||||
local lossBattle = {
|
||||
game = lossGame, kind = "trainer",
|
||||
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
}
|
||||
T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true,
|
||||
"trainer loss continuation binds")
|
||||
lossBattle.onFinish("lose")
|
||||
T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil,
|
||||
"trainer loss does not stamp the trainer defeated")
|
||||
T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil,
|
||||
"trainer loss does not stamp the header event")
|
||||
T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards")
|
||||
|
||||
local mismatchOw = fakeOverworld()
|
||||
T.check(mismatchOw:restoreBattleContinuation(trainer, {
|
||||
kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id,
|
||||
trainerClass = trainer.oppClass, partyIndex = 1,
|
||||
}) == false, "continuation from another map is rejected")
|
||||
T.check(mismatchOw:restoreBattleContinuation(trainer, {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id,
|
||||
trainerClass = "OPP_OTHER", partyIndex = 1,
|
||||
}) == false, "mismatched trainer identity is rejected")
|
||||
|
||||
local ow = {}
|
||||
local stack = { states = { ow } }
|
||||
function stack:top() return self.states[#self.states] end
|
||||
local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods })
|
||||
local entered, resumed = false, false
|
||||
local battle = {
|
||||
enter = function() entered = true end,
|
||||
resumeCheckpoint = function() resumed = true end,
|
||||
}
|
||||
game:restoreCheckpointBattle(battle)
|
||||
T.check(game.stack:top() == battle, "reconstructed battle is installed on stack")
|
||||
T.check(resumed == true, "checkpoint-specific battle resume path runs")
|
||||
T.check(entered == false, "ordinary battle intro is not replayed")
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,315 @@
|
||||
-- A battle checkpoint reconstructs a new controller from data, rather than
|
||||
-- retaining the original table/closure, and restores gameplay RNG exactly.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint restore")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Damage = require("src.battle.Damage")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local Music = require("src.core.Music")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
local oldRandom = love.math.random
|
||||
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
|
||||
local oldPlayBattle = Music.playBattle
|
||||
Music.playBattle = function() end
|
||||
local rng = 12345
|
||||
love.math.getRandomState = function() return tostring(rng) end
|
||||
love.math.setRandomState = function(state) rng = assert(tonumber(state)) end
|
||||
love.math.random = function(a, b)
|
||||
rng = (rng * 1103515245 + 12345) % 2147483648
|
||||
local unit = rng / 2147483648
|
||||
if a == nil then return unit end
|
||||
if b == nil then return math.floor(unit * a) + 1 end
|
||||
return a + math.floor(unit * (b - a + 1))
|
||||
end
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
save.party = {
|
||||
Pokemon.new(Data, "FIXMON_A", 20),
|
||||
Pokemon.new(Data, "FIXMON_C", 15),
|
||||
}
|
||||
-- Strip new-game defaults that are intentionally absent from the tiny
|
||||
-- fixture registry, then place the sanitized save on a fixture map.
|
||||
SaveData.validate(save, Data)
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function overworld:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing = self.player.facing
|
||||
target.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function overworld:restoreBattleContinuation(battle, origin)
|
||||
battle.onFinish = function(result)
|
||||
self.lastRestoredFinish = { result = result, origin = origin.kind }
|
||||
end
|
||||
return true
|
||||
end
|
||||
local game = setmetatable(
|
||||
{ data = Data, save = save, stack = stack, overworld = overworld },
|
||||
{ __index = GameMethods })
|
||||
function game:restoreCheckpointSave(loaded)
|
||||
self.save = loaded
|
||||
self.overworld.map = { id = loaded.player.map }
|
||||
self.overworld.player = {
|
||||
cellX = loaded.player.x, cellY = loaded.player.y,
|
||||
facing = loaded.player.facing,
|
||||
surfing = loaded.player.surfing and true or false,
|
||||
}
|
||||
self.overworld.runner = { isRunning = function() return false end }
|
||||
self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {}
|
||||
self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {}
|
||||
self.stack.states = { self.overworld }
|
||||
end
|
||||
stack.states[1] = overworld
|
||||
local battle
|
||||
if kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
|
||||
event = "EVENT_BEAT_TRAINER_1",
|
||||
}
|
||||
else
|
||||
battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
end
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local function settleOverworld(game)
|
||||
game.stack.states = { game.overworld }
|
||||
game.save.money = 999999
|
||||
game.save.party[1].hp = 1
|
||||
game.overworld.player.cellX = 8
|
||||
game.overworld.player.facing = "up"
|
||||
end
|
||||
|
||||
local game, originalBattle = makeGame("wild")
|
||||
originalBattle.turnCount = 4
|
||||
originalBattle.runAttempts = 1
|
||||
originalBattle.player.stages.speed = 3
|
||||
local originalMoveId = originalBattle.player.curMoves[1].id
|
||||
originalBattle.player.curMoves[1].id = "FIX_CUT"
|
||||
originalBattle.player.curMoves[1].mimic = true
|
||||
originalBattle.mimicRestores = {
|
||||
{ battler = originalBattle.player, entry = originalBattle.player.curMoves[1],
|
||||
id = originalMoveId },
|
||||
}
|
||||
originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5
|
||||
originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp
|
||||
originalBattle.enemy.disabledSlot = 1
|
||||
originalBattle.enemy.disabledTurns = 2
|
||||
originalBattle.enemy.aiLayer2 = 1
|
||||
originalBattle.enemy.thrashTurns = 2
|
||||
originalBattle.enemy.mon.moves = {
|
||||
{ id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 },
|
||||
}
|
||||
originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves
|
||||
originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1]
|
||||
originalBattle.participants = { [game.save.party[1]] = true }
|
||||
local checkpoint = assert(Checkpoint.capture(game))
|
||||
local encounterDef = { grass = {
|
||||
rate = 256, buckets = { 128, 256 },
|
||||
slots = { { species = "FIXMON_A", level = 4 },
|
||||
{ species = "FIXMON_B", level = 7 } },
|
||||
} }
|
||||
Encounter.load(Data)
|
||||
local function randomOutcomes(battle)
|
||||
local damage, detail = Damage.compute(battle.ruleset, battle.player,
|
||||
battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng })
|
||||
local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT,
|
||||
battle.player, battle.enemy, battle.rng)
|
||||
local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil)
|
||||
local escaped = battle:runRollVanilla(1, 100)
|
||||
local encounter = Encounter.roll(encounterDef, love.math.random)
|
||||
local nextValue = love.math.random(1, 1000000)
|
||||
return {
|
||||
damage = damage, critical = detail.crit, hit = hit,
|
||||
ai = ai.id, escaped = escaped, encounter = encounter,
|
||||
nextValue = nextValue,
|
||||
}
|
||||
end
|
||||
local expectedOutcomes = randomOutcomes(originalBattle)
|
||||
|
||||
settleOverworld(game)
|
||||
game.save.options.ruleset = "modern_clean"
|
||||
rng = 777
|
||||
local restored, code, message = Checkpoint.restore(game, checkpoint)
|
||||
T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code))
|
||||
local rebuilt = restored and game.stack:top()
|
||||
if restored then
|
||||
T.check(rebuilt ~= originalBattle, "restore creates a new battle controller")
|
||||
T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState")
|
||||
T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu")
|
||||
T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue")
|
||||
T.eq(rebuilt.turnCount, 4, "turn count roundtrips")
|
||||
T.eq(rebuilt.runAttempts, 1, "escape state roundtrips")
|
||||
T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip")
|
||||
T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1]
|
||||
and rebuilt.mimicRestores[1].battler == rebuilt.player
|
||||
and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1],
|
||||
"Mimic restore references are rebuilt against the new battler")
|
||||
rebuilt:restoreMimicked(rebuilt.player)
|
||||
T.eq(rebuilt.player.curMoves[1].id, originalMoveId,
|
||||
"restored Mimic move returns to its canonical id when battle copy leaves")
|
||||
T.eq(rebuilt.player.curMoves[1].mimic, nil,
|
||||
"restored Mimic marker clears with the battle copy")
|
||||
-- Put the checkpointed battle state back before differential recapture.
|
||||
rebuilt.player.curMoves[1].id = "FIX_CUT"
|
||||
rebuilt.player.curMoves[1].mimic = true
|
||||
rebuilt.mimicRestores = {
|
||||
{ battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId },
|
||||
}
|
||||
T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips")
|
||||
T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips")
|
||||
T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips")
|
||||
T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1],
|
||||
"multi-turn move references rebuild against the new move list")
|
||||
T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp,
|
||||
"enemy Pokemon model roundtrips")
|
||||
T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips")
|
||||
T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp,
|
||||
"party model roundtrips")
|
||||
T.eq(game.save.options.ruleset, "modern_clean",
|
||||
"current global ruleset option remains untouched")
|
||||
T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"),
|
||||
"restored battle keeps the mechanics ruleset it was captured with")
|
||||
T.same(Checkpoint.capture(game), checkpoint,
|
||||
"capture A, discard, restore A, capture A2 yields normalized A == A2")
|
||||
local replayed = randomOutcomes(rebuilt)
|
||||
T.same(replayed, expectedOutcomes,
|
||||
"damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly")
|
||||
rebuilt.onFinish("run")
|
||||
T.same(game.overworld.lastRestoredFinish,
|
||||
{ result = "run", origin = "wild_encounter" },
|
||||
"restored battle receives a reconstructed semantic continuation")
|
||||
end
|
||||
|
||||
local partyGame, switchedOriginal = makeGame("wild")
|
||||
partyGame.save.party[1].hp = 0
|
||||
local activeMon = partyGame.save.party[2]
|
||||
activeMon.status = "PAR"
|
||||
activeMon.moves[1].pp = activeMon.moves[1].pp - 4
|
||||
switchedOriginal.player = BattleState.makeBattler(
|
||||
Data, activeMon, true, partyGame.save)
|
||||
switchedOriginal.sides[1].battlers = { switchedOriginal.player }
|
||||
switchedOriginal.participants = {
|
||||
[partyGame.save.party[1]] = true,
|
||||
[partyGame.save.party[2]] = true,
|
||||
}
|
||||
local partyCheckpoint = assert(Checkpoint.capture(partyGame))
|
||||
settleOverworld(partyGame)
|
||||
partyGame.save.party[2].status = nil
|
||||
partyGame.save.party[2].moves[1].pp = 1
|
||||
restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint)
|
||||
T.check(restored == true,
|
||||
"switched/status/PP checkpoint restores: " .. tostring(message or code))
|
||||
local partyRebuilt = partyGame.stack:top()
|
||||
if restored then
|
||||
T.eq(partyGame.save.party[1].hp, 0,
|
||||
"fainted non-active party member roundtrips")
|
||||
T.check(partyRebuilt.player.mon == partyGame.save.party[2],
|
||||
"switched active Pokemon reconstructs against restored party identity")
|
||||
T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips")
|
||||
T.eq(partyRebuilt.player.mon.moves[1].pp,
|
||||
partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips")
|
||||
T.check(partyRebuilt.participants[partyGame.save.party[1]] == true
|
||||
and partyRebuilt.participants[partyGame.save.party[2]] == true,
|
||||
"participant references rebuild against fainted and active party members")
|
||||
T.same(Checkpoint.capture(partyGame), partyCheckpoint,
|
||||
"switch, faint, status and PP differential recapture is exact")
|
||||
end
|
||||
|
||||
local trainerGame, trainerOriginal = makeGame("trainer")
|
||||
trainerOriginal.turnCount = 6
|
||||
trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3
|
||||
trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp
|
||||
trainerOriginal.aiUses = 1
|
||||
trainerOriginal.participants = { [trainerGame.save.party[1]] = true,
|
||||
[trainerGame.save.party[2]] = true }
|
||||
local trainerCheckpoint = assert(Checkpoint.capture(trainerGame))
|
||||
settleOverworld(trainerGame)
|
||||
restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint)
|
||||
T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code))
|
||||
local trainerRebuilt = trainerGame.stack:top()
|
||||
if restored then
|
||||
T.check(trainerRebuilt ~= trainerOriginal,
|
||||
"trainer restore is independent of the original controller")
|
||||
T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips")
|
||||
T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips")
|
||||
T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips")
|
||||
T.same(Checkpoint.capture(trainerGame), trainerCheckpoint,
|
||||
"trainer differential recapture is exact")
|
||||
end
|
||||
|
||||
local function clone(value)
|
||||
return assert(SaveSerializer.decode(SaveSerializer.encode(value)))
|
||||
end
|
||||
|
||||
local beforeRejected = assert(Checkpoint.capture(trainerGame))
|
||||
local missingSpecies = clone(trainerCheckpoint)
|
||||
missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES"
|
||||
restored, code = Checkpoint.restore(trainerGame, missingSpecies)
|
||||
T.check(restored == false and code == "invalid_content",
|
||||
"unknown battle content is rejected before mutation")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeRejected,
|
||||
"rejected battle content leaves runtime and RNG unchanged")
|
||||
|
||||
local badOrigin = clone(trainerCheckpoint)
|
||||
badOrigin.runtime.battle.origin.npcId = nil
|
||||
restored, code = Checkpoint.restore(trainerGame, badOrigin)
|
||||
T.check(restored == false and code == "battle_origin_unsupported",
|
||||
"incomplete semantic continuation is rejected before mutation")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeRejected,
|
||||
"rejected continuation leaves runtime and RNG unchanged")
|
||||
|
||||
-- Fail after the new battle has been installed, when its RNG is applied.
|
||||
-- The transaction must reconstruct the prior battle and restore its RNG.
|
||||
local workingSetRandomState = love.math.setRandomState
|
||||
local setCalls = 0
|
||||
love.math.setRandomState = function(state)
|
||||
setCalls = setCalls + 1
|
||||
if setCalls == 1 then error("injected RNG restore failure") end
|
||||
return workingSetRandomState(state)
|
||||
end
|
||||
local beforeFailure = assert(Checkpoint.capture(trainerGame))
|
||||
local rngBeforeFailure = rng
|
||||
restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint)
|
||||
T.check(restored == false and code == "restore_failed",
|
||||
"post-install RNG failure is returned as a structured restore failure")
|
||||
T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly")
|
||||
T.same(Checkpoint.capture(trainerGame), beforeFailure,
|
||||
"failed battle restore rolls the complete runtime back exactly")
|
||||
love.math.setRandomState = workingSetRandomState
|
||||
|
||||
love.math.random = oldRandom
|
||||
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
|
||||
Music.playBattle = oldPlayBattle
|
||||
T.finish()
|
||||
@@ -0,0 +1,345 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.link.Net")
|
||||
local Json = require("src.link.Json")
|
||||
local Session = require("src.link.Session")
|
||||
|
||||
local function sessionPair()
|
||||
local hostNet, guestNet = Net.loopbackPair()
|
||||
return Session.new(hostNet, { role = "host", kind = "link" }),
|
||||
Session.new(guestNet, { role = "guest", kind = "link" })
|
||||
end
|
||||
|
||||
local function fakeTransport(options)
|
||||
options = options or {}
|
||||
local transport = {
|
||||
paired = options.paired ~= false,
|
||||
closed = false,
|
||||
error = nil,
|
||||
inbox = options.inbox or {},
|
||||
closeCount = 0,
|
||||
}
|
||||
function transport:update()
|
||||
if options.onUpdate then options.onUpdate(self) end
|
||||
if options.updateError then error(options.updateError) end
|
||||
end
|
||||
function transport:poll()
|
||||
if options.pollError then error(options.pollError) end
|
||||
local messages = self.inbox
|
||||
self.inbox = {}
|
||||
return messages
|
||||
end
|
||||
function transport:send(message)
|
||||
self.sent = message
|
||||
return true
|
||||
end
|
||||
function transport:close()
|
||||
self.closeCount = self.closeCount + 1
|
||||
self.closed = true
|
||||
if options.closeError then error(options.closeError) end
|
||||
end
|
||||
return transport
|
||||
end
|
||||
|
||||
local function readFile(path)
|
||||
local handle = assert(io.open(path, "rb"))
|
||||
local body = handle:read("*a")
|
||||
handle:close()
|
||||
return body
|
||||
end
|
||||
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
T.eq(host:getRole(), "host", "host role is assigned locally")
|
||||
T.eq(guest:getRole(), "guest", "guest role is assigned locally")
|
||||
T.eq(host:getKind(), "link", "session kind is retained")
|
||||
T.eq(host:getStatus(), "paired", "wrapped loopback starts paired")
|
||||
|
||||
guest:send({
|
||||
type = "hello", name = "BLUE", role = "host", kind = "tournament",
|
||||
})
|
||||
host:update()
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.name, "BLUE", "send forwards the original payload")
|
||||
T.eq(hello.session, nil, "send adds no session envelope")
|
||||
T.eq(host:getRole(), "host", "peer payload cannot replace local role")
|
||||
T.eq(host:getKind(), "link", "peer payload cannot replace local kind")
|
||||
end
|
||||
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
guest:send({ type = "before", sequence = 1 })
|
||||
guest:send({ type = "hello", sequence = 2 })
|
||||
guest:send({ type = "after", sequence = 3 })
|
||||
guest:send({ type = "hello", sequence = 4 })
|
||||
host:update()
|
||||
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||
|
||||
local rest = host:poll()
|
||||
T.eq(#rest, 2, "poll returns every remaining packet once")
|
||||
T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order")
|
||||
T.eq(rest[2].sequence, 4, "take preserves repeated-type order")
|
||||
T.eq(#host:poll(), 0, "poll clears the private FIFO")
|
||||
end
|
||||
|
||||
do
|
||||
local sent
|
||||
local transport = {
|
||||
paired = false,
|
||||
code = nil,
|
||||
address = "192.0.2.5:7777",
|
||||
target = "ROOM01",
|
||||
update = function(self)
|
||||
self.paired = true
|
||||
self.code = "ROOM02"
|
||||
end,
|
||||
poll = function() return {} end,
|
||||
send = function(_, message)
|
||||
sent = message
|
||||
return "queued", 7
|
||||
end,
|
||||
close = function(self) self.closed = true end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "tournament" })
|
||||
T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting")
|
||||
T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored")
|
||||
T.eq(session.target, "ROOM01", "target metadata is mirrored")
|
||||
|
||||
local outbound = { type = "ping" }
|
||||
local result, count = session:send(outbound)
|
||||
T.eq(result, "queued", "send preserves the transport's first return")
|
||||
T.eq(count, 7, "send preserves the transport's second return")
|
||||
T.eq(sent, outbound, "send forwards the original table unchanged")
|
||||
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "paired", "update observes transport pairing")
|
||||
T.eq(session.code, "ROOM02", "update refreshes relay metadata")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
onUpdate = function(self) self.closed = true end,
|
||||
closeError = "normal cleanup exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"normal-close cleanup exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error",
|
||||
"normal-close cleanup exception becomes a transport failure")
|
||||
T.check(detail:find("normal cleanup exploded", 1, true) ~= nil,
|
||||
"normal-close cleanup failure keeps its diagnostic detail")
|
||||
T.eq(session:getStatus(), "failed",
|
||||
"normal-close cleanup exception cannot report a clean close")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox = { { type = "before", sequence = 1 } }
|
||||
self.error = "socket failed"
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "transport failure has a stable reason")
|
||||
T.eq(detail, "socket failed", "transport failure retains original detail")
|
||||
T.eq(session:getStatus(), "draining", "transport failure drains valid prefix")
|
||||
T.eq(session.error, nil, "legacy error stays hidden during drain")
|
||||
T.eq(session.closed, false, "legacy closed stays false during failed drain")
|
||||
T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix")
|
||||
T.eq(session:getStatus(), "failed", "failed drain reaches failed")
|
||||
T.eq(session.error, "socket failed", "legacy error appears at terminal failure")
|
||||
transport.error = "later error"
|
||||
session:update()
|
||||
local _, latchedDetail = session:getFailure()
|
||||
T.eq(latchedDetail, "socket failed", "first terminal failure stays latched")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = {
|
||||
{ type = "before", sequence = 1 },
|
||||
false,
|
||||
{ type = "after", sequence = 3 },
|
||||
} })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
local reason = session:getFailure()
|
||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
||||
local messages = session:poll()
|
||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"table without string type is a protocol error")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "future_world_packet", value = 9 } },
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "already_decoded", value = 4 } },
|
||||
updateError = "update exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
local ok = pcall(session.update, session)
|
||||
T.check(ok, "transport update exception does not escape the game loop")
|
||||
T.eq(session:getStatus(), "draining", "update exception still drains prior inbox")
|
||||
T.eq(session:pollOne().value, 4, "decoded packet survives update exception")
|
||||
T.eq(session:getStatus(), "failed", "update exception becomes terminal failure")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ pollError = "poll exploded" })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"transport poll exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "poll exception is a transport failure")
|
||||
T.check(detail:find("poll exploded", 1, true) ~= nil,
|
||||
"poll exception keeps its diagnostic detail")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ closeError = "close exploded" })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
T.check(pcall(session.close, session),
|
||||
"transport close exception does not escape cleanup")
|
||||
T.eq(session:getFailure(), "transport_error",
|
||||
"close exception is a transport failure")
|
||||
session:close()
|
||||
T.eq(transport.closeCount, 1, "failed close is still attempted only once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport()
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:close()
|
||||
session:close()
|
||||
session:update()
|
||||
T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent")
|
||||
T.eq(session:getStatus(), "closed", "explicit close reaches closed")
|
||||
end
|
||||
|
||||
do
|
||||
T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }),
|
||||
"constructor rejects missing transport")
|
||||
local transport = fakeTransport()
|
||||
T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }),
|
||||
"constructor rejects unsupported role")
|
||||
T.check(not pcall(Session.new, transport, { role = "host", kind = "" }),
|
||||
"constructor rejects empty kind")
|
||||
end
|
||||
|
||||
do
|
||||
local senderNet, receiverNet = Net.loopbackPair()
|
||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||
senderNet:send(false)
|
||||
receiver:update()
|
||||
T.eq(receiver:getFailure(), "protocol_error",
|
||||
"loopback forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local delivered = false
|
||||
local transport = Net.new()
|
||||
transport.enetHost = {
|
||||
service = function()
|
||||
if delivered then return nil end
|
||||
delivered = true
|
||||
return { type = "receive", data = "false" }
|
||||
end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"ENet forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
local session = Session.new(transport, { role = "host", kind = "tournament" })
|
||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||
"TCP control handoff does not index a decoded scalar")
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"decoded TCP scalar reaches session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" }))
|
||||
T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned")
|
||||
transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" }))
|
||||
T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact")
|
||||
end
|
||||
|
||||
do
|
||||
local source = readFile("src/link/LinkState.lua")
|
||||
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
||||
"LinkState depends on the session boundary")
|
||||
T.check(source:find('kind = "link"', 1, true) ~= nil,
|
||||
"LinkState assigns the link session kind locally")
|
||||
T.check(source:find("self.net.inbox", 1, true) == nil,
|
||||
"LinkState never mutates a transport inbox")
|
||||
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
||||
"LinkState stores only successful session wrappers")
|
||||
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
||||
"LinkState retrieves hello without draining unrelated packets")
|
||||
T.check(source:find(':take("party")', 1, true) ~= nil,
|
||||
"LinkState leaves battle handoff packets in session order")
|
||||
T.check(source:find("getStatus()", 1, true) ~= nil,
|
||||
"LinkState uses the session lifecycle instead of raw terminal flags")
|
||||
end
|
||||
|
||||
do
|
||||
local source = readFile("src/link/Tournament.lua")
|
||||
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
||||
"Tournament depends on the session boundary")
|
||||
T.check(source:find('kind = "tournament"', 1, true) ~= nil,
|
||||
"Tournament assigns its connection role and kind locally")
|
||||
T.check(source:find("self.net.inbox", 1, true) == nil,
|
||||
"Tournament never mutates a transport inbox")
|
||||
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
||||
"Tournament stores only a successful session wrapper")
|
||||
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
||||
"Tournament retrieves match hello without draining its tail")
|
||||
T.check(source:find(":pollOne()", 1, true) ~= nil,
|
||||
"Tournament processes handoff prefixes one packet at a time")
|
||||
T.check(source:find("getStatus()", 1, true) ~= nil,
|
||||
"Tournament uses the session lifecycle instead of raw terminal flags")
|
||||
end
|
||||
|
||||
T.finish("link_session")
|
||||
@@ -142,7 +142,11 @@ do
|
||||
index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } }))
|
||||
check(index == nil and err ~= nil, "a feed with no schema_version is refused")
|
||||
index, err = ModIndex.parse("<!DOCTYPE html><html>404</html>")
|
||||
check(index == nil and err ~= nil, "an HTML error page soft-fails")
|
||||
check(index == nil and tostring(err):find("HTML", 1, true) ~= nil,
|
||||
"an HTML error page is named, not blamed on the parser")
|
||||
index, err = ModIndex.parse("Error: upstream unavailable")
|
||||
check(index == nil and tostring(err):find("not JSON", 1, true) ~= nil,
|
||||
"a plain-text error names the response")
|
||||
index, err = ModIndex.parse('{"schema_version":1}')
|
||||
check(index == nil and err ~= nil, "a feed with no mods array soft-fails")
|
||||
end
|
||||
|
||||
@@ -76,6 +76,31 @@ do
|
||||
check(path == nil and dlErr ~= nil, "empty url soft-fails")
|
||||
end
|
||||
|
||||
-- the reported bug: a non-JSON answer (plain-text error, proxy/captive
|
||||
-- prompt, outage message) used to leak the decoder's "unexpected character"
|
||||
-- assert at the first byte of the body. The guard must name what the server
|
||||
-- actually sent and never let that assert surface.
|
||||
do
|
||||
local list, err = ModUpdate.parseReleases("Error: API rate limit exceeded", "demo")
|
||||
check(list == nil and err ~= nil, "plain-text error soft-fails")
|
||||
check(tostring(err):find("not JSON", 1, true) ~= nil
|
||||
and tostring(err):find("Error: API", 1, true) ~= nil,
|
||||
"plain-text error names the response and previews what it said")
|
||||
list, err = ModUpdate.parseReleases("<!DOCTYPE html><html>502 Bad Gateway</html>", "demo")
|
||||
check(list == nil and tostring(err):find("HTML", 1, true) ~= nil,
|
||||
"an HTML error page is named as such")
|
||||
list, err = ModUpdate.parseReleases("", "demo")
|
||||
check(list == nil and tostring(err):find("empty", 1, true) ~= nil,
|
||||
"an empty response is named")
|
||||
check(tostring(err):find("unexpected character", 1, true) == nil,
|
||||
"the decoder's assert never leaks into the message")
|
||||
list = ModUpdate.parseReleases(Json.encode({
|
||||
{ tag_name = "v1.0.0", assets = {
|
||||
{ name = "demo-1.0.0.zip", browser_download_url = "https://x/d.zip" } } },
|
||||
}), "demo")
|
||||
eq(#list, 1, "the guard lets real JSON through")
|
||||
end
|
||||
|
||||
do
|
||||
local body = Json.encode({
|
||||
tag_name = "v2.0.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- #932 "Bugs reset settings": a caller that hands saveOptions a PARTIAL
|
||||
-- table (only the keys it changed) used to drop every key it did not
|
||||
-- mention -- launcher-only keys like lastVersion, and keys the launcher set
|
||||
-- (battleBg, tilt) all fell back to defaults. saveOptions now reads the
|
||||
-- on-disk file first and folds caller-absent values underneath, so a delta
|
||||
-- write changes only what it names.
|
||||
--
|
||||
-- This suite pins the three-way merge against injected filesystem stubs
|
||||
-- (the same { getInfo, read, write, remove } shape the other engine suites
|
||||
-- use). It is ROM-free (T2 engine tier).
|
||||
-- luajit tests/engine/options_partial_write_bug932.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local OPTIONS = "options.lua"
|
||||
|
||||
local function memfs()
|
||||
local files = {}
|
||||
return {
|
||||
files = files,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] ~= nil then return { type = "file" } end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Seed a save dir with the full snapshot a launcher would write: defaults,
|
||||
-- plus the keys the issue cares about. lastVersion is launcher-only (not a
|
||||
-- defaultOptions member) and must survive ANY write that does not name it.
|
||||
local function seed(fs)
|
||||
local seed = SaveData.defaultOptions()
|
||||
seed.battleBg = "world"
|
||||
seed.lastVersion = "blue"
|
||||
seed.tilt = 1
|
||||
seed.mods = { foo = true }
|
||||
seed.modOptions = { alpha = { keep = true, x = 1 } }
|
||||
check(SaveData.saveOptions(seed, fs) ~= nil, "seeding lands")
|
||||
end
|
||||
|
||||
-- ---- launcher-only keys survive a delta write
|
||||
|
||||
local fs = memfs()
|
||||
seed(fs)
|
||||
|
||||
-- loader-style partial write: only the mods bucket it manages.
|
||||
SaveData.saveOptions({ mods = { foo = true } }, fs)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
eq(opts.battleBg, "world", "a partial write keeps battleBg the launcher set")
|
||||
eq(opts.lastVersion, "blue", "a partial write keeps lastVersion (#932)")
|
||||
eq(opts.tilt, 1, "a partial write keeps tilt the launcher set")
|
||||
|
||||
-- ---- caller-present keys still win
|
||||
|
||||
SaveData.saveOptions({ battleBg = "black" }, fs)
|
||||
eq(SaveData.loadOptions(fs).battleBg, "black",
|
||||
"a key the caller DOES provide wins over the on-disk value")
|
||||
eq(SaveData.loadOptions(fs).lastVersion, "blue",
|
||||
"...while the launcher-only key is still carried")
|
||||
|
||||
-- ---- modOptions per-mod deep merge stays intact
|
||||
|
||||
SaveData.saveOptions({ modOptions = { alpha = { x = 5 } } }, fs)
|
||||
local after = SaveData.loadOptions(fs)
|
||||
eq(after.modOptions.alpha.x, 5, "newest alpha value wins the per-mod merge")
|
||||
eq(after.modOptions.alpha.keep, true, "alpha's untouched keys survive")
|
||||
eq(after.modOptions.beta, nil, "no beta was invented by the merge")
|
||||
|
||||
-- ---- full-table writes stay authoritative (bindings/activeProfile drops)
|
||||
|
||||
-- The fold must NOT resurrect a key a full snapshot deliberately deletes:
|
||||
-- the RESET REBINDS path nils bindings and the mod manager nils
|
||||
-- activeProfile, always on full loadOptions tables.
|
||||
fs = memfs()
|
||||
seed(fs)
|
||||
SaveData.saveOptions({ bindings = { a = 1 } }, fs)
|
||||
eq(SaveData.loadOptions(fs).bindings.a, 1, "bindings is not a default member")
|
||||
|
||||
local full = SaveData.loadOptions(fs)
|
||||
full.bindings = nil
|
||||
full.activeProfile = nil
|
||||
SaveData.saveOptions(full, fs)
|
||||
local reopened = SaveData.loadOptions(fs)
|
||||
eq(reopened.bindings, nil,
|
||||
"a full-snapshot deletion of bindings is NOT resurrected by the fold")
|
||||
eq(reopened.activeProfile, nil,
|
||||
"a full-snapshot deletion of activeProfile is NOT resurrected")
|
||||
eq(reopened.battleBg, "world",
|
||||
"the rest of the full snapshot is still what it was")
|
||||
|
||||
T.finish("options_partial_write_bug932")
|
||||
@@ -204,23 +204,26 @@ eq(reopened.lastVersion, "blue",
|
||||
"launcher-only keys the game never reads are carried through its write")
|
||||
|
||||
-- The corollary, and the reason the copy has to come from loadOptions: a
|
||||
-- caller that writes a partial literal instead of a loaded table drops every
|
||||
-- key it does not mention, because mergeOptions only fills DEFAULTS in around
|
||||
-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does
|
||||
-- this today; the assertion is the guard rail if someone shortcuts it.
|
||||
-- caller that writes a partial literal instead of a loaded table would drop
|
||||
-- every key it does not mention. Since #932 that drop is closed by a
|
||||
-- three-way merge -- saveOptions folds on-disk values the caller's table
|
||||
-- does not carry (lastVersion here), defaults-filling only what neither side
|
||||
-- has -- so even a delta write keeps the launcher's key alive. Nothing on
|
||||
-- the boot path writes partials today; the assertion is the guard rail if
|
||||
-- someone shortcuts it.
|
||||
SaveData.saveOptions({ battleLayout = "og" }, hop)
|
||||
eq(SaveData.loadOptions(hop).lastVersion, nil,
|
||||
"a partial write drops launcher-only keys, so the game must write the "
|
||||
.. "table loadOptions handed it")
|
||||
eq(SaveData.loadOptions(hop).lastVersion, "blue",
|
||||
"a partial write no longer drops launcher-only keys (#932)")
|
||||
|
||||
-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's
|
||||
-- write and flushed after it still wins, because saveOptions merges only
|
||||
-- modOptions from disk and every other key is last-writer-wins. Measured,
|
||||
-- not guessed (og beats a newer wide). No shipping path holds an options
|
||||
-- table across a launcher write -- HostShell.restart replaces the process on
|
||||
-- the way back to the launcher (#785, #575) and LauncherSettings.open notes
|
||||
-- its own cached table is only true while its modal covers the launcher --
|
||||
-- so closing that gap needs a three-way merge (baseline vs caller vs disk),
|
||||
-- not a straight "disk wins", which would throw away real in-game changes.
|
||||
-- Known gap, deliberately not asserted: a FULL copy taken BEFORE the
|
||||
-- launcher's write and flushed after it still wins -- a table holding every
|
||||
-- defaultOptions key is authoritative, so its og is never folded against a
|
||||
-- newer wide on disk (#932 closes the PARTIAL-write drop, not this).
|
||||
-- Measured, not guessed. No shipping path holds an options table across a
|
||||
-- launcher write -- HostShell.restart replaces the process on the way back
|
||||
-- to the launcher (#785, #575) and LauncherSettings.open notes its own
|
||||
-- cached table is only true while its modal covers the launcher -- so
|
||||
-- closing that gap needs a real three-way baseline (vs caller vs disk), not
|
||||
-- a straight "disk wins", which would throw away real in-game changes.
|
||||
|
||||
T.finish("options_write_readback_bug828")
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Opaque playthrough identity: New Game uniqueness, save/load persistence,
|
||||
-- stable legacy backfill, and version/slot isolation. No real save directory.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function() return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function legacy(version, name)
|
||||
return {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {} },
|
||||
player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 },
|
||||
flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {},
|
||||
money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
|
||||
}
|
||||
end
|
||||
|
||||
-- No-mod parity: creating/saving a vanilla playthrough allocates no tool scope.
|
||||
do
|
||||
fresh()
|
||||
local first = SaveData.newGame({ version = "red" })
|
||||
local second = SaveData.newGame({ version = "red" })
|
||||
T.eq(first.meta.playthroughId, nil,
|
||||
"New Game allocates no playthrough id before a public tool requests it")
|
||||
T.check(SaveData.save(first), "unused identity fixture saves")
|
||||
local untouched = SaveData.load("red")
|
||||
T.eq(untouched.meta.playthroughId, nil,
|
||||
"normal save/load stays identity-free when no tool uses the capability")
|
||||
|
||||
local firstId = SaveData.ensurePlaythroughId(first)
|
||||
local secondId = SaveData.ensurePlaythroughId(second)
|
||||
T.check(type(firstId) == "string" and firstId ~= "",
|
||||
"the first tool request allocates an opaque playthrough id")
|
||||
T.neq(secondId, firstId,
|
||||
"separate New Games receive separate requested playthrough ids")
|
||||
end
|
||||
|
||||
-- Dropping the id from buildMeta or save encoding must fail the roundtrip.
|
||||
do
|
||||
fresh()
|
||||
local save = SaveData.newGame({ version = "red" })
|
||||
local expected = SaveData.ensurePlaythroughId(save)
|
||||
T.check(SaveData.save(save), "identity fixture saves")
|
||||
local loaded = SaveData.load("red")
|
||||
T.eq(loaded and loaded.meta.playthroughId, expected,
|
||||
"normal save/load preserves the playthrough id")
|
||||
end
|
||||
|
||||
-- Legacy identity is persisted independently: the legacy progress bytes remain
|
||||
-- unchanged, yet two loads resolve the same id before a normal SAVE occurs.
|
||||
do
|
||||
local files = fresh()
|
||||
local raw = legacy("red", "LEGACY")
|
||||
files["save.lua"] = SaveSerializer.encode(raw)
|
||||
|
||||
local first = SaveData.load("red")
|
||||
T.eq(first and first.meta.playthroughId, nil,
|
||||
"loading a legacy save alone does not allocate tool identity")
|
||||
local id = SaveData.ensurePlaythroughId(first)
|
||||
T.check(type(id) == "string" and id ~= "",
|
||||
"a legacy save receives a playthrough id")
|
||||
local mappedOptions, mappedErr = SaveSerializer.decode(files["options.lua"] or "")
|
||||
T.check(mappedOptions ~= nil,
|
||||
"legacy identity mapping remains decodable: " .. tostring(mappedErr))
|
||||
|
||||
local slotBytes = files["saves/red/slot1.lua"]
|
||||
local onDisk = slotBytes and SaveSerializer.decode(slotBytes)
|
||||
T.eq(onDisk and onDisk.meta.playthroughId, nil,
|
||||
"legacy backfill does not rewrite normal progress")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
local second = SaveData.load("red")
|
||||
T.eq(SaveData.ensurePlaythroughId(second), id,
|
||||
"legacy backfill is stable across reload before normal SAVE")
|
||||
end
|
||||
|
||||
-- Reusing names and coordinates cannot merge identities across slots or games.
|
||||
do
|
||||
fresh()
|
||||
local redA = SaveData.createSlot("red")
|
||||
local redB = SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", redA)
|
||||
T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")),
|
||||
"seed red slot A")
|
||||
local idA = SaveData.ensurePlaythroughId(SaveData.load("red"))
|
||||
|
||||
SaveData.setActiveSlot("red", redB)
|
||||
T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")),
|
||||
"seed red slot B")
|
||||
local idB = SaveData.ensurePlaythroughId(SaveData.load("red"))
|
||||
|
||||
GameVersion.set("blue")
|
||||
local blue = SaveData.createSlot("blue")
|
||||
SaveData.setActiveSlot("blue", blue)
|
||||
T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")),
|
||||
"seed blue slot")
|
||||
local idBlue = SaveData.ensurePlaythroughId(SaveData.load("blue"))
|
||||
|
||||
T.neq(idA, idB, "two active slots do not share legacy identity")
|
||||
T.neq(idA, idBlue, "Red and Blue do not share legacy identity")
|
||||
T.neq(idB, idBlue, "every version/slot scope is isolated")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
T.finish("playthrough_identity")
|
||||
@@ -0,0 +1,42 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local name = "src.render.SecondScreen"
|
||||
local oldModule = package.loaded[name]
|
||||
local oldFfi = package.loaded.ffi
|
||||
local oldPreload = package.preload.ffi
|
||||
local null = {}
|
||||
local calls = 0
|
||||
local C = {
|
||||
love_android_secondary_ready = function() return 1 end,
|
||||
love_android_push_secondary = function() end,
|
||||
love_android_secondary_enable = function() end,
|
||||
love_android_poll_secondary_touch = function()
|
||||
calls = calls + 1
|
||||
return calls == 1 and "down,12,34" or null
|
||||
end,
|
||||
}
|
||||
local fakeFfi = {
|
||||
C = C,
|
||||
NULL = null,
|
||||
cdef = function() end,
|
||||
load = function() return C end,
|
||||
string = function(value) return value end,
|
||||
}
|
||||
|
||||
package.loaded[name] = nil
|
||||
package.loaded.ffi = nil
|
||||
package.preload.ffi = function() return fakeFfi end
|
||||
|
||||
local SecondScreen = require(name)
|
||||
T.eq(SecondScreen.pollTouch(), "down,12,34",
|
||||
"secondary touch reaches the Lua facade")
|
||||
T.eq(SecondScreen.pollTouch(), nil, "an empty native touch queue returns nil")
|
||||
C.love_android_poll_secondary_touch = nil
|
||||
T.eq(SecondScreen.pollTouch(), nil, "an older native bridge remains safe")
|
||||
|
||||
package.loaded[name] = oldModule
|
||||
package.loaded.ffi = oldFfi
|
||||
package.preload.ffi = oldPreload
|
||||
|
||||
T.finish("second-screen touch facade")
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Issue #945: a mod's per-trainer battleTheme (trainers.battleTheme, an
|
||||
-- audio.songs id) was validated and merged onto the trainer record but
|
||||
-- never read -- battle music came solely from data.audio.battle[kind] where
|
||||
-- kind is computeMusicKind()'s final/gym/trainer/wild. Both battle-theme
|
||||
-- start sites (OverworldController:pushBattle's pre-wipe cue and
|
||||
-- BattleState:enter) now route through BattleState:playBattleTheme(), which
|
||||
-- hands the override to Music.playBattle's new song arg. A nil override
|
||||
-- keeps the kind default, so vanilla trainer fights -- and #782's non-gym
|
||||
-- Giovanni -- are unchanged.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
-- ------- love audio stub: file-backed songs only (mod_audio pattern)
|
||||
|
||||
local love = _G.love or {}
|
||||
_G.love = love
|
||||
love.audio = love.audio or {}
|
||||
|
||||
local assets = {
|
||||
["assets/theme.ogg"] = true,
|
||||
["assets/alt.ogg"] = true,
|
||||
}
|
||||
|
||||
local sources = {}
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() end
|
||||
function Source:stop() end
|
||||
function Source:setLooping() end
|
||||
function Source:setVolume() end
|
||||
function Source:setFilter() end
|
||||
|
||||
love.audio.newSource = function(what, mode)
|
||||
if type(what) == "string" and not assets[what] then
|
||||
error("could not open file " .. what, 0)
|
||||
end
|
||||
local src = setmetatable({ file = what, mode = mode, queueable = false }, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
love.audio.newQueueableSource = function()
|
||||
local src = setmetatable({ queueable = true }, Source)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Font = require("src.render.Font")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- ------- the fix-945 mod: register a song and point a trainer class at it
|
||||
|
||||
local MOD = {
|
||||
["mods/fix_youngster_theme/manifest.json"] = [[{
|
||||
"id": "fix_youngster_theme",
|
||||
"name": "Fix Youngster Theme",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_youngster_theme/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.music:register("Music_ModTheme", { file = "assets/theme.ogg" })
|
||||
mod.content.trainers:patch("OPP_FIX_YOUNGSTER", {
|
||||
battleTheme = "Music_ModTheme",
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
local function newGame(data)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.player.rival = "GARY"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||
return { data = data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end, pop = function() end } }
|
||||
end
|
||||
|
||||
-- record every cue the music.select hook sees
|
||||
local function hookRecorder(seen)
|
||||
Runtime.hooks:wrap("music.select", function(nextLink, song, ctx)
|
||||
seen[#seen + 1] = { song = song, kind = ctx.kind,
|
||||
trainerId = ctx.trainerId }
|
||||
return nextLink(song, ctx)
|
||||
end, nil, "bug945")
|
||||
end
|
||||
|
||||
-- a playBattle spy that records (kind, trainerId, song) without touching audio
|
||||
local function spyPlayBattle()
|
||||
local calls = {}
|
||||
local real = Music.playBattle
|
||||
Music.playBattle = function(data, kind, trainerId, song)
|
||||
calls[#calls + 1] = { kind = kind, trainerId = trainerId, song = song }
|
||||
end
|
||||
return calls, function() Music.playBattle = real end
|
||||
end
|
||||
|
||||
-- ------- the modded class resolves and the override reaches the cue
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
Font.load(Data)
|
||||
TypeChart.load(Data)
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/fix_youngster_theme" },
|
||||
{ data = Data, fs = T.sdk.memfs(MOD) })
|
||||
T.eq(#run.errors, 0, "the battleTheme mod loads without validation errors")
|
||||
T.eq(Data.trainers.OPP_FIX_YOUNGSTER.battleTheme, "Music_ModTheme",
|
||||
"the patch lands on the trainer record")
|
||||
|
||||
-- give the kind defaults a home so the no-override fallback is observable
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.battle = Data.audio.battle or {
|
||||
wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer",
|
||||
}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.songs.Music_DefaultWild = { file = "assets/alt.ogg" }
|
||||
Data.audio.songs.Music_DefaultTrainer = { file = "assets/alt.ogg" }
|
||||
|
||||
local battle = BattleState.newTrainer(newGame(Data), "OPP_FIX_YOUNGSTER", 1)
|
||||
T.eq(battle:battleTheme(), "Music_ModTheme",
|
||||
"battleTheme() resolves the per-trainer override")
|
||||
T.eq(battle:computeMusicKind(), "trainer",
|
||||
"a plain trainer fight is still trainer-kind")
|
||||
|
||||
local calls, restore = spyPlayBattle()
|
||||
battle:playBattleTheme()
|
||||
T.eq(#calls, 1, "playBattleTheme cues the theme once")
|
||||
T.eq(calls[1].kind, "trainer", "the cue carries the computed kind")
|
||||
T.eq(calls[1].trainerId, "OPP_FIX_YOUNGSTER", "the cue carries the trainer id")
|
||||
T.eq(calls[1].song, "Music_ModTheme", "the override label wins over the kind default")
|
||||
|
||||
-- enter() sets self.musicKind before playing; playBattleTheme honors it
|
||||
battle.musicKind = "gym"
|
||||
battle:playBattleTheme()
|
||||
T.eq(calls[2].kind, "gym", "a pre-set musicKind (the enter path) is used as-is")
|
||||
restore()
|
||||
|
||||
-- ------- Music.playBattle: override arg wins; nil falls back to the default
|
||||
|
||||
local seen = {}
|
||||
hookRecorder(seen)
|
||||
Music.reload()
|
||||
Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER", "Music_ModTheme")
|
||||
T.eq(seen[1].song, "Music_ModTheme", "the override arg is played")
|
||||
T.eq(seen[1].kind, "trainer", "the hook sees the battle kind")
|
||||
T.eq(seen[1].trainerId, "OPP_FIX_YOUNGSTER", "the hook sees the trainer id")
|
||||
|
||||
Music.reload()
|
||||
Music.playBattle(Data, "trainer", "OPP_FIX_YOUNGSTER")
|
||||
T.eq(seen[2].song, "Music_DefaultTrainer",
|
||||
"no override falls back to the kind's default song")
|
||||
T.eq(seen[2].trainerId, "OPP_FIX_YOUNGSTER",
|
||||
"the hook still sees the trainer id on the default path")
|
||||
|
||||
-- ------- a vanilla class has no override, so the kind default is untouched
|
||||
|
||||
local DataV = T.fixtures.fresh()
|
||||
Font.load(DataV)
|
||||
TypeChart.load(DataV)
|
||||
DataV.audio = {
|
||||
battle = { wild = "Music_DefaultWild", trainer = "Music_DefaultTrainer" },
|
||||
songs = {
|
||||
Music_DefaultWild = { file = "assets/alt.ogg" },
|
||||
Music_DefaultTrainer = { file = "assets/alt.ogg" },
|
||||
},
|
||||
}
|
||||
|
||||
local battleV = BattleState.newTrainer(newGame(DataV), "OPP_FIX_YOUNGSTER", 1)
|
||||
T.eq(battleV:battleTheme(), nil, "a vanilla trainer class has no override")
|
||||
local callsV, restoreV = spyPlayBattle()
|
||||
battleV:playBattleTheme()
|
||||
T.eq(callsV[1].kind, "trainer", "vanilla cue keeps the trainer kind")
|
||||
T.eq(callsV[1].song, nil, "vanilla passes no override, so the default plays (#782)")
|
||||
restoreV()
|
||||
|
||||
local seenV = {}
|
||||
hookRecorder(seenV)
|
||||
Music.reload()
|
||||
Music.playBattle(DataV, "trainer", "OPP_FIX_YOUNGSTER")
|
||||
T.eq(seenV[1].song, "Music_DefaultTrainer",
|
||||
"vanilla battles play the kind default, not a per-trainer theme (#782)")
|
||||
|
||||
T.finish("trainer battle theme bug945")
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Engine invariant (#916): after the Fly / Dig departure animation ends, the
|
||||
-- trainer sprite must stay hidden through the warp fade-out and only become
|
||||
-- visible again when the arrival animation (flyArrive / teleport spin-down)
|
||||
-- plays on the new map.
|
||||
--
|
||||
-- Root cause: the player-hide guard only held while a departure animation
|
||||
-- was live. flyAnim was nil'd the instant the bird finished path2, and the
|
||||
-- teleportOut countdown cleared the spin fields at 0, but startWarpTo's
|
||||
-- 32-frame fade keeps the overworld drawing beneath the veil (the Transition
|
||||
-- is not isOpaque), so with the departure guard gone and the arrival not yet
|
||||
-- armed, the standing sprite popped back in at the old cell for the whole
|
||||
-- fade.
|
||||
--
|
||||
-- The fix is a playerHidden flag on OverworldState: set when the departure
|
||||
-- completes (flyAnim path2 / teleportOut countdown), cleared in startWarpTo's
|
||||
-- midpoint the same tick the arrival arms, and folded into both player-draw
|
||||
-- guards. This suite runs the REAL Transition + setMap headlessly and
|
||||
-- asserts there is no fade frame where the player would draw bare.
|
||||
--
|
||||
-- ROM-free (fixture dataset, no ROM boot): lives in tests/engine so the CI
|
||||
-- headless tier runs it; also runnable standalone via
|
||||
-- `luajit tests/engine/warp_sprite_hidden_bug916.lua`.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
-- fixture patches that let the overworld boot and run headlessly
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {} }
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = Game.stack
|
||||
|
||||
-- The draw guard both entity passes use: the player sprite is skipped while
|
||||
-- any of flyAnim / flyArrive / playerHidden is set.
|
||||
local function playerHidden(ow)
|
||||
return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true
|
||||
end
|
||||
|
||||
local function newOW()
|
||||
stack:push(OW, "FIX_TOWN", 5, 6, "down")
|
||||
local ow = stack:top()
|
||||
Game.overworld = ow
|
||||
return ow
|
||||
end
|
||||
|
||||
-- Drive `ow` until its departure + warp + arrival all complete, tracking the
|
||||
-- fade window. Returns counters: fadeFrames / fadeFramesHidden (frames the
|
||||
-- Transition was on top; of those, frames the player was hidden), gapFrames
|
||||
-- (fade frames where NO arrival was active AND the player was NOT hidden --
|
||||
-- the regression this suite guards), arrivalFrame (first frame an arrival
|
||||
-- animation armed), warpFrame (first frame a fade is up).
|
||||
--
|
||||
-- Breaks once an arrival armed and then fully finished (no stale departure
|
||||
-- or arrival animation, OW back on top); `maxFrames` is the safety net.
|
||||
local function drive(ow, maxFrames)
|
||||
local st = { fadeFrames = 0, fadeFramesHidden = 0, gapFrames = 0,
|
||||
arrivalFrame = nil, warpFrame = nil }
|
||||
for i = 1, maxFrames or 260 do
|
||||
local fading = stack:top() ~= ow
|
||||
stack:update()
|
||||
if fading then
|
||||
st.fadeFrames = st.fadeFrames + 1
|
||||
if playerHidden(ow) then st.fadeFramesHidden = st.fadeFramesHidden + 1 end
|
||||
local arrivalActive = ow.flyArrive ~= nil or ow.player.spinDrop == true
|
||||
if not arrivalActive and not playerHidden(ow) then
|
||||
st.gapFrames = st.gapFrames + 1
|
||||
end
|
||||
if st.warpFrame == nil then st.warpFrame = i end
|
||||
end
|
||||
if st.arrivalFrame == nil
|
||||
and (ow.flyArrive ~= nil or ow.player.spinDrop == true) then
|
||||
st.arrivalFrame = i
|
||||
end
|
||||
if st.arrivalFrame and stack:top() == ow
|
||||
and ow.flyArrive == nil and ow.player.spinDrop ~= true
|
||||
and not ow.player.inputLocked then
|
||||
break -- departure + fade + arrival all finished
|
||||
end
|
||||
end
|
||||
return st
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ dig/teleport
|
||||
-- Departure spin (48) -> warp fade -> arrival spin-down. From the moment
|
||||
-- the spin ends until the arrival arms, the sprite must never draw bare.
|
||||
local ow = newOW()
|
||||
local doneFired = false
|
||||
ow:beginTeleportOut(function()
|
||||
doneFired = true
|
||||
ow.player.inputLocked = false -- the party-menu caller unlocks after the warp
|
||||
end)
|
||||
local st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
|
||||
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no dig fade frame leaves the player standing bare (#916)")
|
||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
||||
"dig fade hidden on every frame but the arrival-arming midpoint ("
|
||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
||||
check(st.arrivalFrame ~= nil, "dig arrival spin-down arms")
|
||||
check(ow.playerHidden == false, "dig hide cleared on the new map")
|
||||
check(doneFired, "dig onDone fires after the warp")
|
||||
check(ow.player.spinDrop ~= true and ow.player.spinning == false,
|
||||
"dig arrival spin-down completes")
|
||||
check(not playerHidden(ow), "player drawable again after the dig landing")
|
||||
|
||||
-- ------------------------------------------------------------------ fly
|
||||
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
|
||||
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
|
||||
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
|
||||
ow = newOW()
|
||||
ow:flyTo("FIX_ROUTE")
|
||||
st = drive(ow, 260)
|
||||
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
|
||||
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
|
||||
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
|
||||
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
|
||||
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no fly fade frame leaves the player standing bare (#916)")
|
||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
||||
"fly fade hidden on every frame but the arrival-arming midpoint ("
|
||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
||||
check(st.arrivalFrame ~= nil, "fly arrival swoop arms")
|
||||
check(ow.playerHidden == false, "fly hide cleared on the new map")
|
||||
check(ow.flyArrive == nil, "fly arrival swoop completes")
|
||||
check(not ow.player.inputLocked, "fly landing releases player input")
|
||||
check(not playerHidden(ow), "player drawable again after the fly landing")
|
||||
|
||||
T.finish("warp_sprite_hidden_bug916")
|
||||
@@ -0,0 +1,42 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Assets = require("src.render.Assets")
|
||||
local WorldAPI = require("src.world.WorldAPI")
|
||||
|
||||
Assets.imageData = function()
|
||||
return { getPixel = function(_, x)
|
||||
local shade = x < 8 and 1 or 0
|
||||
return shade, shade, shade, 1
|
||||
end }
|
||||
end
|
||||
|
||||
local api = WorldAPI.new({ stack = { states = {} } }, "tester")
|
||||
local overview, err = api:mapOverview()
|
||||
T.eq(overview, nil, "map overview is unavailable outside the overworld")
|
||||
T.eq(err, "no overworld", "map overview reports why it is unavailable")
|
||||
|
||||
local map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 }
|
||||
function map:isWarpTileCell(x, y) return x == 1 and y == 0 end
|
||||
function map:isWaterCell(x, y) return x == 0 and y == 1 end
|
||||
function map:isWalkableCell(x, y) return x == 0 and y == 0 end
|
||||
function map:tileAt(x) return x % 2 end
|
||||
|
||||
api = WorldAPI.new({ stack = { states = {
|
||||
{ isOverworld = true, map = map },
|
||||
} } }, "tester")
|
||||
overview = api:mapOverview()
|
||||
T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map")
|
||||
T.eq(overview.width, 2, "map overview reports its width")
|
||||
T.eq(overview.height, 2, "map overview reports its height")
|
||||
T.eq(overview.rows[1], ".+", "walkable land and warps are distinct")
|
||||
T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct")
|
||||
T.eq(overview.tileRows, nil, "tile overview is optional")
|
||||
|
||||
map.tileset = { image = "test.png", tilesPerRow = 2 }
|
||||
overview = api:mapOverview()
|
||||
T.eq(overview.tileWidth, 4, "tile overview reports its width")
|
||||
T.eq(overview.tileHeight, 4, "tile overview reports its height")
|
||||
T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading")
|
||||
|
||||
T.finish("world map overview")
|
||||
@@ -0,0 +1,442 @@
|
||||
-- Public mod.checkpoints contract over a semantic Game/StateStack fixture.
|
||||
-- The mod entry chunk sees no private module; the harness builds the engine side.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local oldGetRandomState = love.math.getRandomState
|
||||
local oldSetRandomState = love.math.setRandomState
|
||||
local checkpointRngState = "overworld-rng-A"
|
||||
love.math.getRandomState = function() return checkpointRngState end
|
||||
love.math.setRandomState = function(state) checkpointRngState = state end
|
||||
|
||||
local T = require("tests.harness").suite("mod checkpoints")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local GameMethods = require("src.core.Game")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, body) files[path] = body return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
createDirectory = function() return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
getDirectoryItems = function(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function baseSave()
|
||||
return {
|
||||
version = "red",
|
||||
meta = { format = 4, mods = {}, playthroughId = "play-a" },
|
||||
player = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6, facing = "down", surfing = false,
|
||||
name = "RED", rival = "BLUE", id = 7,
|
||||
},
|
||||
money = 3000,
|
||||
party = { { species = "BULBASAUR", level = 5, hp = 19,
|
||||
moves = { "TACKLE" } } },
|
||||
flags = { GOT_STARTER = true },
|
||||
inventory = { POTION = 1 },
|
||||
pcItems = { POTION = 2 },
|
||||
box = { { species = "BULBASAUR", level = 4, hp = 16,
|
||||
moves = { "TACKLE" } } },
|
||||
boxes = { [2] = { { species = "BULBASAUR", level = 3, hp = 14,
|
||||
moves = { "TACKLE" } } } },
|
||||
defeatedTrainers = { PALLET_RIVAL = true },
|
||||
objectToggles = { PALLET_TOWN = { OAK = false } },
|
||||
itemsTaken = { PALLET_TOWN_POTION = true },
|
||||
pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } },
|
||||
modData = {},
|
||||
options = { volume = 4, bindings = {} },
|
||||
}
|
||||
end
|
||||
|
||||
local function makeGame()
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local game
|
||||
local ow = {
|
||||
map = { id = "PALLET_TOWN" },
|
||||
player = { cellX = 5, cellY = 6, facing = "down", surfing = false },
|
||||
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
|
||||
runner = { isRunning = function() return false end },
|
||||
}
|
||||
function ow:captureSave(save)
|
||||
save.player.map = self.map.id
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function ow:enter(mapId, x, y, facing, opts)
|
||||
game.lastEnterOpts = opts
|
||||
if game.failNextEnter then
|
||||
game.failNextEnter = false
|
||||
error("injected reconstruction failure")
|
||||
end
|
||||
self.map = { id = mapId }
|
||||
self.player = {
|
||||
cellX = x, cellY = y, facing = facing,
|
||||
surfing = game.save.player.surfing and true or false,
|
||||
}
|
||||
self.scriptMoves, self.pendingScripts = {}, {}
|
||||
self.parallelRunners, self.parallelQueue = {}, {}
|
||||
self.runner = { isRunning = function() return false end }
|
||||
end
|
||||
game = setmetatable({
|
||||
save = baseSave(), stack = stack, overworld = ow,
|
||||
data = {
|
||||
pokemon = { BULBASAUR = { dex = 1 } },
|
||||
moves = { TACKLE = { pp = 35 } },
|
||||
items = { POTION = {} },
|
||||
constants = { fallbackMove = "TACKLE" },
|
||||
field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } },
|
||||
maps = {
|
||||
PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 },
|
||||
ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 },
|
||||
BROKEN = { id = "BROKEN", width = 10, height = 9 },
|
||||
},
|
||||
},
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = ow
|
||||
return game, ow
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/probe/manifest.json"] =
|
||||
'{"id":"probe","name":"probe","version":"1.0.0",'
|
||||
.. '"entry":"main.lua","api":2,"profile":"content"}',
|
||||
["mods/probe/main.lua"] = [[
|
||||
return function(mod) _G.MOD_CHECKPOINTS = mod.checkpoints end
|
||||
]],
|
||||
}
|
||||
local game, ow = makeGame()
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader.game = game
|
||||
T.check(loader:load({}) == true, "checkpoint fixture mod loads")
|
||||
local checkpoints = _G.MOD_CHECKPOINTS
|
||||
T.check(type(checkpoints) == "table",
|
||||
"Loader exposes mod.checkpoints through the public mod object")
|
||||
if type(checkpoints) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
local capability = checkpoints:inspect(game)
|
||||
T.same(capability, { canCapture = true, canRestore = true, kind = "overworld" },
|
||||
"plain overworld control is a stable checkpoint boundary")
|
||||
|
||||
local function refused(mutator, expectedCode, message)
|
||||
local undo = mutator()
|
||||
local result = checkpoints:inspect(game)
|
||||
T.check(result.canCapture == false and result.reason == expectedCode, message)
|
||||
undo()
|
||||
end
|
||||
|
||||
refused(function()
|
||||
ow.transitioning = true
|
||||
return function() ow.transitioning = nil end
|
||||
end, "transition_busy", "transition frames are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
return function() ow.runner = { isRunning = function() return false end } end
|
||||
end, "script_busy", "foreground suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.parallelRunners = { { isRunning = function() return true end } }
|
||||
return function() ow.parallelRunners = {} end
|
||||
end, "script_busy", "parallel suspended scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.pendingScripts = { { rows = {} } }
|
||||
return function() ow.pendingScripts = {} end
|
||||
end, "script_busy", "queued scripts are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.scriptMoves = { { entity = ow.player } }
|
||||
return function() ow.scriptMoves = {} end
|
||||
end, "script_busy", "scripted movement is rejected")
|
||||
|
||||
refused(function()
|
||||
game.stack.states[2] = { screenId = "StartMenu" }
|
||||
return function() game.stack.states[2] = nil end
|
||||
end, "screen_busy", "modal screens over the overworld are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.emote = { frames = 1 }
|
||||
return function() ow.emote = nil end
|
||||
end, "animation_busy", "partial overworld animations are rejected")
|
||||
|
||||
refused(function()
|
||||
ow.player.moving = true
|
||||
return function() ow.player.moving = nil end
|
||||
end, "movement_busy", "partial player movement is rejected")
|
||||
|
||||
local titleGame = { save = game.save, stack = {
|
||||
top = function() return { screenId = "TitleState" } end,
|
||||
} }
|
||||
local titleCapability = checkpoints:inspect(titleGame)
|
||||
T.check(titleCapability.canCapture == false
|
||||
and titleCapability.reason == "not_overworld",
|
||||
"title and non-playthrough runtime is rejected")
|
||||
|
||||
-- Capture synchronizes semantic position into a detached data-only record.
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "ROUTE_1", 7, 8
|
||||
ow.player.facing, ow.player.surfing = "left", true
|
||||
local snapshot, code, message = checkpoints:capture(game)
|
||||
T.check(snapshot ~= nil, "stable overworld captures: " .. tostring(code or message))
|
||||
T.eq(snapshot.format, 1, "checkpoint format is explicit")
|
||||
T.eq(snapshot.kind, "overworld", "checkpoint runtime kind is explicit")
|
||||
T.same(snapshot.identity, {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = "play-a",
|
||||
},
|
||||
"checkpoint carries compatibility identity")
|
||||
T.same(snapshot.runtime.overworld,
|
||||
{ map = "ROUTE_1", x = 7, y = 8, facing = "left", surfing = true },
|
||||
"checkpoint carries exact semantic overworld position")
|
||||
T.eq(snapshot.save.player.map, "ROUTE_1",
|
||||
"captured progress is synchronized from the live controller")
|
||||
T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind")
|
||||
T.same(snapshot.rng, { love = "overworld-rng-A" },
|
||||
"overworld checkpoint carries deterministic gameplay RNG")
|
||||
|
||||
local legacy = checkpoints:capture(game)
|
||||
legacy.rng = nil
|
||||
checkpointRngState = "legacy-runtime-rng"
|
||||
local legacyRestored, legacyCode = checkpoints:restore(game, legacy)
|
||||
T.check(legacyRestored == true,
|
||||
"legacy format-1 overworld checkpoint without RNG remains loadable: "
|
||||
.. tostring(legacyCode))
|
||||
T.eq(checkpointRngState, "legacy-runtime-rng",
|
||||
"legacy checkpoint leaves the current RNG stream untouched")
|
||||
checkpointRngState = "overworld-rng-A"
|
||||
|
||||
snapshot.save.money = 1
|
||||
snapshot.runtime.overworld.x = 1
|
||||
T.eq(game.save.money, 3000, "mutating a checkpoint cannot mutate live progress")
|
||||
T.eq(ow.player.cellX, 7, "mutating a checkpoint cannot move the live player")
|
||||
|
||||
-- Recapture the unmodified canonical A used for the differential roundtrip.
|
||||
snapshot = checkpoints:capture(game)
|
||||
local original = snapshot
|
||||
|
||||
game.save.money = 999999
|
||||
game.save.flags.GOT_STARTER = nil
|
||||
game.save.party[1].hp = 1
|
||||
game.save.inventory.POTION = 99
|
||||
game.save.pcItems.POTION = nil
|
||||
game.save.box = {}
|
||||
game.save.boxes = {}
|
||||
game.save.defeatedTrainers.PALLET_RIVAL = nil
|
||||
game.save.objectToggles.PALLET_TOWN.OAK = true
|
||||
game.save.itemsTaken.PALLET_TOWN_POTION = nil
|
||||
game.save.pokedex.seen.BULBASAUR = nil
|
||||
game.save.pokedex.owned.BULBASAUR = nil
|
||||
game.save.options.volume = 9
|
||||
checkpointRngState = "overworld-rng-B"
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3
|
||||
ow.player.facing, ow.player.surfing = "up", false
|
||||
|
||||
local restored, restoreCode, restoreMessage = checkpoints:restore(game, original)
|
||||
T.check(restored == true,
|
||||
"valid checkpoint restores: " .. tostring(restoreCode or restoreMessage))
|
||||
local recaptured = checkpoints:capture(game)
|
||||
T.same(recaptured, original,
|
||||
"capture A, mutate B, restore A, capture A2 yields normalized A == A2")
|
||||
T.eq(game.save.options.volume, 9,
|
||||
"checkpoint restoration preserves current global settings")
|
||||
T.eq(checkpointRngState, "overworld-rng-A",
|
||||
"overworld checkpoint restores gameplay RNG")
|
||||
T.eq(game.save.inventory.POTION, 1, "inventory progress roundtrips")
|
||||
T.eq(game.save.pcItems.POTION, 2, "PC item progress roundtrips")
|
||||
T.eq(game.save.box[1].hp, 16, "current box Pokemon roundtrips")
|
||||
T.eq(game.save.boxes[2][1].hp, 14, "stored box collection roundtrips")
|
||||
T.eq(game.save.defeatedTrainers.PALLET_RIVAL, true,
|
||||
"defeated trainer progress roundtrips")
|
||||
T.eq(game.save.objectToggles.PALLET_TOWN.OAK, false,
|
||||
"map object toggle progress roundtrips")
|
||||
T.eq(game.save.itemsTaken.PALLET_TOWN_POTION, true,
|
||||
"taken-object progress roundtrips")
|
||||
T.eq(game.save.pokedex.owned.BULBASAUR, true, "Pokedex progress roundtrips")
|
||||
T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true,
|
||||
"engine reconstruction is marked to suppress map-entry side effects")
|
||||
|
||||
-- Compatibility and schema failures occur before any mutation.
|
||||
local beforeRejected = checkpoints:capture(game)
|
||||
local wrongFormat = checkpoints:capture(game)
|
||||
wrongFormat.format = 99
|
||||
restored, restoreCode = checkpoints:restore(game, wrongFormat)
|
||||
T.check(not restored and restoreCode == "unsupported_format",
|
||||
"unknown checkpoint format is rejected")
|
||||
|
||||
local wrongGame = checkpoints:capture(game)
|
||||
wrongGame.identity.gameVersion = "blue"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongGame)
|
||||
T.check(not restored and restoreCode == "wrong_game",
|
||||
"another game version is rejected")
|
||||
|
||||
local wrongProfile = checkpoints:capture(game)
|
||||
wrongProfile.identity.playthroughId = "play-b"
|
||||
restored, restoreCode = checkpoints:restore(game, wrongProfile)
|
||||
T.check(not restored and restoreCode == "wrong_playthrough",
|
||||
"another playthrough is rejected")
|
||||
|
||||
local badMap = checkpoints:capture(game)
|
||||
badMap.runtime.overworld.map = "MISSING_MAP"
|
||||
badMap.save.player.map = "MISSING_MAP"
|
||||
restored, restoreCode = checkpoints:restore(game, badMap)
|
||||
T.check(not restored and restoreCode == "invalid_map",
|
||||
"unknown content reference is rejected")
|
||||
T.same(checkpoints:capture(game), beforeRejected,
|
||||
"validation failures leave the live state unchanged")
|
||||
|
||||
local invalidGame = makeGame()
|
||||
local badSpecies = checkpoints:capture(invalidGame)
|
||||
badSpecies.save.party[1].species = "MISSING_SPECIES"
|
||||
restored, restoreCode = checkpoints:restore(invalidGame, badSpecies)
|
||||
T.check(not restored and restoreCode == "invalid_content",
|
||||
"unknown Pokemon content is rejected before reconstruction")
|
||||
T.eq(invalidGame.save.party[1].species, "BULBASAUR",
|
||||
"invalid Pokemon content leaves the live party unchanged")
|
||||
|
||||
-- A reconstruction exception rolls back to the exact pre-operation state.
|
||||
local target = checkpoints:capture(game)
|
||||
target.runtime.overworld.map = "BROKEN"
|
||||
target.runtime.overworld.x, target.runtime.overworld.y = 1, 1
|
||||
target.save.player.map = "BROKEN"
|
||||
target.save.player.x, target.save.player.y = 1, 1
|
||||
target.save.money = 42
|
||||
local beforeFailure = checkpoints:capture(game)
|
||||
game.failNextEnter = true
|
||||
restored, restoreCode = checkpoints:restore(game, target)
|
||||
T.check(not restored and restoreCode == "restore_failed",
|
||||
"reconstruction exception is returned as a structured failure")
|
||||
T.same(checkpoints:capture(game), beforeFailure,
|
||||
"failed reconstruction rolls back the complete pre-operation checkpoint")
|
||||
|
||||
-- The same public facade must carry a real battle checkpoint end to end. The
|
||||
-- engine-side fixture is deliberately constructed outside the probe mod; the
|
||||
-- mod sees and calls only mod.checkpoints.
|
||||
local function makeBattleGame()
|
||||
local data = Fixtures.fresh()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "public-battle-playthrough"
|
||||
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
|
||||
-- The tiny fixture registry intentionally omits several full-game defaults.
|
||||
-- Normalize those once, then place the save on its fixture map.
|
||||
SaveData.validate(save, data)
|
||||
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
|
||||
save.player.facing, save.player.surfing = "left", false
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local battleGame
|
||||
local battleOw = {
|
||||
map = { id = "FIX_TOWN" },
|
||||
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
function battleOw:captureSave(target)
|
||||
target.player.map = self.map.id
|
||||
target.player.x, target.player.y = self.player.cellX, self.player.cellY
|
||||
target.player.facing = self.player.facing
|
||||
target.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
function battleOw:enter(mapId, x, y, facing)
|
||||
self.map = { id = mapId }
|
||||
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
|
||||
end
|
||||
function battleOw:restoreBattleContinuation(restoredBattle, origin)
|
||||
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
restoredBattle.onFinish = function() end
|
||||
return true
|
||||
end
|
||||
battleGame = setmetatable({
|
||||
data = data, save = save, stack = stack, overworld = battleOw,
|
||||
}, { __index = GameMethods })
|
||||
stack.states[1] = battleOw
|
||||
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||
battle.musicKind = battle:computeMusicKind()
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return battleGame, battle
|
||||
end
|
||||
|
||||
checkpointRngState = "public-battle-rng-A"
|
||||
local battleGame, liveBattle = makeBattleGame()
|
||||
T.same(checkpoints:inspect(battleGame), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "public mod.checkpoints reports a settled battle boundary")
|
||||
liveBattle.turnCount = 4
|
||||
liveBattle.player.stages.attack = 2
|
||||
local battleSnapshot, battleCaptureCode = checkpoints:capture(battleGame)
|
||||
T.check(battleSnapshot and battleSnapshot.kind == "battle",
|
||||
"public mod.checkpoints captures a data-only battle: "
|
||||
.. tostring(battleCaptureCode))
|
||||
if battleSnapshot then
|
||||
battleGame.save.money = 1
|
||||
liveBattle.turnCount = 99
|
||||
checkpointRngState = "public-battle-rng-B"
|
||||
local battleRestored, battleRestoreCode, battleRestoreMessage = checkpoints:restore(
|
||||
battleGame, battleSnapshot)
|
||||
T.check(battleRestored == true,
|
||||
"public mod.checkpoints reconstructs a battle: "
|
||||
.. tostring(battleRestoreCode) .. " / " .. tostring(battleRestoreMessage))
|
||||
local restoredBattle = battleGame.stack:top()
|
||||
T.eq(restoredBattle.turnCount, 4,
|
||||
"public battle reconstruction restores the exact turn")
|
||||
T.eq(restoredBattle.player.stages.attack, 2,
|
||||
"public battle reconstruction restores battler stages")
|
||||
T.eq(checkpointRngState, "public-battle-rng-A",
|
||||
"public battle reconstruction restores gameplay RNG")
|
||||
T.same(checkpoints:capture(battleGame), battleSnapshot,
|
||||
"public battle capture/restore/capture is a normalized differential roundtrip")
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_CHECKPOINTS = nil
|
||||
love.math.getRandomState = oldGetRandomState
|
||||
love.math.setRandomState = oldSetRandomState
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,181 @@
|
||||
-- Public mod.storage contract: data-only transactions, namespace isolation,
|
||||
-- deterministic listing, recovery, and failure retention.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("mod storage")
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Version = require("src.core.Version")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
local function manifest(id)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0",')
|
||||
:format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}'
|
||||
end
|
||||
|
||||
local function memfs(files)
|
||||
local fs = { files = files, failTmp = false, failMain = false }
|
||||
|
||||
function fs.read(path) return files[path] end
|
||||
function fs.write(path, body)
|
||||
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
|
||||
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
|
||||
files[path] = body
|
||||
return true
|
||||
end
|
||||
function fs.remove(path) files[path] = nil return true end
|
||||
function fs.createDirectory() return true end
|
||||
function fs.getInfo(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
function fs.load(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
local prefix, seen, out = path .. "/", {}, {}
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
out[#out + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
return fs
|
||||
end
|
||||
|
||||
local function game(version, playthroughId)
|
||||
return { save = {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {}, playthroughId = playthroughId },
|
||||
} }
|
||||
end
|
||||
|
||||
local files = {
|
||||
["mods/alpha/manifest.json"] = manifest("alpha"),
|
||||
["mods/alpha/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end
|
||||
]],
|
||||
["mods/beta/manifest.json"] = manifest("beta"),
|
||||
["mods/beta/main.lua"] = [[
|
||||
return function(mod) _G.MOD_STORAGE_BETA = mod.storage end
|
||||
]],
|
||||
}
|
||||
local fs = memfs(files)
|
||||
local loader = Loader.new({ fs = fs })
|
||||
local current = game("red", "play-a")
|
||||
loader.game = current
|
||||
T.check(loader:load({}) == true, "storage fixture mods load")
|
||||
|
||||
local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA
|
||||
T.check(type(alpha) == "table" and type(beta) == "table",
|
||||
"Loader exposes mod.storage through the public mod object")
|
||||
if type(alpha) ~= "table" or type(beta) ~= "table" then
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
T.finish()
|
||||
end
|
||||
|
||||
-- Removing scope identity or exposing a mutable private slot id breaks this.
|
||||
local context = alpha:context(current)
|
||||
T.same(context, {
|
||||
engineVersion = Version.engine,
|
||||
gameVersion = "red",
|
||||
playthroughId = "play-a",
|
||||
}, "context exposes stable engine/game/playthrough compatibility identity")
|
||||
|
||||
-- Data-only write/read. The literal expected table is independent of storage.
|
||||
local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } }
|
||||
local ok, code, message = alpha:write(current, "states/quick/q1", payload)
|
||||
T.check(ok == true, "data-only payload writes: " .. tostring(code or message))
|
||||
local loaded = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "stored payload roundtrips as data")
|
||||
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
|
||||
"read returns decoded data rather than the caller's live table")
|
||||
|
||||
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
|
||||
T.check(not bad and badCode == "encode_failed",
|
||||
"functions are rejected with a stable data-only error")
|
||||
|
||||
local escaped, escapedCode = alpha:write(current, "../escape", {})
|
||||
T.check(not escaped and escapedCode == "invalid_key",
|
||||
"path traversal is rejected before persistence")
|
||||
|
||||
-- Logical enumeration is deterministic and prefix-scoped.
|
||||
T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
|
||||
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
|
||||
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
|
||||
local keys = alpha:list(current, "states/quick")
|
||||
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
|
||||
"list returns sorted logical keys under the requested prefix")
|
||||
|
||||
-- Mod, playthrough, and game namespaces cannot observe each other.
|
||||
local missing, missingCode = beta:read(current, "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another mod cannot read the first mod's payload")
|
||||
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another playthrough cannot read the payload")
|
||||
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
|
||||
T.check(missing == nil and missingCode == "not_found",
|
||||
"another game version cannot read the payload")
|
||||
|
||||
-- Find the implementation-owned file only to inject corruption; assertions stay
|
||||
-- on public read behavior, not the path shape.
|
||||
local function mainFor(fragment)
|
||||
for path in pairs(files) do
|
||||
if path:find(fragment, 1, true) and path:sub(-4) == ".lua" then return path end
|
||||
end
|
||||
end
|
||||
|
||||
local q1Main = mainFor("q1")
|
||||
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
|
||||
files[q1Main] = "not a serialized table"
|
||||
loaded, code = alpha:read(current, "states/quick/q1")
|
||||
T.same(loaded, payload, "corrupt main recovers the last verified payload")
|
||||
T.eq(code, nil, "successful recovery is a normal read")
|
||||
|
||||
-- A failed replacement cannot destroy the prior verified value.
|
||||
T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value")
|
||||
fs.failTmp = true
|
||||
ok, code = alpha:write(current, "replace", { version = 2 })
|
||||
fs.failTmp = false
|
||||
T.check(not ok and code == "write_failed", "staging failure is reported")
|
||||
T.same(alpha:read(current, "replace"), { version = 1 },
|
||||
"staging failure leaves the prior value readable")
|
||||
|
||||
-- Delete is exact and idempotent-not-found is explicit.
|
||||
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
|
||||
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")
|
||||
T.check(alpha:delete(current, "delete/me") == true, "delete removes its target")
|
||||
missing, missingCode = alpha:read(current, "delete/me")
|
||||
T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable")
|
||||
T.same(alpha:read(current, "delete/keep"), { yes = true },
|
||||
"delete leaves neighboring keys untouched")
|
||||
|
||||
-- No-mod parity: constructing/loading an empty loader creates no storage bytes.
|
||||
local emptyFiles, emptyFs = {}, nil
|
||||
emptyFs = memfs(emptyFiles)
|
||||
local emptyLoader = Loader.new({ fs = emptyFs })
|
||||
emptyLoader.game = current
|
||||
T.check(emptyLoader:load({}) == true, "no-mod loader still boots")
|
||||
T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files")
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
Runtime.currentMod = nil
|
||||
_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil
|
||||
|
||||
T.finish()
|
||||
@@ -636,6 +636,52 @@ local packed = io.open(cleanPkg, "rb")
|
||||
check(packed ~= nil, "pack writes the package")
|
||||
if packed then packed:close() end
|
||||
|
||||
-- Reproducible-build callers pin the informational pack timestamp through the
|
||||
-- standard SOURCE_DATE_EPOCH contract. Two clean invocations over the same
|
||||
-- input must then produce identical archive bytes and metadata.
|
||||
local epoch = "1234567890"
|
||||
local envPrefix = isWindows
|
||||
and ('set "SOURCE_DATE_EPOCH=%s" && '):format(epoch)
|
||||
or ("SOURCE_DATE_EPOCH=%s "):format(epoch)
|
||||
local deterministicA = root .. "/declared-a.modpkg"
|
||||
local deterministicB = root .. "/declared-b.modpkg"
|
||||
out, code = run(envPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, deterministicA))
|
||||
check(code == 0, "SOURCE_DATE_EPOCH package A succeeds: " .. out)
|
||||
out, code = run(envPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, deterministicB))
|
||||
check(code == 0, "SOURCE_DATE_EPOCH package B succeeds: " .. out)
|
||||
local archiveA = assert(io.open(deterministicA, "rb"))
|
||||
local bytesA = archiveA:read("*a")
|
||||
archiveA:close()
|
||||
local archiveB = assert(io.open(deterministicB, "rb"))
|
||||
local bytesB = archiveB:read("*a")
|
||||
archiveB:close()
|
||||
check(bytesA == bytesB, "SOURCE_DATE_EPOCH makes package bytes reproducible")
|
||||
local inspectPack = root .. "/inspect_pack.py"
|
||||
write(inspectPack, [[
|
||||
import json, sys, zipfile
|
||||
with zipfile.ZipFile(sys.argv[1]) as archive:
|
||||
meta = json.loads(archive.read(".modkit/pack.json"))
|
||||
assert meta["packed_at"] == "2009-02-13T23:31:30Z", meta["packed_at"]
|
||||
]])
|
||||
out, code = run(("%s %q %q"):format(python, inspectPack, deterministicA))
|
||||
check(code == 0, "pack metadata honors SOURCE_DATE_EPOCH: " .. out)
|
||||
local invalidEpochPrefix = isWindows
|
||||
and 'set "SOURCE_DATE_EPOCH=not-a-time" && '
|
||||
or "SOURCE_DATE_EPOCH=not-a-time "
|
||||
local invalidEpochPkg = root .. "/declared-invalid-epoch.modpkg"
|
||||
out, code = run(invalidEpochPrefix ..
|
||||
("%s tools/modkit.py pack %q -o %q --base fixture")
|
||||
:format(python, declared, invalidEpochPkg))
|
||||
check(code == 2, "invalid SOURCE_DATE_EPOCH is a usage failure: " .. out)
|
||||
check(out:find("SOURCE_DATE_EPOCH", 1, true) ~= nil,
|
||||
"invalid source epoch names the failed contract")
|
||||
check(io.open(invalidEpochPkg, "rb") == nil,
|
||||
"invalid source epoch writes no package")
|
||||
|
||||
-- MK305 diffs shipped tables against the imported dataset; fake one under
|
||||
-- a scratch repo root so the check exercises the same on ROM-less machines
|
||||
local fake = root .. "/fakerepo"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Pokemon Yellow's Oak-speech show-off mon is the player's Pikachu, not
|
||||
-- Red/Blue's NIDORINO (engine/battle/core.asm BATTLE_TYPE_PIKACHU, the
|
||||
-- ProfOak demo; engine/movie/oak_speech/oak_speech.asm). The import
|
||||
-- manifest must carry field.oakSpeech.demoSpecies, and
|
||||
-- Data:applyVersionedFieldData repairs Yellow caches made before the
|
||||
-- manifest carried it (#915).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.field and Data.field.oakSpeech) then Data:load() end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local S = require("tests.harness").suite("parity Yellow Oak speech")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local oldVersion = GameVersion.get()
|
||||
local oldTrades = Data.field.trades
|
||||
local oldOldManBattle = Data.field.oldManBattle
|
||||
|
||||
local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r"))
|
||||
local manifest = manifestFile:read("*a")
|
||||
manifestFile:close()
|
||||
|
||||
check(manifest:find('"demoSpecies": "PIKACHU"') ~= nil,
|
||||
"Yellow manifest stamps field.oakSpeech.demoSpecies as PIKACHU")
|
||||
|
||||
-- a stale Yellow cache carries shrink frames but no demoSpecies
|
||||
local stale = { shrink1 = "assets/generated/intro/shrink1.png",
|
||||
shrink2 = "assets/generated/intro/shrink2.png" }
|
||||
local oldOakSpeech = Data.field.oakSpeech
|
||||
Data.field.oakSpeech = stale
|
||||
|
||||
GameVersion.set("yellow")
|
||||
Data:applyVersionedFieldData()
|
||||
eq(Data.field.oakSpeech.demoSpecies, "PIKACHU",
|
||||
"applyVersionedFieldData fills a stale Yellow cache with PIKACHU")
|
||||
|
||||
-- fill-if-absent: an importer that learns to stamp the key wins
|
||||
local preStamped = { demoSpecies = "RAICHU",
|
||||
shrink1 = "assets/generated/intro/shrink1.png" }
|
||||
Data.field.oakSpeech = preStamped
|
||||
Data:applyVersionedFieldData()
|
||||
eq(Data.field.oakSpeech.demoSpecies, "RAICHU",
|
||||
"applyVersionedFieldData leaves an already-stamped demoSpecies alone")
|
||||
|
||||
Data.field.oakSpeech = oldOakSpeech
|
||||
Data.field.trades = oldTrades
|
||||
Data.field.oldManBattle = oldOldManBattle
|
||||
GameVersion.set(oldVersion)
|
||||
|
||||
return S:finish()
|
||||
+52
-16
@@ -92,6 +92,17 @@ def version_for_manifest(manifest, requested_version=None, manifest_explicit=Fal
|
||||
return detected or requested_version or "red"
|
||||
|
||||
|
||||
def detect_rom_version(path):
|
||||
"""Read a canonical ROM once and return its supported game version."""
|
||||
rom = RomImage(path, None)
|
||||
version = SHA1_TO_VERSION.get(rom.sha1)
|
||||
if version is None:
|
||||
expected = ", ".join(VERSION_SHA1[name] for name in VERSION_MANIFESTS)
|
||||
raise ValueError(
|
||||
f"unsupported ROM SHA-1 {rom.sha1}; expected one of {expected}")
|
||||
return version, rom
|
||||
|
||||
|
||||
def extract_constants(manifest, out_dir):
|
||||
data = manifest["constants"]
|
||||
util.write_lua(
|
||||
@@ -2086,48 +2097,73 @@ def build(rom, symbols, manifest, out_dir, assets_dir, datasets):
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--rom", required=True,
|
||||
help="canonical US Pokemon Red, Blue, or Yellow ROM")
|
||||
parser.add_argument(
|
||||
"--version", choices=sorted(VERSION_MANIFESTS), default="red",
|
||||
help="select the shipped manifest for this version (default: red)")
|
||||
"--version", choices=["auto", *sorted(VERSION_MANIFESTS)], default="auto",
|
||||
help="select the shipped manifest for this version (default: detect from ROM)")
|
||||
parser.add_argument(
|
||||
"--manifest", default=None,
|
||||
help="explicit manifest path (overrides --version default path; "
|
||||
"RomImage hash still comes from the file's romSha1)")
|
||||
parser.add_argument("--out", default="data/generated")
|
||||
parser.add_argument("--assets", default="assets/generated")
|
||||
parser.add_argument(
|
||||
"--out", default=None,
|
||||
help="generated data directory (default: version-specific cache path)")
|
||||
parser.add_argument(
|
||||
"--assets", default=None,
|
||||
help="generated assets directory (default: version-specific cache path)")
|
||||
parser.add_argument("--clean", action="store_true")
|
||||
parser.add_argument(
|
||||
"--only", action="append", choices=DATASETS,
|
||||
help="build one dataset (repeatable); default builds all implemented")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
manifest_explicit = args.manifest is not None
|
||||
manifest_path = resolve_manifest_path(args.version, args.manifest)
|
||||
manifest = load_manifest(manifest_path)
|
||||
version = version_for_manifest(
|
||||
manifest, args.version, manifest_explicit=manifest_explicit)
|
||||
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
|
||||
rom = RomImage(args.rom, expected_sha1)
|
||||
requested_version = None if args.version == "auto" else args.version
|
||||
if manifest_explicit:
|
||||
manifest_path = resolve_manifest_path(
|
||||
requested_version or "red", args.manifest)
|
||||
manifest = load_manifest(manifest_path)
|
||||
version = version_for_manifest(
|
||||
manifest, requested_version, manifest_explicit=True)
|
||||
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
|
||||
rom = RomImage(args.rom, expected_sha1)
|
||||
elif requested_version is None:
|
||||
version, rom = detect_rom_version(args.rom)
|
||||
manifest_path = resolve_manifest_path(version, None)
|
||||
manifest = load_manifest(manifest_path)
|
||||
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
|
||||
if rom.sha1 != expected_sha1:
|
||||
raise ValueError(
|
||||
f"unsupported ROM SHA-1 {rom.sha1}; expected {expected_sha1}")
|
||||
else:
|
||||
version = requested_version
|
||||
manifest_path = resolve_manifest_path(version, None)
|
||||
manifest = load_manifest(manifest_path)
|
||||
version = version_for_manifest(manifest, version)
|
||||
expected_sha1 = manifest.get("romSha1") or VERSION_SHA1[version]
|
||||
rom = RomImage(args.rom, expected_sha1)
|
||||
symbols = SymbolTable(manifest["symbols"])
|
||||
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
prefix = "" if version == "red" else version + os.sep
|
||||
out_dir = args.out or prefix + os.path.join("data", "generated")
|
||||
assets_dir = args.assets or prefix + os.path.join("assets", "generated")
|
||||
if args.clean:
|
||||
for path in (args.out, args.assets):
|
||||
for path in (out_dir, assets_dir):
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
os.makedirs(args.assets, exist_ok=True)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
os.makedirs(assets_dir, exist_ok=True)
|
||||
datasets = tuple(args.only) if args.only else DATASETS
|
||||
try:
|
||||
build(rom, symbols, manifest, args.out, args.assets, datasets)
|
||||
build(rom, symbols, manifest, out_dir, assets_dir, datasets)
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -481,6 +481,12 @@ def derive(red, pokeyellow, symbols_path):
|
||||
for i, name in enumerate(yellow_bubbles)],
|
||||
}
|
||||
|
||||
# The Oak-speech show-off mon is the player's Pikachu in Yellow
|
||||
# (engine/battle/core.asm BATTLE_TYPE_PIKACHU / the ProfOak demo);
|
||||
# the deep-copied Red field.oakSpeech has no demoSpecies, so stamp
|
||||
# it or the import falls back to NIDORINO (#915).
|
||||
yellow["field"]["oakSpeech"]["demoSpecies"] = "PIKACHU"
|
||||
|
||||
# Ensure Melanie / Summer Beach town-map entries exist after rebuild.
|
||||
locations = yellow["field"]["townMap"]["locations"]
|
||||
if "CERULEAN_MELANIES_HOUSE" not in locations \
|
||||
|
||||
+19
-2
@@ -1085,6 +1085,20 @@ def cmd_lint(args, repo):
|
||||
|
||||
# ---------------------------------------------------------------- pack
|
||||
|
||||
def pack_timestamp():
|
||||
raw = os.environ.get("SOURCE_DATE_EPOCH")
|
||||
if raw is None:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), None
|
||||
try:
|
||||
epoch = int(raw, 10)
|
||||
if epoch < 0:
|
||||
raise ValueError("negative epoch")
|
||||
stamp = datetime.fromtimestamp(epoch, timezone.utc)
|
||||
except (ValueError, OverflowError, OSError):
|
||||
return None, "SOURCE_DATE_EPOCH must be a nonnegative Unix timestamp"
|
||||
return stamp.strftime("%Y-%m-%dT%H:%M:%SZ"), None
|
||||
|
||||
|
||||
def cmd_pack(args, repo):
|
||||
mod_dir = resolve_mod_dir(repo, args.mod)
|
||||
if not mod_dir:
|
||||
@@ -1119,6 +1133,10 @@ def cmd_pack(args, repo):
|
||||
mod_id = manifest["id"]
|
||||
version = manifest.get("version", "0.0.0")
|
||||
out = args.output or f"{mod_id}-{version}.modpkg"
|
||||
packed_at, timestamp_problem = pack_timestamp()
|
||||
if timestamp_problem:
|
||||
print(f"modkit: {timestamp_problem}")
|
||||
return 2
|
||||
files = mod_files(mod_dir)
|
||||
records = []
|
||||
for rel in files:
|
||||
@@ -1127,8 +1145,7 @@ def cmd_pack(args, repo):
|
||||
"sha256": hashlib.sha256(body).hexdigest()})
|
||||
pack_meta = {
|
||||
"modkit": MODKIT_VERSION,
|
||||
"packed_at": datetime.now(timezone.utc)
|
||||
.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"packed_at": packed_at,
|
||||
"id": mod_id,
|
||||
"version": version,
|
||||
"api": manifest.get("api", 1),
|
||||
|
||||
@@ -6566,6 +6566,7 @@
|
||||
}
|
||||
],
|
||||
"oakSpeech": {
|
||||
"demoSpecies": "PIKACHU",
|
||||
"shrink1": "assets/generated/intro/shrink1.png",
|
||||
"shrink2": "assets/generated/intro/shrink2.png"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user