Merge remote-tracking branch 'origin/dev' into feat/worldapi-start-wild-battle

This commit is contained in:
Jakub Lisicki
2026-08-08 12:42:10 +02:00
77 changed files with 6267 additions and 248 deletions
+3
View File
@@ -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
View File
@@ -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
+114
View File
@@ -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.
+124
View File
@@ -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.
+132
View File
@@ -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) {
+34 -1
View File
@@ -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 {
+44
View File
@@ -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 },
+27
View File
@@ -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
+111
View File
@@ -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.
+11
View File
@@ -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`.
+10
View File
@@ -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,
}
+12
View File
@@ -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"] = "",
}
+13
View File
@@ -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,
-- },
}
+11
View File
@@ -0,0 +1,11 @@
-- Item names
--
-- Item names for Espanol.
return {
["FIX_BADGE_1"] = "",
["FIX_BADGE_2"] = "",
["FIX_BALL"] = "",
["FIX_POTION"] = "",
["FIX_TM"] = "",
}
+10
View File
@@ -0,0 +1,10 @@
-- Move names
--
-- Move names for Espanol.
return {
["FIX_CUT"] = "",
["FIX_EMBERISH"] = "",
["FIX_SCRATCH"] = "",
["FIX_TACKLE"] = "",
}
+41
View File
@@ -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" },
},
}
+9
View File
@@ -0,0 +1,9 @@
-- Species names
--
-- Species names for Espanol.
return {
["FIXMON_A"] = "",
["FIXMON_B"] = "",
["FIXMON_C"] = "",
}
+11
View File
@@ -0,0 +1,11 @@
-- Status labels
--
-- Short enough for the battle HUD: the vanilla ones are three glyphs.
return {
["BRN"] = "",
["FRZ"] = "",
["PAR"] = "",
["PSN"] = "",
["SLP"] = "",
}
+584
View File
@@ -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.",
}
+7
View File
@@ -0,0 +1,7 @@
-- Trainer class names
--
-- Trainer class names for Espanol.
return {
["OPP_FIX_YOUNGSTER"] = "",
}
+127
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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; }
+1
View File
@@ -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
+36 -8
View File
@@ -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
+432
View File
@@ -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
+415
View File
@@ -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
+9
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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)
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+198
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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")
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+3
View File
@@ -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 })',
+220
View File
@@ -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
+15 -1
View File
@@ -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)
+3 -3
View File
@@ -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)
+2 -1
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+85 -19
View File
@@ -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
+63
View File
@@ -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)
+65
View File
@@ -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()
+168
View File
@@ -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()
+315
View File
@@ -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()
+345
View File
@@ -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")
+5 -1
View File
@@ -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
+25
View File
@@ -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")
+19 -16
View File
@@ -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")
+154
View File
@@ -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")
+42
View File
@@ -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")
+151
View File
@@ -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")
+42
View File
@@ -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")
+442
View File
@@ -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()
+181
View File
@@ -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()
+46
View File
@@ -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"
+51
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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),
+1
View File
@@ -6566,6 +6566,7 @@
}
],
"oakSpeech": {
"demoSpecies": "PIKACHU",
"shrink1": "assets/generated/intro/shrink1.png",
"shrink2": "assets/generated/intro/shrink2.png"
},