mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
Merge branch 'dev' into spidercar2
This commit is contained in:
+20
-1
@@ -2,6 +2,8 @@
|
|||||||
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
-- ghost, elevators, the Game Corner coins/prizes, the SS Anne departure
|
||||||
-- and the Hall of Fame record. Each cites its pokered source.
|
-- and the Hall of Fame record. Each cites its pokered source.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -908,17 +910,34 @@ M.VERMILION_DOCK = {
|
|||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
-- the ship is long gone: erase her right away, and anyone who
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
-- still lands here is sent back out past the guard
|
-- still lands here is sent back out past the guard unless a mod
|
||||||
|
-- explicitly permits this occupied map state. This hook surrounds
|
||||||
|
-- only the ejection decision; map-script registration and dispatch
|
||||||
|
-- stay unchanged, and the departed ship remains erased.
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
ow.map:setBlock(b.bx, b.by, b.water)
|
ow.map:setBlock(b.bx, b.by, b.water)
|
||||||
end
|
end
|
||||||
ow.map.renderer:rebuild()
|
ow.map.renderer:rebuild()
|
||||||
|
local occupancyAllowed = false
|
||||||
|
if Runtime.wantsHook("map.occupancy_allowed") then
|
||||||
|
local player = ow.player or {}
|
||||||
|
occupancyAllowed = Runtime.call("map.occupancy_allowed",
|
||||||
|
function() return false end, game, {
|
||||||
|
mapId = "VERMILION_DOCK",
|
||||||
|
reason = "ss_anne_departed",
|
||||||
|
gameVersion = game.save and game.save.version,
|
||||||
|
x = player.cellX,
|
||||||
|
y = player.cellY,
|
||||||
|
}) == true
|
||||||
|
end
|
||||||
|
if not occupancyAllowed then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||||
or "The ship set sail.", function()
|
or "The ship set sail.", function()
|
||||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||||
end))
|
end))
|
||||||
|
end
|
||||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
-- triggers the departure (wDestinationWarpID == 1 in pokered)
|
||||||
|
|||||||
@@ -469,8 +469,11 @@ is warned once per name and the rest of the list still runs. The engine's own
|
|||||||
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
Gen 1 verbs are **not** seeded on Gold: a row-list verb handed Gold's ctx would
|
||||||
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
find no runner on it, so `data.commands` under Gen 2 is the mod verbs alone.
|
||||||
|
|
||||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`, exports.**
|
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||||
Generation-agnostic; nothing to adapt.
|
`mod.developer`, exports.** Generation-agnostic; nothing to adapt.
|
||||||
|
`mod.developer` is the same fixed boot-time boolean on both generations and is
|
||||||
|
available while the entry chunk runs. Gold does not gain Gen 1's developer
|
||||||
|
console or F5 hot-reload hotkey; the field reports the loader's mode only.
|
||||||
|
|
||||||
**`mod.world`.** Same method set, resolved against Gold's world
|
**`mod.world`.** Same method set, resolved against Gold's world
|
||||||
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are
|
||||||
@@ -783,6 +786,9 @@ name and the existing payload, plus fields where Gen 2 genuinely carries more
|
|||||||
|
|
||||||
The list is much shorter than it was. What is outstanding, in descending value:
|
The list is much shorter than it was. What is outstanding, in descending value:
|
||||||
|
|
||||||
|
- `battle.field_residual`: the first guarded call site is in Gen 1 end-of-round
|
||||||
|
processing. Gold already has a native weather/between-turn pipeline but does
|
||||||
|
not yet expose the shared data-only descriptor hook.
|
||||||
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
- `trainer.before_battle`: Gold constructs and pushes its trainer battle in
|
||||||
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
`src/world/gen2/World.lua:startBattle`, which does not yet expose a deferred
|
||||||
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
preparation boundary or a battle-local player-party view. Gen 1 mods can use
|
||||||
|
|||||||
+202
-4
@@ -228,6 +228,66 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
|||||||
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
read-only snapshots; mods choose which layers to render. Red and Gold expose
|
||||||
the same contract while applying their own object and event visibility rules.
|
the same contract while applying their own object and event visibility rules.
|
||||||
|
|
||||||
|
### Active Gen 1 block checks
|
||||||
|
|
||||||
|
Red, Blue, and Yellow expose
|
||||||
|
`mod.world:activeBlockAt(mapId, blockX, blockY)`. It returns the numeric block
|
||||||
|
ID at one zero-based block coordinate only when `mapId` is the active map.
|
||||||
|
The value is a scalar snapshot: changing it cannot change the map. This lets a
|
||||||
|
mod compare a small runtime map signature before it applies a lawful authored
|
||||||
|
replacement, without reading the mutable map or ROM cache through engine
|
||||||
|
internals.
|
||||||
|
|
||||||
|
The method fails closed. Before an overworld exists it returns
|
||||||
|
`nil, "no overworld"`; for a different active map it returns
|
||||||
|
`nil, "map is not active"`; non-numeric, non-finite, or fractional coordinates
|
||||||
|
return `nil, "invalid block coordinates"`; and negative or out-of-range
|
||||||
|
coordinates return `nil, "block coordinates out of bounds"`. An unavailable
|
||||||
|
or malformed active block returns `nil, "block unavailable"`. The caller must
|
||||||
|
require every expected cell to match before changing presentation. This method
|
||||||
|
is Gen 1-only; Gold callers receive no parity promise for it.
|
||||||
|
|
||||||
|
The same unavailable result covers missing or sparse active block storage and
|
||||||
|
an accessor result that does not match its validated active block slot.
|
||||||
|
|
||||||
|
### Conditional map occupancy
|
||||||
|
|
||||||
|
`map.occupancy_allowed` is a narrow Gen 1 hook around a map script's vanilla
|
||||||
|
decision to eject the player from an otherwise valid loaded map. Its first
|
||||||
|
call site is the post-departure `VERMILION_DOCK` branch. The ship has already
|
||||||
|
been erased when the hook runs, and the hook does not replace or suppress any
|
||||||
|
base or peer map handler.
|
||||||
|
|
||||||
|
The wrapper receives `(next, game, context)`. The dock context is a copied
|
||||||
|
`{ mapId = "VERMILION_DOCK", reason = "ss_anne_departed", gameVersion, x, y }`
|
||||||
|
record. Vanilla returns `false`. Return exactly `true` to allow the player to
|
||||||
|
remain; every other value denies occupancy and preserves the normal message
|
||||||
|
and warp. A composable wrapper calls downstream first and only adds its own
|
||||||
|
permission:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("map.occupancy_allowed", function(next, game, ctx)
|
||||||
|
local allowed = next(game, ctx)
|
||||||
|
local mine = ctx.mapId == "VERMILION_DOCK"
|
||||||
|
and ctx.reason == "ss_anne_departed"
|
||||||
|
and myPublicEligibilityCheck(game)
|
||||||
|
return allowed == true or mine == true
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no wrapper, the hook allocates no context and vanilla behavior is
|
||||||
|
unchanged. A throwing wrapper is isolated by the normal hook bus. A nil,
|
||||||
|
string, number, table, or other malformed final answer fails closed. Disabling
|
||||||
|
or uninstalling the permitting mod therefore restores vanilla ejection without
|
||||||
|
changing the S.S. Anne story flag or restoring the ship.
|
||||||
|
|
||||||
|
Normal hook-chain ownership applies: a wrapper that does not call `next`
|
||||||
|
intentionally owns the final answer and does not run lower-priority wrappers.
|
||||||
|
Permission wrappers must call `next` as shown above to compose. A noncompliant
|
||||||
|
wrapper that returns false without calling `next` safely denies occupancy and
|
||||||
|
can suppress downstream permission by this standard rule. A malformed answer
|
||||||
|
also fails closed and cannot force occupancy.
|
||||||
|
|
||||||
## Party ordering
|
## Party ordering
|
||||||
|
|
||||||
Companion UIs and alternate party screens can call
|
Companion UIs and alternate party screens can call
|
||||||
@@ -265,6 +325,80 @@ Red exposes FLY separately because it requires a destination picker:
|
|||||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||||
Fly town list. Gold does not expose these two methods yet.
|
Fly town list. Gold does not expose these two methods yet.
|
||||||
|
|
||||||
|
## Self-driven world actors
|
||||||
|
|
||||||
|
`mod.world:spawnNpc()` returns a handle whose `scriptMove` queues onto the
|
||||||
|
overworld's scripted-movement list. A non-empty list is how the overworld
|
||||||
|
knows a cutscene is running, so it gates player input for as long as the
|
||||||
|
actor walks -- right for Oak marching to his lab, wrong for an actor that
|
||||||
|
moves on its own schedule (a networked player's ghost, an ambient walker).
|
||||||
|
Five handle methods drive one without that lockout:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ghost = mod.world:spawnNpc({ map = "ROUTE_1", x = 5, y = 7,
|
||||||
|
sprite = "SPRITE_RED" })
|
||||||
|
if ghost:canStep("up") then ghost:stepNow("up") end -- one tile, now
|
||||||
|
if not ghost:isMoving() then ghost:placeAt(9, 3, "down") end -- snap, no walk
|
||||||
|
ghost:setPassable(true) -- walk-through
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow(dir)` sets the same per-tile state `scriptMove` does, minus the
|
||||||
|
queue. It deliberately does **not** check collision: a caller replaying a
|
||||||
|
move that was already decided elsewhere (validated on a peer's machine, or
|
||||||
|
authored) would let the two copies disagree about where the actor is if this
|
||||||
|
re-judged it. Ask `canStep(dir)` first when you do want the map's opinion.
|
||||||
|
`placeAt(x, y, facing)` snaps with no animation and clears any step in
|
||||||
|
flight, for a warp arrival or a resync too far gone to walk off.
|
||||||
|
`isMoving()` lets a driver pace itself instead of stomping a move already
|
||||||
|
running. `setPassable(flag)` is the flag `Collision.occupied` skips (the
|
||||||
|
engine's own user is Yellow's companion Pikachu); a passable object still
|
||||||
|
draws and can still be talked to.
|
||||||
|
|
||||||
|
An object spawned this way carries no `TEXT_*` id, so the vanilla talk path
|
||||||
|
has nothing to say for it. The **`world.talk`** hook is the A press on an
|
||||||
|
object, raised before the map's text tables get it:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
if mine(target) then
|
||||||
|
say(target) -- the mod answers for an object it owns
|
||||||
|
return -- ...by not calling next()
|
||||||
|
end
|
||||||
|
return next(ow, target) -- everything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no subscriber the A press reaches `talkTo` exactly as before. An object
|
||||||
|
mid-step raises no hook, matching the vanilla gate.
|
||||||
|
|
||||||
|
## Adopting an already-paired link session
|
||||||
|
|
||||||
|
`LinkState.newFromSession(game, transport, mode, isHost, opts)` starts a link
|
||||||
|
session on a transport that is *already* paired, skipping the address/code
|
||||||
|
entry UI while keeping the hello and fingerprint compatibility exchange
|
||||||
|
intact. `transport` is anything `Session` accepts, which is what lets a mode
|
||||||
|
tunnel a battle through its own connection rather than opening a second one.
|
||||||
|
|
||||||
|
When the battle finishes, **`link.battle_ended`** reports the outcome:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = { result, myParty, theirParty, peerName, role }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The party copies are the point. Cable rules leave the real party untouched,
|
||||||
|
so a mode built on link battles -- a tournament ladder, a battle royale --
|
||||||
|
has no other way to learn what the fight cost, and by the time the state
|
||||||
|
unwinds the battle object is gone. `role` is `"host"` or `"guest"`.
|
||||||
|
|
||||||
|
Two smaller pieces support the same shape of mode. `Game:startNewGame(opts)`
|
||||||
|
is the title screen's NEW GAME closure made callable, with `opts.intro =
|
||||||
|
false` to land straight in the world -- a mode that hands out its own starting
|
||||||
|
state has no use for Oak's speech. `CodeEntry.new` takes an optional
|
||||||
|
`{ length = , charset = }`, so the slot-scrub widget that enters a link code
|
||||||
|
can also carry a room code or an address.
|
||||||
|
|
||||||
## Read-only battle snapshots
|
## Read-only battle snapshots
|
||||||
|
|
||||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||||
@@ -687,15 +821,38 @@ the hook context.
|
|||||||
|
|
||||||
## Developer console
|
## Developer console
|
||||||
|
|
||||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
On a Gen 1 boot, developer mode unlocks the in-game console and hot-reload
|
||||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass `--developer`
|
||||||
`--developer` on the command line:
|
on the command line:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
love . --developer
|
love . --developer
|
||||||
```
|
```
|
||||||
|
|
||||||
While developer mode is active:
|
The mod loader independently derives a matching boolean for every sandboxed
|
||||||
|
entry chunk as `mod.developer`. It is available while the entry file is
|
||||||
|
loading, so a mod can keep diagnostic commands, screens, and verbose tracing
|
||||||
|
out of player builds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", function(ctx)
|
||||||
|
-- open or print this mod's diagnostic view
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`mod.developer` is a plain boolean snapshot for this boot. It grants no
|
||||||
|
permission and exposes neither the process environment nor the loader. In a
|
||||||
|
normal player boot it is `false`; `POKEPORT_DEV=1` and `--developer` make it
|
||||||
|
`true`. On Gen 1 those inputs separately enable the console and hot-reload
|
||||||
|
hotkeys. The headless loader's `opts.dev` test seam changes only the loader
|
||||||
|
signal and diagnostics; it does not enable the game's console or hot reload.
|
||||||
|
Gold exposes the same `mod.developer` boolean but does not implement the Gen 1
|
||||||
|
console or hotkeys. Use a mod option for player-facing feature toggles rather
|
||||||
|
than treating developer mode as configuration.
|
||||||
|
|
||||||
|
While developer mode is active on Gen 1:
|
||||||
|
|
||||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||||
@@ -857,6 +1014,47 @@ which stay unconditional and are never visible to a subscriber.
|
|||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|
||||||
|
## Battle field residual hook
|
||||||
|
|
||||||
|
`battle.field_residual` lets a Gen 1 battle-rule mod request end-of-round
|
||||||
|
damage without mutating live battlers. It is guarded and runs after vanilla
|
||||||
|
status residuals, before field-token expiry and `battle.turn_ended`. The
|
||||||
|
wrapper receives `(next, context)`, calls `next(context)` for the existing
|
||||||
|
descriptor list, and appends data-only rows:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy", amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.field` is a detached, data-only view with the same
|
||||||
|
`{ weather, tokens }` shape that battle checkpoints capture; it does not expose
|
||||||
|
`field.sides` or any live battler aliases. The projection recursively retains
|
||||||
|
raw tables and finite numbers, strings, and booleans under scalar keys. It
|
||||||
|
strips metatables and omits functions, userdata, threads, unsupported keys, and
|
||||||
|
cyclic edges. Consequently a wrapper cannot obtain or invoke an engine callback
|
||||||
|
even if a live field token uses one internally, and changing any nested view
|
||||||
|
value cannot change live field state. `context.battlers.player` and `.enemy` are
|
||||||
|
detached `{ side, name, hp, maxHp, types, vanished }` views, and `context.turn`
|
||||||
|
is the current turn number. A descriptor accepts `side`
|
||||||
|
(`player` or `enemy`), a positive, finite integer number `amount`, and an
|
||||||
|
optional string `message`.
|
||||||
|
Numeric strings and invalid rows are ignored; damage is clamped to current HP.
|
||||||
|
The engine retains HP-bar, faint, experience, and replacement authority. If
|
||||||
|
both active battlers take terminal residual damage together and the player has
|
||||||
|
no healthy reserve, this hook batch queues only the player faint authority and
|
||||||
|
resolves as a blackout loss without an enemy-faint EXP award or replacement.
|
||||||
|
That precedence is local to accepted rows from this hook; native faint paths
|
||||||
|
are unchanged when no hook is active. Hook callbacks remain process-local;
|
||||||
|
checkpoints serialize only field data. Gold does not yet raise this hook; its
|
||||||
|
native weather pipeline is documented in `docs/mod-api-gen2-compat.md`.
|
||||||
|
|
||||||
## Process-lifecycle hooks
|
## Process-lifecycle hooks
|
||||||
|
|
||||||
These exist so a platform-specific launcher integration (a native shell
|
These exist so a platform-specific launcher integration (a native shell
|
||||||
|
|||||||
@@ -768,7 +768,8 @@ the same:
|
|||||||
So: read the log for coverage problems, and the manager for load problems.
|
So: read the log for coverage problems, and the manager for load problems.
|
||||||
|
|
||||||
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
`POKEPORT_IDENTITY=<name>` sandboxes the save directory if you want a clean
|
||||||
profile to test in, and `POKEPORT_DEV=1` adds the console and `F5` hot reload.
|
profile to test in. `POKEPORT_DEV=1` makes `mod.developer` true for loader-gated
|
||||||
|
diagnostics; Gold does not add Gen 1's console or `F5` hot reload.
|
||||||
|
|
||||||
## What this guide does not promise
|
## What this guide does not promise
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# RFC 0013: Conditional map occupancy and active-block reads
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The Gen 1 Vermilion Dock script correctly ejects a player who enters after the
|
||||||
|
S.S. Anne has departed. A content mod can add a city-side route back to that
|
||||||
|
empty harbor, but it cannot preserve the dock visit: replacing the complete
|
||||||
|
dock handler would discard vanilla behavior and peer handlers, while an added
|
||||||
|
handler cannot cancel the base handler's ejection.
|
||||||
|
|
||||||
|
A mod that changes one active map block also needs to prove that it is looking
|
||||||
|
at the expected Red, Blue, or Yellow layout before it acts. `mapOverview()` is
|
||||||
|
intentionally presentation-oriented and does not expose block identity.
|
||||||
|
Requiring internal `Map` state or generated ROM data would cross the public mod
|
||||||
|
boundary and make a wrong-version edit difficult to fail closed.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This extends Route B in `CONTRIBUTING-mods.md`: new behavior is additive,
|
||||||
|
ordinary hook composition remains the authority, an empty hook chain is a
|
||||||
|
provable no-op, and mods receive copied or scalar data rather than mutable
|
||||||
|
engine state. It supports the approved Mew-under-the-truck implementation plan
|
||||||
|
without adding any Mew-specific rule, asset, flag, or content to the engine.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### `map.occupancy_allowed`
|
||||||
|
|
||||||
|
The post-departure `VERMILION_DOCK` script calls this hook after it replaces the
|
||||||
|
ship blocks with water and immediately before it would display the departure
|
||||||
|
message and warp the player to Vermilion City.
|
||||||
|
|
||||||
|
A wrapper has this shape:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function(next, game, context) -> boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The context is a new table with these fields:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `mapId` | `"VERMILION_DOCK"` at this call site |
|
||||||
|
| `reason` | Stable reason key `"ss_anne_departed"` |
|
||||||
|
| `gameVersion` | Active save version (`red`, `blue`, or `yellow`) when present |
|
||||||
|
| `x`, `y` | Current player cell coordinates when present |
|
||||||
|
|
||||||
|
Vanilla returns `false`. The player remains only when the final chain result is
|
||||||
|
exactly `true`. Absent, throwing, or malformed wrappers therefore preserve
|
||||||
|
ejection. A wrapper composes by calling `next(game, context)` and returning
|
||||||
|
true when either downstream or its own narrow rule permits occupancy. The hook
|
||||||
|
does not replace `MapScripts` registration, merging, or dispatch, and does not
|
||||||
|
change the departure flag or reconstruct the ship.
|
||||||
|
|
||||||
|
As with every wrapper hook, a callback that does not call `next` intentionally
|
||||||
|
owns the final answer and does not run lower-priority callbacks. Permission
|
||||||
|
wrappers must call downstream to compose. A false, non-forwarding wrapper
|
||||||
|
safely denies occupancy and can suppress downstream permission by this normal
|
||||||
|
rule. A malformed final result also fails closed and cannot permit occupancy.
|
||||||
|
|
||||||
|
The call is guarded by `Runtime.wantsHook`, so an empty chain allocates no
|
||||||
|
context and follows the prior branch exactly.
|
||||||
|
|
||||||
|
### `WorldAPI:activeBlockAt`
|
||||||
|
|
||||||
|
Gen 1's public `mod.world` facade adds:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
activeBlockAt(mapId, blockX, blockY) -> blockId
|
||||||
|
| nil, reason
|
||||||
|
```
|
||||||
|
|
||||||
|
`mapId` must equal the active map ID. Coordinates are finite, integral,
|
||||||
|
zero-based block coordinates. A successful result is a numeric scalar copied
|
||||||
|
from the active runtime map. The method never returns the map's mutable block
|
||||||
|
array and never writes game or save state.
|
||||||
|
|
||||||
|
Failure reasons are stable:
|
||||||
|
|
||||||
|
| Condition | Reason |
|
||||||
|
|---|---|
|
||||||
|
| No active overworld map | `no overworld` |
|
||||||
|
| `mapId` differs from the active map | `map is not active` |
|
||||||
|
| Coordinate has the wrong type, is non-finite, or is fractional | `invalid block coordinates` |
|
||||||
|
| Coordinate is negative or outside the active map | `block coordinates out of bounds` |
|
||||||
|
| Active block data is absent or malformed | `block unavailable` |
|
||||||
|
|
||||||
|
The block slot and the active map accessor must both contain the same valid
|
||||||
|
nonnegative integer. Missing or sparse storage and inconsistent accessor data
|
||||||
|
return `block unavailable`.
|
||||||
|
|
||||||
|
Requiring the expected map ID and rejecting all ambiguous input lets a mod
|
||||||
|
compare every cell in its version-specific signature before it calls an
|
||||||
|
existing mutation API. Red, Blue, and Yellow each use their own loaded map
|
||||||
|
data. Gold does not gain this method in this RFC.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook, event, registry, manifest field, save
|
||||||
|
field, map handler, or WorldAPI method is removed or renamed. With no hook
|
||||||
|
subscriber the departed-dock behavior is unchanged. Existing callers cannot
|
||||||
|
invoke the new WorldAPI method accidentally.
|
||||||
|
|
||||||
|
The occupancy answer is not persisted by the engine. Disabling or uninstalling
|
||||||
|
a mod removes its wrapper through normal owner cleanup, so a later dock entry
|
||||||
|
uses vanilla ejection. The engine writes no new save state and neither API
|
||||||
|
returns or serializes ROM or save data.
|
||||||
|
|
||||||
|
## Verification requirements
|
||||||
|
|
||||||
|
The parity gate must prove:
|
||||||
|
|
||||||
|
- vanilla departed-dock message and warp remain with no subscriber;
|
||||||
|
- one permission wrapper can allow occupancy without removing the ship-erasure
|
||||||
|
work or any map handler;
|
||||||
|
- multiple cooperative wrappers preserve downstream permission;
|
||||||
|
- absent, throwing, nil, false, malformed, and non-forwarding hook answers fail
|
||||||
|
closed according to the normal wrapper-chain rule;
|
||||||
|
- Red, Blue, and Yellow contexts keep their version identity separate;
|
||||||
|
- disabling or removing the owner restores vanilla behavior without a save
|
||||||
|
migration;
|
||||||
|
- `activeBlockAt` accepts only the active map and valid in-range integral
|
||||||
|
coordinates, returns a scalar, and never mutates map or save state; and
|
||||||
|
- the test fixtures and resulting changes contain no ROM or save payload.
|
||||||
|
|
||||||
|
The hook must be driven through a real public `hooks:wrap` chain. The block API
|
||||||
|
must be exercised through a public `WorldAPI` instance. Tests must not replace
|
||||||
|
the production map handler with a test-only implementation.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents both contracts, their failure behavior, the
|
||||||
|
cooperative wrapper pattern, and the Gen 1-only block method. No registry or
|
||||||
|
schema changes occur, so generated registry documentation is unchanged.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, superseded, or deprecated.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# RFC 0014: Mod-driven world actors and adopted link sessions
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
A mod can already spawn a runtime object with `mod.world:spawnNpc` and can
|
||||||
|
already start a link battle. It cannot make either of them behave like
|
||||||
|
something the mod itself owns.
|
||||||
|
|
||||||
|
Three walls, each of which stops a mode rather than inconveniencing it:
|
||||||
|
|
||||||
|
**An actor cannot move on its own schedule.** The only public way to animate a
|
||||||
|
spawned object is `Handle:scriptMove`, which queues onto
|
||||||
|
`OverworldState.scriptMoves`. A non-empty `scriptMoves` is how the overworld
|
||||||
|
knows a cutscene is running -- `handleInput` gates on it -- so anything
|
||||||
|
animated that way freezes the player's controls for as long as it walks. That
|
||||||
|
is correct for Oak marching into his lab and wrong for an ambient walker or a
|
||||||
|
networked player's ghost, which move continuously and must not lock anyone out.
|
||||||
|
|
||||||
|
**A spawned object cannot answer the A press.** Talking is resolved from the
|
||||||
|
map's text tables by `TEXT_*` id. A runtime object has no id, so the vanilla
|
||||||
|
path has nothing to say for it, and the mod that created it has no way to say
|
||||||
|
anything either.
|
||||||
|
|
||||||
|
**A link battle cannot ride a connection the mod already has.** `LinkState`
|
||||||
|
owns pairing, so a mode that already has a socket to its peer must either open
|
||||||
|
a second connection for the battle or reimplement lockstep. And when the
|
||||||
|
battle ends, cable rules leave the real party untouched, so the damage exists
|
||||||
|
only in the battle's own copies -- which are gone by the time the state
|
||||||
|
unwinds. A mode built on link battles cannot learn what the fight cost.
|
||||||
|
|
||||||
|
The immediate consumer is an overworld multiplayer mode, but nothing here is
|
||||||
|
specific to it: the first two are wanted by any mod with an actor that moves
|
||||||
|
itself, and the third by any mode that runs battles over its own transport --
|
||||||
|
a tournament ladder, a draft, a gauntlet.
|
||||||
|
|
||||||
|
## The decision it extends
|
||||||
|
|
||||||
|
This adds nothing to the compatibility surface's shape; it extends the
|
||||||
|
**additive, guarded seam convention** that Route B in `CONTRIBUTING-mods.md`
|
||||||
|
documents, and is gated by the parity guarantee `tests/engine/gate_meta_coverage.lua`
|
||||||
|
enforces ("21-testing-and-ci: a parity gate for every extension point"; M14).
|
||||||
|
|
||||||
|
There is no in-repo D-number registry to amend; the consuming design lives
|
||||||
|
outside this repository, as it did for RFC 0011.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
### New hook: `world.talk`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
-- ow = the OverworldState raising it
|
||||||
|
-- target = the object on the faced cell
|
||||||
|
if mine(target) then
|
||||||
|
say(target)
|
||||||
|
return -- the mod answered; the text path is skipped
|
||||||
|
end
|
||||||
|
return next(ow, target) -- anything else falls through unchanged
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `OverworldState:interact`, on the branch that has already resolved
|
||||||
|
an object on the faced cell (including across a counter) and confirmed it is
|
||||||
|
not mid-step and not the Pikachu follower. It runs before the map's text
|
||||||
|
tables are consulted. With no subscriber, `Runtime.call` invokes the vanilla
|
||||||
|
fallthrough, which is a file-local function rather than a per-press closure,
|
||||||
|
so an unhooked A press allocates nothing it did not allocate before.
|
||||||
|
|
||||||
|
### New event: `link.battle_ended`
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
-- ev = {
|
||||||
|
-- result = "win" | "lose" | "draw" | "ended",
|
||||||
|
-- myParty = lockstep copy of our party,
|
||||||
|
-- theirParty = lockstep copy of theirs,
|
||||||
|
-- peerName = string,
|
||||||
|
-- role = "host" | "guest",
|
||||||
|
-- }
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call site: `LinkState:update`, in the `battleRunning` stage, once the battle
|
||||||
|
state has been popped and before `exitWith` unwinds the link stack. Guarded by
|
||||||
|
`Runtime.wants("link.battle_ended")`, so with no subscriber no payload table
|
||||||
|
is allocated and the branch runs exactly as before.
|
||||||
|
|
||||||
|
The party copies are the point of the event. They are the lockstep records the
|
||||||
|
battle actually fought with, which is where the damage lives under cable rules.
|
||||||
|
|
||||||
|
### New `WorldAPI` handle methods
|
||||||
|
|
||||||
|
```lua
|
||||||
|
handle:stepNow(dir) --> true when the step started
|
||||||
|
handle:canStep(dir) --> would stepNow land somewhere legal?
|
||||||
|
handle:placeAt(x, y, dir) --> snap, no animation, clears a step in flight
|
||||||
|
handle:isMoving() --> true while a step is animating
|
||||||
|
handle:setPassable(flag) --> may the player walk through this object?
|
||||||
|
```
|
||||||
|
|
||||||
|
`stepNow` sets the same per-tile state `scriptMove` does, without the queue and
|
||||||
|
therefore without the input lockout. It does **not** consult collision: the
|
||||||
|
intended caller is replaying a move already decided elsewhere (validated on a
|
||||||
|
peer, or authored), and re-judging it locally would let two copies of the same
|
||||||
|
actor disagree about where it is. `canStep` is the separate opinion for callers
|
||||||
|
that want it. `setPassable` sets the flag `Collision.occupied` already honours
|
||||||
|
for Yellow's companion Pikachu; a passable object still draws and still talks.
|
||||||
|
|
||||||
|
### Two supporting changes
|
||||||
|
|
||||||
|
`Game:startNewGame(opts)` -- the title screen's NEW GAME closure, made callable,
|
||||||
|
with `opts.intro = false` to land directly in the world. A mode that issues its
|
||||||
|
own starting state has no use for the intro cutscene.
|
||||||
|
|
||||||
|
`CodeEntry.new(shape)` -- accepts an optional `{ length =, charset = }`, so the
|
||||||
|
slot-scrub widget that enters a link code can also carry a room code or a
|
||||||
|
host:port address. Called with no argument it is byte-for-byte the previous
|
||||||
|
widget.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
**Existing mods change nothing.** Every item above is a new name or a new
|
||||||
|
optional argument; no existing name, payload, signature, or default changes.
|
||||||
|
|
||||||
|
The v1 surface is unaffected: `content.X:register/override/get`, `events:on`,
|
||||||
|
`hooks:wrap`, `mod.log`, `mod:read`, the manifest v1 fields and
|
||||||
|
`pokemon.before_give` all behave as before. `mods/example_mew_starter` -- api 1,
|
||||||
|
`category = "GAMEPLAY"`, whole-species copy -- loads unchanged, which
|
||||||
|
`tests/run_modkit.lua` proves on every run.
|
||||||
|
|
||||||
|
With no subscriber to either seam, Red, Blue, Yellow, Gold and Silver behave
|
||||||
|
exactly as they did: the A press reaches `talkTo`, the link battle unwinds
|
||||||
|
without building an event payload, and no handle method is reachable unless a
|
||||||
|
mod calls it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/modkit/cases/world_talk.lua` drives the real `OverworldState:interact`
|
||||||
|
path. It asserts the unhooked build first (the A press reaches `talkTo`),
|
||||||
|
then that a mod owning the object suppresses the text path by not calling
|
||||||
|
`next`, that an object the mod ignores still falls through, and that an
|
||||||
|
object mid-step raises no hook at all.
|
||||||
|
- `tests/modkit/cases/link_battle_ended.lua` drives the real `LinkState:update`
|
||||||
|
path. It asserts that with nothing subscribed the event is not wanted (so no
|
||||||
|
payload is built) and the battle still unwinds, then that a subscriber
|
||||||
|
receives the result, the role, the peer name and both party copies including
|
||||||
|
the damage the real party never took, from both sides of the cable.
|
||||||
|
- `tests/engine/gate_hooks.lua` and `tests/engine/gate_events.lua` walk the live
|
||||||
|
catalog, so both seams are parity-gated structurally; `gate_meta_coverage`
|
||||||
|
passes 208/208 with both names covered and no DEBT entry added.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded or deprecated.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# RFC 0016: Engine-owned field residual descriptors
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Battle-rule mods can keep deterministic data-only state in the public battle
|
||||||
|
field and observe `battle.turn_ended`, but that event fires after the engine's
|
||||||
|
residual and faint pipeline. A listener cannot safely deal end-of-round field
|
||||||
|
damage: directly changing live HP bypasses bar drains, faint messages,
|
||||||
|
experience, replacements, double-faint resolution, and checkpoint continuation.
|
||||||
|
Putting callbacks into `battle.field` is also rejected by the checkpoint
|
||||||
|
serializer, correctly, because executable state is not save-safe.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-004: public engine-owned field residual execution**, the
|
||||||
|
consuming decision required by Adaptive Trainers capability `ENGINE-FIELD-
|
||||||
|
RESIDUALS`. The plan is
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 8. The engine delta defines only a generic end-of-round extension point;
|
||||||
|
it contains no weather names, immunities, damage formula, trainer identity, or
|
||||||
|
Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Add the guarded Gen 1 hook:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy",
|
||||||
|
amount = 7,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
The hook runs once during an undecided battle's end-of-round processing, after
|
||||||
|
vanilla status residuals and before field/side token expiry and
|
||||||
|
`battle.turn_ended`. With no subscriber, the guarded site builds no context and
|
||||||
|
changes nothing. Vanilla contributes an empty list.
|
||||||
|
|
||||||
|
`context` is `{ field, battlers, turn }`. `field` is a strictly data-only view
|
||||||
|
with the same `{ weather, tokens }` shape captured by battle checkpoints. Its
|
||||||
|
recursive projection retains raw tables and finite numbers, strings, and
|
||||||
|
booleans under scalar keys. It strips metatables and omits functions, userdata,
|
||||||
|
threads, unsupported keys, and cyclic edges. Thus it exposes neither
|
||||||
|
`field.sides` nor a graph or executable callback back into live engine state.
|
||||||
|
`battlers.player` and `battlers.enemy` are detached snapshots with `{ side,
|
||||||
|
name, hp, maxHp, types, vanished }`. Changing either detached view cannot
|
||||||
|
change the live battle. `turn` is the current Gen 1 turn counter.
|
||||||
|
|
||||||
|
A result row is `{ side = "player"|"enemy", amount =
|
||||||
|
positive_finite_integer_number, message = optional_string }`. Numeric strings,
|
||||||
|
zero, negatives, fractions, NaN, infinities, malformed sides, and non-string
|
||||||
|
messages fail closed. Damage is clamped to current HP. The engine owns
|
||||||
|
mutation, HP-bar drain rows, and its existing faint pipeline. It applies every
|
||||||
|
accepted row before scheduling newly fainted battlers. If this hook batch
|
||||||
|
terminally faints the player with no healthy reserve, it queues only the player
|
||||||
|
faint authority, so descriptor order cannot race a blackout against an enemy
|
||||||
|
EXP/replacement path. Otherwise it schedules newly fainted battlers in fixed
|
||||||
|
player/enemy order. This precedence is scoped to this hook response; native
|
||||||
|
faint paths, including `enemyMonFainted`, keep their existing no-hook behavior.
|
||||||
|
Wrappers compose by calling `next(context)` and appending their own rows.
|
||||||
|
Callbacks are not part of the descriptor contract and hook functions are never
|
||||||
|
stored in the battle.
|
||||||
|
|
||||||
|
Gold already owns native weather and a generation-specific between-turn order;
|
||||||
|
this first additive call site is Gen 1-only. A future Gold site must keep the
|
||||||
|
same context and descriptor contract and choose its native ordering explicitly.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. No hook name or payload changes. With no wrapper,
|
||||||
|
Gen 1 performs the same residual, token, event, and faint work as before,
|
||||||
|
including native simultaneous-faint resolution, and allocates no context. Gen
|
||||||
|
2 is unchanged. Existing and new checkpoints keep
|
||||||
|
serializing only the data stored in `battle.field`; hook callbacks remain
|
||||||
|
process-local loader state and are never serialized.
|
||||||
|
|
||||||
|
The v1 surface remains unchanged: `content.X:register/override/get`,
|
||||||
|
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields, and
|
||||||
|
`pokemon.before_give` keep their existing behavior.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- The catalog hook parity gate proves null and live-empty buses return the
|
||||||
|
vanilla list unchanged.
|
||||||
|
- A sandboxed fixture mod exercises the seam through `mod.hooks`, verifies the
|
||||||
|
detached checkpoint-shaped context, applies damage, and reaches the engine
|
||||||
|
faint pipeline.
|
||||||
|
- Engine validation tests cover strict number validation (including numeric
|
||||||
|
strings, NaN, infinities, zero, and negatives), nested mutation isolation,
|
||||||
|
omission of functions/userdata/threads/cycles and metatables from the public
|
||||||
|
field projection, optional messages, non-table results, clamping, and
|
||||||
|
settled-battle suppression.
|
||||||
|
- Both descriptor orders are driven through queue completion for a simultaneous
|
||||||
|
terminal residual. Each proves player blackout loss with no EXP event,
|
||||||
|
enemy replacement, or replacement UI.
|
||||||
|
- A disabled-bus sentinel proves the guard performs no `Runtime.call` or field
|
||||||
|
context construction. An ordering probe proves the enabled hook runs after
|
||||||
|
vanilla status residuals and before token expiry and `battle.turn_ended`.
|
||||||
|
- A no-hook native regression proves a simultaneous zero-HP state still enters
|
||||||
|
the pre-existing enemy-faint EXP and win authority outside this hook batch.
|
||||||
|
- Capture/restore/capture evidence proves checkpointed field state round-trips
|
||||||
|
while the enabled process-local hook remains installed and callable.
|
||||||
|
|
||||||
|
## Docs with the change
|
||||||
|
|
||||||
|
`docs/modding.md` documents the Gen 1 timing, detached payload, descriptor
|
||||||
|
validation, simultaneous-terminal result, and checkpoint boundary.
|
||||||
|
`docs/mod-api-gen2-compat.md` records that Gold does not yet expose the hook.
|
||||||
|
No registry or schema changes are involved, so generated registry docs do not
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is deprecated. The hook is additive.
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# RFC 0017: Public mod developer-mode signal
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The loader already derives a boot-time developer-mode flag for its permission
|
||||||
|
diagnostics and headless test seam. Gen 1's `Game` independently derives a
|
||||||
|
similarly sourced flag for its console and hot reload. A sandboxed mod cannot
|
||||||
|
read either one. `mod.commands` can register a diagnostic command but cannot
|
||||||
|
say whether the current boot is a developer boot. `mod.exports` only publishes
|
||||||
|
values to other mods. `game.ready` fires after entry registration and carries
|
||||||
|
only the game. `mod.options` is player configuration, not engine mode, and
|
||||||
|
`mod.log` logs unconditionally. The pre-sandbox compatibility
|
||||||
|
`os.getenv("POKEPORT_DEV")` deliberately returns `nil`, because the process
|
||||||
|
environment is hidden from mods.
|
||||||
|
|
||||||
|
The concrete consumer is **Adaptive Trainers**. Its approved Chapter 30 and
|
||||||
|
Phase H require trainer, boss, Rival, and League diagnostic views plus
|
||||||
|
seed-label tracing to exist only when `POKEPORT_DEV` is active. Without a
|
||||||
|
public signal, the mod must either ship those registrations in production,
|
||||||
|
misuse a player option, or import loader/Logger internals. All three violate
|
||||||
|
the approved observability boundary or the sandbox/public-API policy.
|
||||||
|
|
||||||
|
## Decision and plan extended
|
||||||
|
|
||||||
|
This implements **D-AT-005: diagnostics and seed tracing are admitted only by
|
||||||
|
the engine's developer-mode decision**. The consuming design is tracked in the
|
||||||
|
Adaptive Trainers implementation plan,
|
||||||
|
[`docs/superpowers/plans/2026-08-14-adaptive-trainers.md`](https://github.com/MaxTomahawk/gen1recomp-adaptive-trainers/blob/main/docs/superpowers/plans/2026-08-14-adaptive-trainers.md),
|
||||||
|
Task 9. The engine delta is generic and contains no trainer, balancing,
|
||||||
|
diagnostic-layout, seed-label, or Adaptive Trainers policy.
|
||||||
|
|
||||||
|
## Exact API delta
|
||||||
|
|
||||||
|
Every sandboxed mod object adds one field:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
mod.developer -- boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
The loader copies its existing `dev` decision into this field before invoking
|
||||||
|
the mod's entry chunk. It is therefore available for load-time registration:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("my_mod:diagnostics", diagnostics_command)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is a plain boolean snapshot, not a loader reference or environment
|
||||||
|
facade. `false` is the normal player-build answer. `POKEPORT_DEV=1` and the
|
||||||
|
`--developer` command-line path make the loader flag true; on Gen 1 those inputs
|
||||||
|
separately make `Game`'s own developer flag true for its console and hot
|
||||||
|
reload. The loader's existing injected `opts.dev` test seam changes only the
|
||||||
|
loader flag and diagnostics, not `Game` or its hotkeys. It grants no permission
|
||||||
|
and does not expose environment variables. The answer is fixed for the life of
|
||||||
|
that loader; changing a field on a mod's own table cannot change engine mode.
|
||||||
|
|
||||||
|
The field is generation-independent and has identical semantics on Red, Blue,
|
||||||
|
Yellow, Gold, and Silver. Gold and Silver do not gain Gen 1's developer console
|
||||||
|
or hot-reload hotkeys from this field.
|
||||||
|
|
||||||
|
## Migration and compatibility
|
||||||
|
|
||||||
|
Existing mods change nothing. `mod.developer` is additive, requires no
|
||||||
|
permission, and does not bump the integer mod API. Existing API-v1 and API-v2
|
||||||
|
entry chunks receive one extra scalar field and retain all prior fields and
|
||||||
|
methods unchanged. No name is removed or shadowed.
|
||||||
|
|
||||||
|
With no mods installed, `Loader:_api` is never called, so the delta allocates no
|
||||||
|
mod object and changes no data, save, options, event, hook, command, or file.
|
||||||
|
With mods installed in a normal boot, the new field is `false` unless an author
|
||||||
|
explicitly reads it. Existing registration and logging behavior is unchanged.
|
||||||
|
|
||||||
|
An adopting mod should gate developer-only registrations and verbose logging
|
||||||
|
directly on `mod.developer`. Player-facing behavior belongs behind
|
||||||
|
`mod.options`, not this signal.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/engine/mod_developer_mode_test.lua` loads a real sandboxed mod through
|
||||||
|
the public SDK with developer mode both on and off. It proves the boolean is
|
||||||
|
available during entry execution and that the same source registers its
|
||||||
|
diagnostic command only for the developer load. It also covers the
|
||||||
|
command-line global path and a Gen 2 load.
|
||||||
|
- `tests/engine/mod_developer_mode_parity_test.lua` is the separate no-mod
|
||||||
|
parity suite. It proves both developer answers discover no mods, create no
|
||||||
|
files, and leave injected vanilla data unchanged. It also loads an unchanged
|
||||||
|
API-v1 probe and verifies identity, `mod:read`, exports, and options behavior.
|
||||||
|
- The full ROM-free engine and modkit tiers remain the compatibility proof for
|
||||||
|
`content.X:register/override/get`, `events:on`, `hooks:wrap`, `mod.log`,
|
||||||
|
`mod:read`, manifest v1 fields, and `pokemon.before_give`.
|
||||||
|
|
||||||
|
No registry or schema changes are involved, so generated registry documentation
|
||||||
|
is unaffected.
|
||||||
|
|
||||||
|
## Deprecation etiquette
|
||||||
|
|
||||||
|
Nothing is removed, renamed, superseded, or deprecated.
|
||||||
@@ -84,40 +84,8 @@ local closeEditor -- forward declaration: openEditor hands it to the editor
|
|||||||
|
|
||||||
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||||
-- version session (save editor or game). closeEditor and returnToLauncher
|
-- version session (save editor or game). closeEditor and returnToLauncher
|
||||||
-- both go through here so neither path can forget a singleton the other resets.
|
-- both go through SessionLifecycle so neither path forgets a singleton.
|
||||||
local function teardownMountedSession(version)
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
if version then
|
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
end
|
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
local Runtime = require("src.mods.Runtime")
|
|
||||||
if Runtime.reset then Runtime.reset() end
|
|
||||||
local Assets = require("src.render.Assets")
|
|
||||||
if Assets.installLoader then Assets.installLoader(nil) end
|
|
||||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
|
||||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Evict every save-editor module from package.loaded without a hardcoded
|
|
||||||
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
|
||||||
-- tools/save-editor/; path-style keys may also appear. A key is flushed
|
|
||||||
-- when it names a save-editor path or when tools/save-editor/{panels/}K.lua
|
|
||||||
-- exists for a flat name K -- new panels are picked up automatically.
|
|
||||||
local function flushEditorPackageLoaded()
|
|
||||||
local fs = love and love.filesystem
|
|
||||||
local function isEditorFlat(name)
|
|
||||||
if not (fs and fs.getInfo) then return false end
|
|
||||||
if name:find("[./]") then return false end
|
|
||||||
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
|
||||||
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
|
||||||
end
|
|
||||||
for k in pairs(package.loaded) do
|
|
||||||
if type(k) == "string"
|
|
||||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
|
||||||
package.loaded[k] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||||
-- their directories have to be on the require path. It must be
|
-- their directories have to be on the require path. It must be
|
||||||
@@ -194,9 +162,7 @@ local function openEditor(version, slotId)
|
|||||||
local okReq, appOrErr = pcall(require, "App")
|
local okReq, appOrErr = pcall(require, "App")
|
||||||
if not okReq then
|
if not okReq then
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
require("src.import.CacheFs").unmountVersion(version)
|
|
||||||
end
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -216,10 +182,7 @@ local function openEditor(version, slotId)
|
|||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp.unload then pcall(EditorApp.unload) end
|
if EditorApp.unload then pcall(EditorApp.unload) end
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = nil })
|
||||||
teardownMountedSession(version)
|
|
||||||
end
|
|
||||||
flushEditorPackageLoaded()
|
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
editorHost = nil
|
editorHost = nil
|
||||||
@@ -238,13 +201,10 @@ end
|
|||||||
-- next Edit or Play does not inherit the editor's dead mod loader.
|
-- next Edit or Play does not inherit the editor's dead mod loader.
|
||||||
function closeEditor()
|
function closeEditor()
|
||||||
local version = editorVersion
|
local version = editorVersion
|
||||||
|
local app = EditorApp
|
||||||
editorMode = false
|
editorMode = false
|
||||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
|
||||||
EditorApp = nil
|
EditorApp = nil
|
||||||
if version then
|
SessionLifecycle.endEditorSession({ version = version, app = app })
|
||||||
teardownMountedSession(version)
|
|
||||||
end
|
|
||||||
flushEditorPackageLoaded()
|
|
||||||
editorVersion = nil
|
editorVersion = nil
|
||||||
restoreWindow()
|
restoreWindow()
|
||||||
Importer = editorHost
|
Importer = editorHost
|
||||||
@@ -345,32 +305,9 @@ end
|
|||||||
local function returnToLauncher()
|
local function returnToLauncher()
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
|
|
||||||
pcall(function() require("src.core.Music").stop() end)
|
|
||||||
pcall(function() require("src.core.Sound").stop() end)
|
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.core.DiscordPresence"] then
|
|
||||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.core.gen2.Clock"] then
|
|
||||||
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.net.Gen1Tls"] then
|
|
||||||
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
|
||||||
end
|
|
||||||
if love.audio and love.audio.stop then
|
|
||||||
pcall(love.audio.stop)
|
|
||||||
end
|
|
||||||
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
teardownMountedSession(currentVersion)
|
SessionLifecycle.endGameSession(Game)
|
||||||
|
|
||||||
if Game.reset then
|
|
||||||
pcall(function() Game:reset() end)
|
|
||||||
end
|
|
||||||
Game = nil
|
Game = nil
|
||||||
autopilot = nil
|
autopilot = nil
|
||||||
driverCo = nil
|
driverCo = nil
|
||||||
@@ -380,10 +317,7 @@ local function returnToLauncher()
|
|||||||
require("src.core.SaveData").setCart(nil)
|
require("src.core.SaveData").setCart(nil)
|
||||||
require("src.core.GameSpeed").setAllowed(nil)
|
require("src.core.GameSpeed").setAllowed(nil)
|
||||||
|
|
||||||
local Input = require("src.core.Input")
|
SessionLifecycle.endMountedSession(currentVersion)
|
||||||
local TouchControls = require("src.core.TouchControls")
|
|
||||||
Input:reset()
|
|
||||||
TouchControls:reset()
|
|
||||||
|
|
||||||
require("src.core.Orientation").applyOptions(
|
require("src.core.Orientation").applyOptions(
|
||||||
require("src.core.SaveData").loadOptions())
|
require("src.core.SaveData").loadOptions())
|
||||||
@@ -746,7 +680,7 @@ function love.gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadpressed(joystick, button) end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
@@ -766,7 +700,7 @@ function love.gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadreleased(joystick, button) end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
@@ -786,7 +720,7 @@ function love.gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.gamepadaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
@@ -806,7 +740,7 @@ function love.joystickpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickpressed(joystick, button) end
|
||||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickpressed(joystick, button)
|
Game:joystickpressed(joystick, button)
|
||||||
@@ -826,7 +760,7 @@ function love.joystickreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickreleased(joystick, button) end
|
||||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickreleased(joystick, button)
|
Game:joystickreleased(joystick, button)
|
||||||
@@ -846,7 +780,7 @@ function love.joystickaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickaxis(joystick, axis, value) end
|
||||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickaxis(joystick, axis, value)
|
Game:joystickaxis(joystick, axis, value)
|
||||||
@@ -866,7 +800,7 @@ function love.joystickhat(joystick, hat, direction)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if Studio then return end
|
if Studio then return Studio.joystickhat(joystick, hat, direction) end
|
||||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
if not Game then return end
|
if not Game then return end
|
||||||
Game:joystickhat(joystick, hat, direction)
|
Game:joystickhat(joystick, hat, direction)
|
||||||
@@ -1205,22 +1139,7 @@ function love.quit()
|
|||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
end)
|
||||||
-- LOVE waits for every live love.thread before the process exits, and both
|
SessionLifecycle.endProcess()
|
||||||
-- background workers idle in a loop that only a "quit" command breaks, so
|
|
||||||
-- without this the process outlived the window and the next launch re-entered
|
|
||||||
-- the dead one instead of starting fresh (#339)
|
|
||||||
if package.loaded["src.core.ChipAudio"] then
|
|
||||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
|
||||||
end
|
|
||||||
if package.loaded["src.update.Check"] then
|
|
||||||
pcall(package.loaded["src.update.Check"].shutdown)
|
|
||||||
end
|
|
||||||
-- The launcher's fetch pool is the same story: its workers idle in
|
|
||||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
|
||||||
-- touched the network would hang the process on exit (#339's shape again).
|
|
||||||
if package.loaded["src.net.Fetch"] then
|
|
||||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# LÖVE UWP binaries
|
# LÖVE UWP binaries
|
||||||
|
|
||||||
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb`.
|
These x64 UWP Release binaries were built from [`caorthann-celt/love-xbox-uwp`](https://github.com/caorthann-celt/love-xbox-uwp) at commit `3f51bf0be5f3f86e5934da3893caba2b817f82c1`.
|
||||||
|
|
||||||
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
The build uses LÖVE 11.5, LuaJIT, SDL2, and ANGLE. Keep the DLLs and import libraries together; they are one binary interface.
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+3
-3
@@ -7,7 +7,7 @@
|
|||||||
"love": {
|
"love": {
|
||||||
"version": "11.5",
|
"version": "11.5",
|
||||||
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
"repository": "https://github.com/caorthann-celt/love-xbox-uwp.git",
|
||||||
"commit": "cdf85f28ed794c95d6f5f7e3a8f23ca9ee1fbdcb"
|
"commit": "3f51bf0be5f3f86e5934da3893caba2b817f82c1"
|
||||||
},
|
},
|
||||||
"luajit": {
|
"luajit": {
|
||||||
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
"repository": "https://github.com/LuaJIT/LuaJIT.git",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/bin/love.dll",
|
"path": "love/bin/love.dll",
|
||||||
"sha256": "A3622828014F03DDB502D2A7807DC8F94432172595BE03508099BD66937FD45F"
|
"sha256": "E2CE334817636AA9EFF97E181FB5C33C506971F2AC7C653F2CA751B5CDE2FCE6"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/bin/lua51.dll",
|
"path": "love/bin/lua51.dll",
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/lib/lovestatic.lib",
|
"path": "love/lib/lovestatic.lib",
|
||||||
"sha256": "15FB3A9CB3C3CDAB8B33DE5B1D1F8D8FF6CE9719E9AA7BC2CF3D7B51CDCE0C98"
|
"sha256": "3794690ED3F44DB6F0C83BBC1673979922627929E852E7668C9911DE1F72B972"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "love/lib/lua51.lib",
|
"path": "love/lib/lua51.lib",
|
||||||
|
|||||||
@@ -2798,6 +2798,111 @@ function BattleState:queueResidual(b, opp)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fieldBattlerView(battler, side)
|
||||||
|
local types = {}
|
||||||
|
for index, typeId in ipairs(battler.curTypes or {}) do
|
||||||
|
types[index] = typeId
|
||||||
|
end
|
||||||
|
local mon = battler.mon or {}
|
||||||
|
return {
|
||||||
|
side = side,
|
||||||
|
name = battler.name,
|
||||||
|
hp = tonumber(mon.hp) or 0,
|
||||||
|
maxHp = tonumber(mon.stats and mon.stats.hp) or tonumber(mon.hp) or 0,
|
||||||
|
types = types,
|
||||||
|
vanished = battler.invulnerable and true or false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function publicScalar(value)
|
||||||
|
local kind = type(value)
|
||||||
|
if kind == "string" or kind == "boolean" then return value, true end
|
||||||
|
if kind == "number" and value == value
|
||||||
|
and value < math.huge and value > -math.huge then
|
||||||
|
return value, true
|
||||||
|
end
|
||||||
|
return nil, false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function publicDataCopy(value, visiting)
|
||||||
|
local scalar, ok = publicScalar(value)
|
||||||
|
if ok then return scalar, true end
|
||||||
|
if type(value) ~= "table" then return nil, false end
|
||||||
|
|
||||||
|
visiting = visiting or {}
|
||||||
|
if visiting[value] then return nil, false end
|
||||||
|
visiting[value] = true
|
||||||
|
|
||||||
|
local copy = {}
|
||||||
|
for key, child in next, value do
|
||||||
|
local copiedKey, keyOk = publicScalar(key)
|
||||||
|
local copiedChild, childOk = publicDataCopy(child, visiting)
|
||||||
|
if keyOk and childOk then copy[copiedKey] = copiedChild end
|
||||||
|
end
|
||||||
|
visiting[value] = nil
|
||||||
|
return copy, true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function checkpointFieldView(field)
|
||||||
|
field = field or {}
|
||||||
|
local view = publicDataCopy({
|
||||||
|
weather = field.weather,
|
||||||
|
tokens = field.tokens or {},
|
||||||
|
})
|
||||||
|
return view
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Public field residuals are data-only requests. Mods can inspect a detached
|
||||||
|
-- checkpoint-shaped field view and detached battler views, but only the engine
|
||||||
|
-- mutates HP, animates the bar, or enters the faint pipeline.
|
||||||
|
function BattleState:applyFieldResiduals()
|
||||||
|
if not Runtime.wantsHook("battle.field_residual") then return end
|
||||||
|
local views = {
|
||||||
|
player = fieldBattlerView(self.player, "player"),
|
||||||
|
enemy = fieldBattlerView(self.enemy, "enemy"),
|
||||||
|
}
|
||||||
|
local rows = Runtime.call("battle.field_residual", function() return {} end, {
|
||||||
|
field = checkpointFieldView(self.field),
|
||||||
|
battlers = views,
|
||||||
|
turn = self.turnCount or 0,
|
||||||
|
})
|
||||||
|
if type(rows) ~= "table" then return end
|
||||||
|
|
||||||
|
local fainted = {}
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
local battler = type(row) == "table" and row.side == "player"
|
||||||
|
and self.player or type(row) == "table" and row.side == "enemy"
|
||||||
|
and self.enemy or nil
|
||||||
|
local amount = type(row) == "table" and row.amount or nil
|
||||||
|
if battler and battler.mon.hp > 0 and type(amount) == "number"
|
||||||
|
and amount > 0
|
||||||
|
and amount < math.huge and amount == math.floor(amount)
|
||||||
|
and (row.message == nil or type(row.message) == "string") then
|
||||||
|
amount = math.min(amount, battler.mon.hp)
|
||||||
|
if type(row.message) == "string" and row.message ~= "" then
|
||||||
|
self:sayNext(row.message)
|
||||||
|
end
|
||||||
|
battler.mon.hp = battler.mon.hp - amount
|
||||||
|
self:drainNext(battler, battler.mon.hp)
|
||||||
|
if battler.mon.hp <= 0 then fainted[battler] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- A terminal player faint owns a simultaneous field-residual batch. Queue
|
||||||
|
-- only that authority so its blackout cannot race an enemy EXP/replacement
|
||||||
|
-- path from the same hook response. Native faint paths remain untouched.
|
||||||
|
if fainted[self.player]
|
||||||
|
and not Party.firstHealthy(self:playerPartyView()) then
|
||||||
|
self:onFaint(self.player)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Otherwise resolve the two sides in engine order after every accepted
|
||||||
|
-- descriptor has landed. Descriptor order must not decide resolution.
|
||||||
|
for _, battler in ipairs({ self.player, self.enemy }) do
|
||||||
|
if fainted[battler] then self:onFaint(battler) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:endOfTurn()
|
function BattleState:endOfTurn()
|
||||||
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||||
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||||
@@ -2851,6 +2956,7 @@ function BattleState:endOfTurn()
|
|||||||
b.trappingTurns = nil
|
b.trappingTurns = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self:applyFieldResiduals()
|
||||||
self:tickTokens()
|
self:tickTokens()
|
||||||
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ function ChipAudio.shutdown()
|
|||||||
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||||
if worker then pcall(function() worker:wait() end) end
|
if worker then pcall(function() worker:wait() end) end
|
||||||
worker, cmdCh, outCh = nil, nil, nil
|
worker, cmdCh, outCh = nil, nil, nil
|
||||||
workerReady = false
|
workerReady = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChipAudio.currentSource()
|
function ChipAudio.currentSource()
|
||||||
@@ -498,6 +498,8 @@ end
|
|||||||
-- program (20 §2 cache contract, chip music row)
|
-- program (20 §2 cache contract, chip music row)
|
||||||
Assets.register(ChipAudio.invalidate)
|
Assets.register(ChipAudio.invalidate)
|
||||||
|
|
||||||
|
require("src.core.SessionLifecycle").registerProcessShutdown(ChipAudio.shutdown)
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|||||||
+33
-7
@@ -147,13 +147,16 @@ function Game:bootConfig()
|
|||||||
return boot
|
return boot
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
|
||||||
-- and by the START-menu QUIT confirmation
|
-- can reshape it), the overworld at the skeleton's spawn, and the intro
|
||||||
function Game:makeTitleState()
|
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
|
||||||
|
-- game on its own terms -- a match, a challenge mode -- calls it directly.
|
||||||
|
--
|
||||||
|
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
|
||||||
|
-- lands straight in the world; the skeleton then has to carry a name and a
|
||||||
|
-- party, which is the caller's job via save.new_game.
|
||||||
|
function Game:startNewGame(opts)
|
||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
|
||||||
local title = factory.new(self, {
|
|
||||||
onNewGame = function()
|
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
-- New Game keeps the standalone options.lua preferences
|
-- New Game keeps the standalone options.lua preferences
|
||||||
self.sessionStartedAt = os.time()
|
self.sessionStartedAt = os.time()
|
||||||
@@ -167,9 +170,19 @@ function Game:makeTitleState()
|
|||||||
self.save.player.x, self.save.player.y,
|
self.save.player.x, self.save.player.y,
|
||||||
self.save.player.facing,
|
self.save.player.facing,
|
||||||
{ via = "boot", freshBoot = true })
|
{ via = "boot", freshBoot = true })
|
||||||
|
if not (opts and opts.intro == false) then
|
||||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||||
function() end)
|
function() end)
|
||||||
end,
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||||
|
-- and by the START-menu QUIT confirmation
|
||||||
|
function Game:makeTitleState()
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||||
|
local title = factory.new(self, {
|
||||||
|
onNewGame = function() self:startNewGame() end,
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
@@ -1382,12 +1395,25 @@ function Game:reset()
|
|||||||
if self.stack and self.stack.clear then
|
if self.stack and self.stack.clear then
|
||||||
pcall(function() self.stack:clear() end)
|
pcall(function() self.stack:clear() end)
|
||||||
end
|
end
|
||||||
|
if self.world and self.world.release then
|
||||||
|
pcall(function() self.world:release() end)
|
||||||
|
end
|
||||||
|
if self._canvases then
|
||||||
|
for _, canvas in pairs(self._canvases) do
|
||||||
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
|
end
|
||||||
|
end
|
||||||
if self.renderer and self.renderer.releaseCanvases then
|
if self.renderer and self.renderer.releaseCanvases then
|
||||||
pcall(function() self.renderer:releaseCanvases() end)
|
pcall(function() self.renderer:releaseCanvases() end)
|
||||||
end
|
end
|
||||||
local keys = {}
|
local keys = {}
|
||||||
for key, value in pairs(self) do
|
for key, value in pairs(self) do
|
||||||
if type(value) ~= "function" then
|
if type(value) ~= "function" then
|
||||||
|
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||||
|
if type(value) == "table" and value.release then
|
||||||
|
pcall(value.release, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
keys[#keys + 1] = key
|
keys[#keys + 1] = key
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2208,9 +2208,25 @@ function Game2:reset()
|
|||||||
if self.stack and self.stack.clear then
|
if self.stack and self.stack.clear then
|
||||||
pcall(function() self.stack:clear() end)
|
pcall(function() self.stack:clear() end)
|
||||||
end
|
end
|
||||||
|
if self.world and self.world.release then
|
||||||
|
pcall(function() self.world:release() end)
|
||||||
|
end
|
||||||
|
if self._canvases then
|
||||||
|
for _, canvas in pairs(self._canvases) do
|
||||||
|
if canvas and canvas.release then pcall(canvas.release, canvas) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.renderer and self.renderer.releaseCanvases then
|
||||||
|
pcall(function() self.renderer:releaseCanvases() end)
|
||||||
|
end
|
||||||
local keys = {}
|
local keys = {}
|
||||||
for key, value in pairs(self) do
|
for key, value in pairs(self) do
|
||||||
if type(value) ~= "function" then
|
if type(value) ~= "function" then
|
||||||
|
if key ~= "world" and key ~= "renderer" and key ~= "_canvases" then
|
||||||
|
if type(value) == "table" and value.release then
|
||||||
|
pcall(value.release, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
keys[#keys + 1] = key
|
keys[#keys + 1] = key
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- Central session lifecycle orchestrator. Subsystems register teardown hooks
|
||||||
|
-- at module load (Assets release/invalidate bus, process shutdown below); the
|
||||||
|
-- host only calls phase entry points.
|
||||||
|
--
|
||||||
|
-- Three tiers:
|
||||||
|
-- mount endMountedSession — GPU release + CacheFs/Data/Runtime/Assets
|
||||||
|
-- game endGameSession — audio + game:reset; workers stay alive
|
||||||
|
-- process endProcess — worker shutdown on real app exit (love.quit)
|
||||||
|
--
|
||||||
|
-- Hot reload (Assets.flush / installLoader) stays invalidate-only forever.
|
||||||
|
|
||||||
|
local SessionLifecycle = {}
|
||||||
|
|
||||||
|
local processShutdowns = {}
|
||||||
|
|
||||||
|
function SessionLifecycle.registerProcessShutdown(fn)
|
||||||
|
processShutdowns[#processShutdowns + 1] = fn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop CacheFs / Data / mod Runtime / Assets / LegacyCompat for one mounted
|
||||||
|
-- version session (save editor or game). GPU release runs before soft
|
||||||
|
-- invalidate via installLoader(nil).
|
||||||
|
function SessionLifecycle.endMountedSession(version)
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
if Assets.releaseSession then Assets.releaseSession() end
|
||||||
|
if version then
|
||||||
|
require("src.import.CacheFs").unmountVersion(version)
|
||||||
|
end
|
||||||
|
require("src.core.Data"):unloadGenerated()
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
if Runtime.reset then Runtime.reset() end
|
||||||
|
if Assets.installLoader then Assets.installLoader(nil) end
|
||||||
|
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||||
|
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Evict every save-editor module from package.loaded without a hardcoded
|
||||||
|
-- panel whitelist. Flat require names (App, Party, …) resolve under
|
||||||
|
-- tools/save-editor/; path-style keys may also appear.
|
||||||
|
local function flushEditorPackageLoaded()
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
local function isEditorFlat(name)
|
||||||
|
if not (fs and fs.getInfo) then return false end
|
||||||
|
if name:find("[./]") then return false end
|
||||||
|
return fs.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
||||||
|
or fs.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
||||||
|
end
|
||||||
|
for k in pairs(package.loaded) do
|
||||||
|
if type(k) == "string"
|
||||||
|
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
||||||
|
package.loaded[k] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SessionLifecycle.endEditorSession(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
if opts.app and opts.app.unload then pcall(opts.app.unload) end
|
||||||
|
flushEditorPackageLoaded()
|
||||||
|
if opts.version then
|
||||||
|
SessionLifecycle.endMountedSession(opts.version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||||
|
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||||
|
function SessionLifecycle.endGameSession(game)
|
||||||
|
pcall(function() require("src.core.Music").stop() end)
|
||||||
|
pcall(function() require("src.core.Sound").stop() end)
|
||||||
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
|
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.DiscordPresence"] then
|
||||||
|
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.core.gen2.Clock"] then
|
||||||
|
pcall(package.loaded["src.core.gen2.Clock"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.net.Gen1Tls"] then
|
||||||
|
pcall(package.loaded["src.net.Gen1Tls"].shutdown)
|
||||||
|
end
|
||||||
|
if love.audio and love.audio.stop then
|
||||||
|
pcall(love.audio.stop)
|
||||||
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
|
if game and game.reset then
|
||||||
|
pcall(function() game:reset() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
Input:reset()
|
||||||
|
TouchControls:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SessionLifecycle.endProcess()
|
||||||
|
for _, fn in ipairs(processShutdowns) do pcall(fn) end
|
||||||
|
end
|
||||||
|
|
||||||
|
return SessionLifecycle
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
-- Engine-owned contract for generated ROM caches.
|
||||||
|
--
|
||||||
|
-- A cache is playable only when its versioned marker matches the ROM and every
|
||||||
|
-- required output for that version exists. Extraction writers may differ by
|
||||||
|
-- platform, but they must publish through this contract so partial staging
|
||||||
|
-- cannot look ready to the runtime.
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
|
local CacheContract = {}
|
||||||
|
|
||||||
|
CacheContract.FORMAT = "rom-cache-v10:"
|
||||||
|
CacheContract.MARKER_PATH = "rom-cache.complete"
|
||||||
|
|
||||||
|
CacheContract.REQUIRED_FILES = {
|
||||||
|
"data/generated/constants.lua",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/text.lua",
|
||||||
|
"data/generated/field.lua",
|
||||||
|
"data/generated/battle_anims.lua",
|
||||||
|
"assets/generated/title/pokemon_logo.png",
|
||||||
|
"assets/generated/fonts/font.png",
|
||||||
|
"assets/generated/battle/front/pikachu.png",
|
||||||
|
"assets/generated/battle/anims/move_anim_0.png",
|
||||||
|
"assets/generated/battle/anims/move_anim_1.png",
|
||||||
|
"assets/generated/audio/programs.bin",
|
||||||
|
"assets/generated/trade/game_boy.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheContract.VERSION_REQUIRED_FILES = {
|
||||||
|
yellow = {
|
||||||
|
"assets/generated/battle/trainers/jessie_james.png",
|
||||||
|
"assets/generated/battle/profoakb.png",
|
||||||
|
"assets/generated/pikachu/pikapic_1.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
|
||||||
|
gold = {
|
||||||
|
"data/generated/constants.lua",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/roofs.lua",
|
||||||
|
"data/generated/sprites.lua",
|
||||||
|
"data/generated/scripts.lua",
|
||||||
|
"data/generated/text.lua",
|
||||||
|
-- The engine's label-keyed strings are separate from Gen 2 script text.
|
||||||
|
-- Caches made before RomExtractorGen2:extractText must be rebuilt so
|
||||||
|
-- src/core/RomText.lua does not silently fall back to built-in wording.
|
||||||
|
"data/generated/rom_text.lua",
|
||||||
|
"data/generated/pokemon.lua",
|
||||||
|
"data/generated/tilesets.lua",
|
||||||
|
"data/generated/audio.lua",
|
||||||
|
"data/generated/marts.lua",
|
||||||
|
"assets/generated/fonts/font.png",
|
||||||
|
"assets/generated/fonts/frames.png",
|
||||||
|
"assets/generated/title/pokemon_logo.png",
|
||||||
|
"assets/generated/title/title_screen.png",
|
||||||
|
"assets/generated/title/hooh.png",
|
||||||
|
"assets/generated/title/hooh_5.png",
|
||||||
|
"assets/generated/title/clouds.png",
|
||||||
|
"assets/generated/title/copyright_splash.png",
|
||||||
|
"data/generated/oak_speech.lua",
|
||||||
|
"assets/generated/intro/oak.png",
|
||||||
|
"assets/generated/intro/cal.png",
|
||||||
|
"assets/generated/tilesets/johto.png",
|
||||||
|
"assets/generated/tilesets/roofs/new_bark.png",
|
||||||
|
"assets/generated/sprites/chris.png",
|
||||||
|
"assets/generated/battle/front/chikorita.png",
|
||||||
|
"assets/generated/battle/front/pikachu.png",
|
||||||
|
"assets/generated/battle/front/marill.png",
|
||||||
|
"assets/generated/battle/trainers/falkner.png",
|
||||||
|
"assets/generated/battle/hud/balls.png",
|
||||||
|
"assets/generated/audio/programs.bin",
|
||||||
|
"assets/generated/slots/gold_slots_1.png",
|
||||||
|
"assets/generated/card_flip/card_flip_1.png",
|
||||||
|
"assets/generated/pc/mail_item.png",
|
||||||
|
-- engine/events/fishing_gfx.asm:23
|
||||||
|
"assets/generated/emotes/fishing.png",
|
||||||
|
},
|
||||||
|
crystal = {
|
||||||
|
"data/generated/constants.lua",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/roofs.lua",
|
||||||
|
"data/generated/sprites.lua",
|
||||||
|
"data/generated/scripts.lua",
|
||||||
|
"data/generated/text.lua",
|
||||||
|
"data/generated/rom_text.lua",
|
||||||
|
"data/generated/pokemon.lua",
|
||||||
|
"data/generated/encounters.lua",
|
||||||
|
"data/generated/tilesets.lua",
|
||||||
|
"data/generated/landmarks.lua",
|
||||||
|
"data/generated/audio.lua",
|
||||||
|
"data/generated/marts.lua",
|
||||||
|
"data/generated/oak_speech.lua",
|
||||||
|
"data/generated/title.lua",
|
||||||
|
"data/generated/intro.lua",
|
||||||
|
"assets/generated/fonts/font.png",
|
||||||
|
"assets/generated/fonts/frames.png",
|
||||||
|
"assets/generated/title/crystal_logo.png",
|
||||||
|
"assets/generated/title/crystal_wordmark.png",
|
||||||
|
"assets/generated/title/crystal_suicune.png",
|
||||||
|
"assets/generated/splash/ditto.png",
|
||||||
|
"assets/generated/intro/chris.png",
|
||||||
|
"assets/generated/intro/kris.png",
|
||||||
|
"assets/generated/intro/suicune_run_sprites.png",
|
||||||
|
"assets/generated/intro/unowns_tiles.png",
|
||||||
|
"assets/generated/intro/oak.png",
|
||||||
|
"assets/generated/tilesets/johto.png",
|
||||||
|
"assets/generated/tilesets/roofs/new_bark.png",
|
||||||
|
"assets/generated/sprites/chris.png",
|
||||||
|
"assets/generated/sprites/kris.png",
|
||||||
|
"assets/generated/battle/front/chikorita.png",
|
||||||
|
"assets/generated/battle/front/wooper.png",
|
||||||
|
"assets/generated/battle/front/pikachu.png",
|
||||||
|
"assets/generated/battle/trainers/falkner.png",
|
||||||
|
"assets/generated/battle/hud/balls.png",
|
||||||
|
"assets/generated/audio/programs.bin",
|
||||||
|
"assets/generated/slots/gold_slots_1.png",
|
||||||
|
"assets/generated/card_flip/card_flip_1.png",
|
||||||
|
"assets/generated/pc/mail_item.png",
|
||||||
|
"assets/generated/trainer_card/card_f.png",
|
||||||
|
"data/generated/mobile_gfx.lua",
|
||||||
|
"assets/generated/battle/player_back_female.png",
|
||||||
|
"assets/generated/battle/trainers/kris.png",
|
||||||
|
"assets/generated/battle/trainers/chris.png",
|
||||||
|
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
|
||||||
|
"assets/generated/emotes/fishing.png",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
|
||||||
|
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||||
|
|
||||||
|
function CacheContract.requiredFilesFor(version)
|
||||||
|
local override = CacheContract.VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||||
|
if override then return override, true end
|
||||||
|
return CacheContract.REQUIRED_FILES, false
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.markerFor(version)
|
||||||
|
return CacheContract.FORMAT .. GameVersion.info(version).sha1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep the process-global CacheFs prefix isolated even when a filesystem
|
||||||
|
-- adapter raises while probing or publishing. The real CacheFs methods
|
||||||
|
-- return errors, but this also makes the contract safe for platform adapters
|
||||||
|
-- that surface I/O failures as Lua errors.
|
||||||
|
local function withVersionPrefix(version, fs, action)
|
||||||
|
local saved = fs.prefix
|
||||||
|
fs.prefix = GameVersion.cachePrefix(version)
|
||||||
|
local ok, first, second = pcall(action)
|
||||||
|
fs.prefix = saved
|
||||||
|
if not ok then return false, first end
|
||||||
|
return true, first, second
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.allRequiredFilesExist(version, fs)
|
||||||
|
fs = fs or require("src.import.CacheFs")
|
||||||
|
local ok, complete, missing = withVersionPrefix(version, fs, function()
|
||||||
|
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||||
|
local missingPath
|
||||||
|
for _, path in ipairs(required) do
|
||||||
|
if not fs.exists(path) then missingPath = path; break end
|
||||||
|
end
|
||||||
|
if not missingPath and not isOverride then
|
||||||
|
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
if not fs.exists(path) then missingPath = path; break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return missingPath == nil, missingPath
|
||||||
|
end)
|
||||||
|
if not ok then return false, complete end
|
||||||
|
return complete, missing
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.readMarker(version, fs)
|
||||||
|
fs = fs or require("src.import.CacheFs")
|
||||||
|
local ok, marker, readError = withVersionPrefix(version, fs, function()
|
||||||
|
return fs.read(CacheContract.MARKER_PATH)
|
||||||
|
end)
|
||||||
|
if not ok then return nil, marker end
|
||||||
|
-- LÖVE may return contents plus a byte count; only a nil contents result
|
||||||
|
-- makes the auxiliary value an error. CacheFs' portable reader returns
|
||||||
|
-- just the contents, so this remains adapter-neutral.
|
||||||
|
if marker == nil then return nil, readError end
|
||||||
|
return marker
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.isReady(version, fs)
|
||||||
|
fs = fs or require("src.import.CacheFs")
|
||||||
|
if CacheContract.sourceTreeHasData(version) then return true end
|
||||||
|
local marker, readError = CacheContract.readMarker(version, fs)
|
||||||
|
if readError or marker ~= CacheContract.markerFor(version) then return false end
|
||||||
|
return CacheContract.allRequiredFilesExist(version, fs)
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.publish(version, fs)
|
||||||
|
fs = fs or require("src.import.CacheFs")
|
||||||
|
local complete, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||||
|
if not complete then
|
||||||
|
-- A caller may be retrying over a partially replaced cache. Do not
|
||||||
|
-- leave its old marker advertising readiness after this failed check.
|
||||||
|
local removed, removeError = withVersionPrefix(version, fs, function()
|
||||||
|
if not fs.remove then
|
||||||
|
error("cache filesystem cannot remove the completion marker")
|
||||||
|
end
|
||||||
|
return fs.remove(CacheContract.MARKER_PATH)
|
||||||
|
end)
|
||||||
|
if not removed then
|
||||||
|
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||||
|
.. "; could not remove completion marker: " .. tostring(removeError)
|
||||||
|
end
|
||||||
|
return false, "cache is incomplete; missing " .. tostring(missing)
|
||||||
|
end
|
||||||
|
local changed, ok, err = withVersionPrefix(version, fs, function()
|
||||||
|
return fs.write(CacheContract.MARKER_PATH, CacheContract.markerFor(version))
|
||||||
|
end)
|
||||||
|
if not changed then return false, tostring(ok) end
|
||||||
|
return ok, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheContract.sourceTreeHasData(version)
|
||||||
|
if not (love and love.filesystem and love.filesystem.getInfo
|
||||||
|
and love.filesystem.getRealDirectory and love.filesystem.getSource) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||||
|
local required, isOverride = CacheContract.requiredFilesFor(version)
|
||||||
|
local source = love.filesystem.getSource()
|
||||||
|
for _, path in ipairs(required) do
|
||||||
|
local fullPath = prefix .. path
|
||||||
|
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||||
|
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not isOverride then
|
||||||
|
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
local fullPath = prefix .. path
|
||||||
|
if love.filesystem.getInfo(fullPath, "file") == nil
|
||||||
|
or love.filesystem.getRealDirectory(fullPath) ~= source then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return CacheContract
|
||||||
+14
-247
@@ -35,189 +35,12 @@ local function cartOfScope(scope)
|
|||||||
return scope:match("^cart_(.+)$")
|
return scope:match("^cart_(.+)$")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
local CacheContract = require("src.import.CacheContract")
|
||||||
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
|
||||||
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
|
||||||
-- v10: maps carry their raw map-header/connection/object bytes and tilesets
|
|
||||||
-- their Tilesets row (#889), which a .sav export replays so a Continue on
|
|
||||||
-- real hardware has a map to load; a v9 cache has none of them and exports
|
|
||||||
-- the same unbootable save as before.
|
|
||||||
-- Deliberately NOT bumped for the Gold trainer-pic gap: this tag invalidates
|
|
||||||
-- every version at once, and that gap is Gold-only. A per-version marker in
|
|
||||||
-- VERSION_REQUIRED_FILES_OVERRIDE.gold re-imports exactly the caches that lack
|
|
||||||
-- the stage, which is what the Yellow markers below already do for #439/#557.
|
|
||||||
-- Reach for a bump when the change spans versions or has no single file to
|
|
||||||
-- point at.
|
|
||||||
local CACHE_FORMAT = "rom-cache-v10:"
|
|
||||||
-- The completion marker is written under each version's cache prefix
|
|
||||||
-- (red/rom-cache.complete, blue/rom-cache.complete, ...).
|
|
||||||
local MARKER_PATH = "rom-cache.complete"
|
|
||||||
|
|
||||||
-- The marker a finished import writes for a version: the generation tag plus
|
|
||||||
-- that version's ROM hash, so both a format bump and a swapped ROM invalidate.
|
|
||||||
local function markerFor(version)
|
|
||||||
return CACHE_FORMAT .. GameVersion.info(version).sha1
|
|
||||||
end
|
|
||||||
local COMMUNITY_URL = "https://bois.icu"
|
local COMMUNITY_URL = "https://bois.icu"
|
||||||
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
|
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
|
||||||
"or a link from the discord that bryanthaboi himself posted, just know " ..
|
"or a link from the discord that bryanthaboi himself posted, just know " ..
|
||||||
"it might have been tampered with. go to the discord to verify " ..
|
"it might have been tampered with. go to the discord to verify " ..
|
||||||
COMMUNITY_URL .. " (or click the logo above)"
|
COMMUNITY_URL .. " (or click the logo above)"
|
||||||
local REQUIRED_FILES = {
|
|
||||||
"data/generated/constants.lua",
|
|
||||||
"data/generated/maps.lua",
|
|
||||||
"data/generated/text.lua",
|
|
||||||
"data/generated/field.lua",
|
|
||||||
"data/generated/battle_anims.lua",
|
|
||||||
"assets/generated/title/pokemon_logo.png",
|
|
||||||
"assets/generated/fonts/font.png",
|
|
||||||
"assets/generated/battle/front/pikachu.png",
|
|
||||||
"assets/generated/battle/anims/move_anim_0.png",
|
|
||||||
"assets/generated/battle/anims/move_anim_1.png",
|
|
||||||
"assets/generated/audio/programs.bin",
|
|
||||||
-- The trade cinematic's Game Boy / cable art. Caches built before #750
|
|
||||||
-- carry none of it and fall back to plain rectangles, so listing one of
|
|
||||||
-- the files re-imports them without a CACHE_FORMAT bump.
|
|
||||||
"assets/generated/trade/game_boy.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Files only one version's cache carries. A version that predates one of
|
|
||||||
-- them re-imports on its own, without dragging the other versions through a
|
|
||||||
-- CACHE_FORMAT bump.
|
|
||||||
local VERSION_REQUIRED_FILES = {
|
|
||||||
yellow = {
|
|
||||||
"assets/generated/battle/trainers/jessie_james.png", -- #439
|
|
||||||
-- Oak's own back pic and the pikapic base frames only exist in caches
|
|
||||||
-- built after their manifest symbols landed, so an older Yellow cache
|
|
||||||
-- has to re-import to stop falling back to the old man's back pic and
|
|
||||||
-- to the battle front pic (#557, #561). Both are gated on manifest
|
|
||||||
-- symbols in RomExtractor, so these markers must only ever list files
|
|
||||||
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
|
|
||||||
-- cache reads as incomplete and re-importing cannot clear it.
|
|
||||||
"assets/generated/battle/profoakb.png",
|
|
||||||
"assets/generated/pikachu/pikapic_1.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
-- Gold Phase 1 writes a thinner cache than Gen 1 (no battle anim sheets,
|
|
||||||
-- trade art, or field.lua payload yet -- see docs/gold-phase1.md). This
|
|
||||||
-- list replaces REQUIRED_FILES entirely for that version so a successful
|
|
||||||
-- Gen 2 extract is not stuck as "incomplete" waiting on Gen 1 markers.
|
|
||||||
local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|
||||||
gold = {
|
|
||||||
"data/generated/constants.lua",
|
|
||||||
"data/generated/maps.lua",
|
|
||||||
"data/generated/roofs.lua", -- Phase 2: forces re-import of Phase 1 caches
|
|
||||||
"data/generated/sprites.lua", -- OW sheets (Chris + NPCs)
|
|
||||||
"data/generated/scripts.lua", -- disassembled map scripts
|
|
||||||
"data/generated/text.lua", -- decoded Gen 2 dialogue strings
|
|
||||||
-- The engine's own strings, keyed by label rather than by address. A
|
|
||||||
-- cache built before RomExtractorGen2:extractText has none, and every
|
|
||||||
-- line that reads through src/core/RomText.lua would silently keep
|
|
||||||
-- printing its Lua fallback, so this re-imports those caches rather than
|
|
||||||
-- bumping CACHE_FORMAT and dragging Red, Blue and Yellow through it too.
|
|
||||||
"data/generated/rom_text.lua",
|
|
||||||
"data/generated/pokemon.lua",
|
|
||||||
"data/generated/tilesets.lua",
|
|
||||||
"data/generated/audio.lua",
|
|
||||||
-- Mart shelves + the heal machine art ride the same import, so listing
|
|
||||||
-- marts.lua alone re-imports the caches from before either existed
|
|
||||||
-- (empty shop shelves, no Pokecenter light show).
|
|
||||||
"data/generated/marts.lua",
|
|
||||||
"assets/generated/fonts/font.png",
|
|
||||||
"assets/generated/fonts/frames.png", -- the seven other OPTION textbox frames
|
|
||||||
"assets/generated/title/pokemon_logo.png",
|
|
||||||
"assets/generated/title/title_screen.png", -- TitleScreenTilemap composition
|
|
||||||
"assets/generated/title/hooh.png",
|
|
||||||
"assets/generated/title/hooh_5.png", -- wing-flap frames force re-import
|
|
||||||
"assets/generated/title/clouds.png",
|
|
||||||
"assets/generated/title/copyright_splash.png",
|
|
||||||
"data/generated/oak_speech.lua", -- Oak texts + trainer pics
|
|
||||||
"assets/generated/intro/oak.png",
|
|
||||||
"assets/generated/intro/cal.png",
|
|
||||||
"assets/generated/tilesets/johto.png",
|
|
||||||
"assets/generated/tilesets/roofs/new_bark.png",
|
|
||||||
"assets/generated/sprites/chris.png",
|
|
||||||
"assets/generated/battle/front/chikorita.png",
|
|
||||||
"assets/generated/battle/front/pikachu.png",
|
|
||||||
"assets/generated/battle/front/marill.png", -- Oak speech demo mon
|
|
||||||
-- The trainer class pics (TrainerPicPointers). FALKNER is row 0 of that
|
|
||||||
-- table, so a cache that produced any class pic at all produced this one.
|
|
||||||
-- Listed for the reason the Yellow markers above are: a cache built before
|
|
||||||
-- the stage existed reads as INCOMPLETE and re-imports itself, so this
|
|
||||||
-- particular gap cannot survive a tag bump being forgotten again. It
|
|
||||||
-- costs nothing on a current cache and is the difference between every
|
|
||||||
-- trainer battle opening with a picture and opening with none.
|
|
||||||
"assets/generated/battle/trainers/falkner.png",
|
|
||||||
-- BattleStart_TrainerHuds cannot draw its party rows from a cache made
|
|
||||||
-- before the four ball tiles were extracted (#1502).
|
|
||||||
"assets/generated/battle/hud/balls.png",
|
|
||||||
"assets/generated/audio/programs.bin",
|
|
||||||
-- Goldenrod Game Corner reel + board art (#1581). menu_gfx.lua used to
|
|
||||||
-- advertise these paths even when Slots*LZ / CardFlip* were absent from
|
|
||||||
-- the manifest, so a cache that never wrote the PNGs still looked
|
|
||||||
-- complete and SlotMachine crashed on its labelled-cell fallback.
|
|
||||||
"assets/generated/slots/gold_slots_1.png",
|
|
||||||
"assets/generated/card_flip/card_flip_1.png",
|
|
||||||
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173)
|
|
||||||
"assets/generated/pc/mail_item.png",
|
|
||||||
-- FishingGFX (engine/events/fishing_gfx.asm:23): the pose rows and the rod
|
|
||||||
-- tiles, which a cache built before #1708 has none of.
|
|
||||||
"assets/generated/emotes/fishing.png",
|
|
||||||
},
|
|
||||||
crystal = {
|
|
||||||
"data/generated/constants.lua",
|
|
||||||
"data/generated/maps.lua",
|
|
||||||
"data/generated/roofs.lua",
|
|
||||||
"data/generated/sprites.lua",
|
|
||||||
"data/generated/scripts.lua",
|
|
||||||
"data/generated/text.lua",
|
|
||||||
"data/generated/rom_text.lua",
|
|
||||||
"data/generated/pokemon.lua",
|
|
||||||
"data/generated/encounters.lua",
|
|
||||||
"data/generated/tilesets.lua",
|
|
||||||
"data/generated/landmarks.lua",
|
|
||||||
"data/generated/audio.lua",
|
|
||||||
"data/generated/marts.lua",
|
|
||||||
"data/generated/oak_speech.lua",
|
|
||||||
"data/generated/title.lua",
|
|
||||||
"data/generated/intro.lua",
|
|
||||||
"assets/generated/fonts/font.png",
|
|
||||||
"assets/generated/fonts/frames.png",
|
|
||||||
"assets/generated/title/crystal_logo.png",
|
|
||||||
"assets/generated/title/crystal_wordmark.png",
|
|
||||||
"assets/generated/title/crystal_suicune.png",
|
|
||||||
"assets/generated/splash/ditto.png",
|
|
||||||
"assets/generated/intro/chris.png",
|
|
||||||
"assets/generated/intro/kris.png",
|
|
||||||
"assets/generated/intro/suicune_run_sprites.png",
|
|
||||||
"assets/generated/intro/unowns_tiles.png",
|
|
||||||
"assets/generated/intro/oak.png",
|
|
||||||
"assets/generated/tilesets/johto.png",
|
|
||||||
"assets/generated/tilesets/roofs/new_bark.png",
|
|
||||||
"assets/generated/sprites/chris.png",
|
|
||||||
"assets/generated/sprites/kris.png",
|
|
||||||
"assets/generated/battle/front/chikorita.png",
|
|
||||||
"assets/generated/battle/front/wooper.png",
|
|
||||||
"assets/generated/battle/front/pikachu.png",
|
|
||||||
"assets/generated/battle/trainers/falkner.png",
|
|
||||||
"assets/generated/battle/hud/balls.png",
|
|
||||||
"assets/generated/audio/programs.bin",
|
|
||||||
"assets/generated/slots/gold_slots_1.png",
|
|
||||||
"assets/generated/card_flip/card_flip_1.png",
|
|
||||||
"assets/generated/pc/mail_item.png",
|
|
||||||
"assets/generated/trainer_card/card_f.png",
|
|
||||||
"data/generated/mobile_gfx.lua",
|
|
||||||
"assets/generated/battle/player_back_female.png",
|
|
||||||
"assets/generated/battle/trainers/kris.png",
|
|
||||||
"assets/generated/battle/trainers/chris.png",
|
|
||||||
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
|
|
||||||
"assets/generated/emotes/fishing.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
|
||||||
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
|
|
||||||
|
|
||||||
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
||||||
-- a dark neon arcade panel, one column per game.
|
-- a dark neon arcade panel, one column per game.
|
||||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||||
@@ -270,58 +93,6 @@ local PAL = {
|
|||||||
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
|
|
||||||
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
|
|
||||||
local function requiredFilesFor(version)
|
|
||||||
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
|
|
||||||
if override then return override, true end
|
|
||||||
return REQUIRED_FILES, false
|
|
||||||
end
|
|
||||||
|
|
||||||
-- CacheFs.exists checks the game folder directly for a portable install,
|
|
||||||
-- otherwise the save directory through love.filesystem. It honors
|
|
||||||
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
|
||||||
-- blue/, yellow/, gold/).
|
|
||||||
local function allRequiredFilesExist(version)
|
|
||||||
local CacheFs = require("src.import.CacheFs")
|
|
||||||
local saved = CacheFs.prefix
|
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
|
||||||
local ok = true
|
|
||||||
local required, isOverride = requiredFilesFor(version)
|
|
||||||
for _, path in ipairs(required) do
|
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
|
||||||
end
|
|
||||||
if ok and not isOverride then
|
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
CacheFs.prefix = saved
|
|
||||||
return ok
|
|
||||||
end
|
|
||||||
|
|
||||||
-- A developer checkout / Python build leaves generated data in the physfs
|
|
||||||
-- source: Red at the historical root, Blue/Yellow/Gold 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)
|
|
||||||
local required, isOverride = requiredFilesFor(version)
|
|
||||||
for _, path in ipairs(required) do
|
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
|
||||||
end
|
|
||||||
if not isOverride then
|
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local path = prefix .. required[1]
|
|
||||||
local real = love.filesystem.getRealDirectory(path)
|
|
||||||
return real == love.filesystem.getSource()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ------- ROM cache location
|
-- ------- ROM cache location
|
||||||
--
|
--
|
||||||
-- The extracted cache (data/generated, assets/generated) plus the
|
-- The extracted cache (data/generated, assets/generated) plus the
|
||||||
@@ -377,10 +148,15 @@ local function purgeSaveDirCache()
|
|||||||
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
|
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
local prefix = GameVersion.cachePrefix(version)
|
local prefix = GameVersion.cachePrefix(version)
|
||||||
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
local required = CacheContract.requiredFilesFor(version)
|
||||||
|
local hasRequired = false
|
||||||
|
for _, path in ipairs(required) do
|
||||||
|
if saveDirHas(prefix .. path) then hasRequired = true; break end
|
||||||
|
end
|
||||||
|
if saveDirHas(prefix .. CacheContract.MARKER_PATH) or hasRequired then
|
||||||
removeTree(prefix .. "data/generated")
|
removeTree(prefix .. "data/generated")
|
||||||
removeTree(prefix .. "assets/generated")
|
removeTree(prefix .. "assets/generated")
|
||||||
love.filesystem.remove(prefix .. MARKER_PATH)
|
love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -395,13 +171,7 @@ function RomImporter.isReady(version)
|
|||||||
-- save-directory copy that would otherwise shadow it at runtime.
|
-- save-directory copy that would otherwise shadow it at runtime.
|
||||||
purgeSaveDirCache()
|
purgeSaveDirCache()
|
||||||
end
|
end
|
||||||
-- Generated data in a developer checkout / Python build is always current.
|
return CacheContract.isReady(version, CacheFs)
|
||||||
if sourceTreeHasData(version) then return true end
|
|
||||||
local saved = CacheFs.prefix
|
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
|
||||||
local marker = CacheFs.read(MARKER_PATH)
|
|
||||||
CacheFs.prefix = saved
|
|
||||||
return marker == markerFor(version) and allRequiredFilesExist(version)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter.syncAndroidShortcuts(activeVersion)
|
function RomImporter.syncAndroidShortcuts(activeVersion)
|
||||||
@@ -1660,12 +1430,9 @@ function RomImporter.new(onComplete, opts)
|
|||||||
self.ready[version] = ready
|
self.ready[version] = ready
|
||||||
-- a marker present but for an older cache generation / different ROM means
|
-- a marker present but for an older cache generation / different ROM means
|
||||||
-- "update required" (re-import) rather than a clean first-run choose
|
-- "update required" (re-import) rather than a clean first-run choose
|
||||||
local saved = CacheFs.prefix
|
local marker = CacheContract.readMarker(version, CacheFs)
|
||||||
CacheFs.prefix = info.cachePrefix
|
|
||||||
local marker = CacheFs.read(MARKER_PATH)
|
|
||||||
CacheFs.prefix = saved
|
|
||||||
self.returning[version] =
|
self.returning[version] =
|
||||||
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
(not ready) and marker ~= nil and marker ~= CacheContract.markerFor(version)
|
||||||
self.romName[version] = "pokemon_" .. info.id
|
self.romName[version] = "pokemon_" .. info.id
|
||||||
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
||||||
and ".gbc" or ".gb")
|
and ".gbc" or ".gb")
|
||||||
@@ -1980,10 +1747,10 @@ function RomImporter:startData(data, displayName)
|
|||||||
local cleared, clearError = pcall(function()
|
local cleared, clearError = pcall(function()
|
||||||
removeTree(prefix .. "data/generated")
|
removeTree(prefix .. "data/generated")
|
||||||
removeTree(prefix .. "assets/generated")
|
removeTree(prefix .. "assets/generated")
|
||||||
love.filesystem.remove(prefix .. MARKER_PATH)
|
love.filesystem.remove(prefix .. CacheContract.MARKER_PATH)
|
||||||
CacheFs.removeTree("data/generated")
|
CacheFs.removeTree("data/generated")
|
||||||
CacheFs.removeTree("assets/generated")
|
CacheFs.removeTree("assets/generated")
|
||||||
CacheFs.remove(MARKER_PATH)
|
CacheFs.remove(CacheContract.MARKER_PATH)
|
||||||
end)
|
end)
|
||||||
CacheFs.prefix = savedPrefix
|
CacheFs.prefix = savedPrefix
|
||||||
if not cleared then
|
if not cleared then
|
||||||
@@ -2055,7 +1822,7 @@ function RomImporter:_completeImport(version, prefix, displayName)
|
|||||||
-- appear once every required file is in place.
|
-- appear once every required file is in place.
|
||||||
local savedPrefix = CacheFs.prefix
|
local savedPrefix = CacheFs.prefix
|
||||||
CacheFs.prefix = prefix
|
CacheFs.prefix = prefix
|
||||||
local ok, writeError = CacheFs.write(MARKER_PATH, markerFor(version))
|
local ok, writeError = CacheContract.publish(version, CacheFs)
|
||||||
CacheFs.prefix = savedPrefix
|
CacheFs.prefix = savedPrefix
|
||||||
if not ok then
|
if not ok then
|
||||||
error("could not finish the private cache: " .. tostring(writeError))
|
error("could not finish the private cache: " .. tostring(writeError))
|
||||||
|
|||||||
+54
-15
@@ -1,28 +1,63 @@
|
|||||||
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
|
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
|
||||||
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
|
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
|
||||||
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
|
-- style alphabet pokeserver room/tournament codes are drawn from
|
||||||
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
|
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
|
||||||
-- aloud or handwritten never has to be checked twice).
|
-- handwritten never has to be checked twice).
|
||||||
|
--
|
||||||
|
-- The Gen 1 naming grid cannot stand in for this: it has no digits at all
|
||||||
|
-- (data/text/alphabets.asm is letters and punctuation), so a room code or
|
||||||
|
-- an address typed there would be unenterable. That is what this exists
|
||||||
|
-- for.
|
||||||
|
--
|
||||||
|
-- new() takes an optional {length=, charset=} so the same interaction can
|
||||||
|
-- carry something other than a room code -- a dotted IP over "0123456789.",
|
||||||
|
-- say. Both default to the room-code shape, so existing callers are
|
||||||
|
-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them.
|
||||||
|
|
||||||
local CodeEntry = {}
|
local CodeEntry = {}
|
||||||
|
|
||||||
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||||
CodeEntry.LENGTH = 6
|
CodeEntry.LENGTH = 6
|
||||||
|
|
||||||
function CodeEntry.new()
|
-- state carries its own length/charset so a caller holding two widgets of
|
||||||
|
-- different shapes cannot have one read the other's alphabet
|
||||||
|
function CodeEntry.new(opts)
|
||||||
|
local charset = (opts and opts.charset) or CodeEntry.CHARSET
|
||||||
|
local length = (opts and opts.length) or CodeEntry.LENGTH
|
||||||
local chars = {}
|
local chars = {}
|
||||||
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
|
for i = 1, length do chars[i] = 1 end -- index into charset, 1-based
|
||||||
return { chars = chars, pos = 1 }
|
return { chars = chars, pos = 1, charset = charset, length = length }
|
||||||
end
|
end
|
||||||
|
|
||||||
local N = #CodeEntry.CHARSET
|
-- Seed the slots from an existing string: prefilling the LAN address means
|
||||||
|
-- the player scrubs the last octet instead of all twelve digits. Anything
|
||||||
|
-- not in the charset lands on slot 1's character.
|
||||||
|
-- Slots past the end of the seed -- and any character the charset does not
|
||||||
|
-- carry -- land on the charset's blank where it has one, so seeding a
|
||||||
|
-- 15-slot address widget with "192.168.1.40" reads back as that address and
|
||||||
|
-- not as "192.168.1.40000". A charset with no blank (the room code's) has
|
||||||
|
-- nowhere to put one, so those fall back to the first character as before.
|
||||||
|
function CodeEntry.fromText(text, opts)
|
||||||
|
local state = CodeEntry.new(opts)
|
||||||
|
local blank = state.charset:find(" ", 1, true) or 1
|
||||||
|
for i = 1, state.length do
|
||||||
|
local ch = tostring(text or ""):sub(i, i)
|
||||||
|
state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank
|
||||||
|
end
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
local function charsetOf(state) return state.charset or CodeEntry.CHARSET end
|
||||||
|
local function lengthOf(state) return state.length or CodeEntry.LENGTH end
|
||||||
|
|
||||||
function CodeEntry.up(state)
|
function CodeEntry.up(state)
|
||||||
state.chars[state.pos] = state.chars[state.pos] % N + 1
|
local n = #charsetOf(state)
|
||||||
|
state.chars[state.pos] = state.chars[state.pos] % n + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
function CodeEntry.down(state)
|
function CodeEntry.down(state)
|
||||||
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
|
local n = #charsetOf(state)
|
||||||
|
state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
function CodeEntry.left(state)
|
function CodeEntry.left(state)
|
||||||
@@ -30,14 +65,18 @@ function CodeEntry.left(state)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function CodeEntry.right(state)
|
function CodeEntry.right(state)
|
||||||
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
|
state.pos = math.min(lengthOf(state), state.pos + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the character in slot i, which is what a draw loop wants
|
||||||
|
function CodeEntry.charAt(state, i)
|
||||||
|
local charset = charsetOf(state)
|
||||||
|
return charset:sub(state.chars[i], state.chars[i])
|
||||||
end
|
end
|
||||||
|
|
||||||
function CodeEntry.text(state)
|
function CodeEntry.text(state)
|
||||||
local out = {}
|
local out = {}
|
||||||
for i = 1, CodeEntry.LENGTH do
|
for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end
|
||||||
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
|
|
||||||
end
|
|
||||||
return table.concat(out)
|
return table.concat(out)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code)
|
|||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
|
||||||
|
-- overworld multiplayer session handing one pair of players off to a battle
|
||||||
|
-- or a trade. The caller has settled which mode and which side hosts, so
|
||||||
|
-- all that is left is the hello exchange every link session runs before it
|
||||||
|
-- commits -- the fingerprint/mod compatibility check still gets its say,
|
||||||
|
-- exactly as it would have on the LAN or ONLINE path.
|
||||||
|
--
|
||||||
|
-- `transport` is anything Session accepts (update/poll/send/close plus the
|
||||||
|
-- .paired/.closed/.error fields), which is what lets a mod route a battle
|
||||||
|
-- over a channel of its own. Ownership transfers with it: exitWith closes
|
||||||
|
-- the session, so the caller's transport is done once this state unwinds.
|
||||||
|
--
|
||||||
|
-- opts.forceLevel the level rule, normally chosen on the battleOptions
|
||||||
|
-- screen; an adopted session has no menu to pick it on
|
||||||
|
function LinkState.newFromSession(game, transport, mode, isHost, opts)
|
||||||
|
local self = LinkState.new(game)
|
||||||
|
self.net = Session.new(transport, { role = isHost and "host" or "guest",
|
||||||
|
kind = "link" })
|
||||||
|
self.adopted = true
|
||||||
|
self.adoptedMode, self.adoptedHost = mode, isHost and true or false
|
||||||
|
self.forceLevel = opts and opts.forceLevel or nil
|
||||||
|
self.stage = "adopted"
|
||||||
|
self:sendHello(isHost and mode or nil)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
function LinkState:exitWith(message, reason)
|
function LinkState:exitWith(message, reason)
|
||||||
DiscordPresence.setJoinCode(nil)
|
DiscordPresence.setJoinCode(nil)
|
||||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||||
@@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost)
|
|||||||
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
||||||
})
|
})
|
||||||
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
||||||
if isHost and mode == "battle" then
|
if isHost and mode == "battle" and not self.adopted then
|
||||||
-- host picks the level rule now, before the parties are exchanged
|
-- host picks the level rule now, before the parties are exchanged
|
||||||
|
-- (an adopted session had it passed in; see newFromSession)
|
||||||
self.stage = "battleOptions"
|
self.stage = "battleOptions"
|
||||||
else
|
else
|
||||||
self:startMode(mode, isHost)
|
self:startMode(mode, isHost)
|
||||||
@@ -243,6 +270,16 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- handed over from an already-paired session (LinkState.newFromSession):
|
||||||
|
-- both hellos are in flight, and the first one to land settles compat and
|
||||||
|
-- drops us straight into the agreed mode
|
||||||
|
if self.stage == "adopted" then
|
||||||
|
if self:pollHello() then
|
||||||
|
self:decideCompat(self.adoptedMode, self.adoptedHost)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if self.stage == "menu" then
|
if self.stage == "menu" then
|
||||||
if input:wasPressed("down") then
|
if input:wasPressed("down") then
|
||||||
self.index = self.index % 3 + 1
|
self.index = self.index % 3 + 1
|
||||||
@@ -497,11 +534,26 @@ function LinkState:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
|
self.battle = battle
|
||||||
self.stage = "battleRunning"
|
self.stage = "battleRunning"
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "battleRunning" then
|
elseif self.stage == "battleRunning" then
|
||||||
if self.game.stack:top() == self then
|
if self.game.stack:top() == self then
|
||||||
|
-- the lockstep copies carry the damage the real party never takes
|
||||||
|
-- (cable rules), so a mode that wants it -- a tournament ladder, a
|
||||||
|
-- battle royale -- reads it from here before the state unwinds
|
||||||
|
local battle = self.battle
|
||||||
|
if battle and Runtime.wants("link.battle_ended") then
|
||||||
|
Runtime.emit("link.battle_ended", {
|
||||||
|
result = battle.result or "ended",
|
||||||
|
myParty = battle.playerParty,
|
||||||
|
theirParty = battle.enemyParty,
|
||||||
|
peerName = self.peerName,
|
||||||
|
role = self.isHost and "host" or "guest",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
self.battle = nil
|
||||||
self:exitWith(nil) -- battle finished
|
self:exitWith(nil) -- battle finished
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+5
-1
@@ -273,7 +273,7 @@ function Loader.new(opts)
|
|||||||
modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {},
|
modInput = {}, modEnv = {}, stepsQueues = {}, cartSwitches = {},
|
||||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||||
cart = opts and opts.cart or nil,
|
cart = opts and opts.cart or nil,
|
||||||
dev = dev,
|
dev = dev == true,
|
||||||
safeMode = false,
|
safeMode = false,
|
||||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||||
-- active version is set once in main.lua's bootGame before anything
|
-- active version is set once in main.lua's bootGame before anything
|
||||||
@@ -1148,6 +1148,10 @@ function Loader:_api(mod)
|
|||||||
id = modId,
|
id = modId,
|
||||||
version = mod.manifest.version,
|
version = mod.manifest.version,
|
||||||
path = mod.path,
|
path = mod.path,
|
||||||
|
-- Fixed at Loader construction and copied as plain data: a sandboxed
|
||||||
|
-- entry chunk can decide whether to register developer-only diagnostics
|
||||||
|
-- without receiving the process environment or the loader itself.
|
||||||
|
developer = loader.dev == true,
|
||||||
-- a deep copy: what a mod does to its own view never reaches the loader
|
-- a deep copy: what a mod does to its own view never reaches the loader
|
||||||
manifest = Merge.deepCopy(mod.manifest),
|
manifest = Merge.deepCopy(mod.manifest),
|
||||||
content = {},
|
content = {},
|
||||||
|
|||||||
+3
-1
@@ -234,7 +234,9 @@ function Fetch.shutdown()
|
|||||||
end
|
end
|
||||||
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
|
for _, th in ipairs(workers) do pcall(function() th:wait() end) end
|
||||||
workers = {}
|
workers = {}
|
||||||
cmdCh, resCh, quitCh, ready = nil, nil, nil, false
|
cmdCh, resCh, quitCh, ready = nil, nil, nil, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
require("src.core.SessionLifecycle").registerProcessShutdown(Fetch.shutdown)
|
||||||
|
|
||||||
return Fetch
|
return Fetch
|
||||||
|
|||||||
+25
-2
@@ -14,6 +14,8 @@ local Assets = {}
|
|||||||
local cache = {}
|
local cache = {}
|
||||||
-- downstream caches that must empty when the search path changes
|
-- downstream caches that must empty when the search path changes
|
||||||
local invalidators = {}
|
local invalidators = {}
|
||||||
|
-- optional GPU release hooks for session end (never run on hot reload flush)
|
||||||
|
local releasers = {}
|
||||||
|
|
||||||
-- The loader bridge: overrideOrder() yields mods highest-priority-first
|
-- The loader bridge: overrideOrder() yields mods highest-priority-first
|
||||||
-- and derivedPath(rel) yields an existing save/mod-derived/<id>/<rel>.
|
-- and derivedPath(rel) yields an existing save/mod-derived/<id>/<rel>.
|
||||||
@@ -68,8 +70,18 @@ function Assets.imageData(path)
|
|||||||
return love.image.newImageData(Assets.resolve(path))
|
return love.image.newImageData(Assets.resolve(path))
|
||||||
end
|
end
|
||||||
|
|
||||||
function Assets.register(invalidate)
|
-- Register a cache invalidator, or { invalidate = fn, release = fn } when a
|
||||||
invalidators[#invalidators + 1] = invalidate
|
-- module caches LOVE Images/Canvases and can eagerly free them at session end.
|
||||||
|
-- release is optional and is NOT run on flush/invalidate (HotReload safe).
|
||||||
|
-- MapLoader is the canonical split-hook example: invalidateAll clears tables
|
||||||
|
-- without GPU release; releaseAll evicts every resident map renderer.
|
||||||
|
function Assets.register(hooks)
|
||||||
|
if type(hooks) == "function" then
|
||||||
|
invalidators[#invalidators + 1] = hooks
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if hooks.invalidate then invalidators[#invalidators + 1] = hooks.invalidate end
|
||||||
|
if hooks.release then releasers[#releasers + 1] = hooks.release end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- hot reload's single entry point (20-developer-tooling): drop the central
|
-- hot reload's single entry point (20-developer-tooling): drop the central
|
||||||
@@ -82,6 +94,17 @@ end
|
|||||||
|
|
||||||
Assets.flush = Assets.invalidate
|
Assets.flush = Assets.invalidate
|
||||||
|
|
||||||
|
-- In-process return-to-launcher / editor close: release central Images and
|
||||||
|
-- run release hooks only. Does not call invalidate hooks (MapLoader must
|
||||||
|
-- keep invalidateAll separate from releaseAll).
|
||||||
|
function Assets.releaseSession()
|
||||||
|
for _, img in pairs(cache) do
|
||||||
|
if img and img.release then pcall(img.release, img) end
|
||||||
|
end
|
||||||
|
cache = {}
|
||||||
|
for _, fn in ipairs(releasers) do pcall(fn) end
|
||||||
|
end
|
||||||
|
|
||||||
-- Loader:load hands over the live mod set once the merge is done. Load
|
-- Loader:load hands over the live mod set once the merge is done. Load
|
||||||
-- order is priority ascending, so the search walks it backwards: the mod
|
-- order is priority ascending, so the search walks it backwards: the mod
|
||||||
-- that wins the record merge wins the asset lookup too.
|
-- that wins the record merge wins the asset lookup too.
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ function Renderer:releaseCanvases()
|
|||||||
self.worldOverride = nil
|
self.worldOverride = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Renderer.release = Renderer.releaseCanvases
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ local function build(game, id, ...)
|
|||||||
inst = factory.new(game, ...)
|
inst = factory.new(game, ...)
|
||||||
end
|
end
|
||||||
inst.screenId = inst.screenId or id
|
inst.screenId = inst.screenId or id
|
||||||
|
-- Standardized opt-in marker for mod-created options/settings screens.
|
||||||
|
-- A mod may declare `isModOptions = true` on its screen factory table or
|
||||||
|
-- on the returned instance. Either way the flag is propagated so that other
|
||||||
|
-- UI mods can detect mod options screens reliably without brittle screenId
|
||||||
|
-- string-matching (issue #1697).
|
||||||
|
if factory.isModOptions and inst.isModOptions == nil then
|
||||||
|
inst.isModOptions = true
|
||||||
|
end
|
||||||
return inst
|
return inst
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+80
-4
@@ -6,6 +6,8 @@ local TouchControls = require("src.core.TouchControls")
|
|||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local FilePicker = require("src.core.FilePicker")
|
local FilePicker = require("src.core.FilePicker")
|
||||||
local SafeArea = require("src.core.SafeArea")
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
local GamepadMap = require("src.core.GamepadMap")
|
||||||
|
local PadCursor = require("src.ui.PadCursor")
|
||||||
|
|
||||||
local Studio = {}
|
local Studio = {}
|
||||||
|
|
||||||
@@ -501,6 +503,7 @@ function Studio.load(opts)
|
|||||||
Studio.thumbs = {}
|
Studio.thumbs = {}
|
||||||
Studio.pointerX, Studio.pointerY = nil, nil
|
Studio.pointerX, Studio.pointerY = nil, nil
|
||||||
Studio.pointerDown, Studio.touchId = false, nil
|
Studio.pointerDown, Studio.touchId = false, nil
|
||||||
|
PadCursor.reset()
|
||||||
-- Studio always opens on the library. Creating and choosing a skin are
|
-- Studio always opens on the library. Creating and choosing a skin are
|
||||||
-- first-class tasks, not controls buried inside the editor workspace.
|
-- first-class tasks, not controls buried inside the editor workspace.
|
||||||
Studio.mode = "library"
|
Studio.mode = "library"
|
||||||
@@ -758,6 +761,7 @@ function Studio.unload()
|
|||||||
Studio.undoStack, Studio.redoStack = {}, {}
|
Studio.undoStack, Studio.redoStack = {}, {}
|
||||||
Studio.pointerX, Studio.pointerY = nil, nil
|
Studio.pointerX, Studio.pointerY = nil, nil
|
||||||
Studio.pointerDown, Studio.touchId = false, nil
|
Studio.pointerDown, Studio.touchId = false, nil
|
||||||
|
PadCursor.reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
|
-- The Studio is a real touch editor on Android and iOS. Keeping this here,
|
||||||
@@ -2678,7 +2682,12 @@ function Studio.draw()
|
|||||||
local f = Studio._frame
|
local f = Studio._frame
|
||||||
Kit.layout(f.w, f.h)
|
Kit.layout(f.w, f.h)
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
if Studio.pointerX ~= nil then mx, my = Studio.pointerX, Studio.pointerY end
|
local px, py, padActive = PadCursor.pointer()
|
||||||
|
if padActive then
|
||||||
|
mx, my = px, py
|
||||||
|
elseif Studio.pointerX ~= nil then
|
||||||
|
mx, my = Studio.pointerX, Studio.pointerY
|
||||||
|
end
|
||||||
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
|
||||||
Studio.clicked, Studio.wheel = false, 0
|
Studio.clicked, Studio.wheel = false, 0
|
||||||
Theme.fill(0, 0, W, H, PAL.bg, 1)
|
Theme.fill(0, 0, W, H, PAL.bg, 1)
|
||||||
@@ -2691,19 +2700,26 @@ function Studio.draw()
|
|||||||
Kit.blockClicks = false
|
Kit.blockClicks = false
|
||||||
Studio.drawOverlay(f.w, f.h)
|
Studio.drawOverlay(f.w, f.h)
|
||||||
Kit.endFrame()
|
Kit.endFrame()
|
||||||
|
PadCursor.draw()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- input
|
-- ---------------------------------------------------------------- input
|
||||||
|
|
||||||
function Studio.update()
|
function Studio.update(dt)
|
||||||
|
PadCursor.update(dt or 0)
|
||||||
|
local x, y, active = PadCursor.pointer()
|
||||||
|
if active and Studio.pointerDown then Studio.mousemoved(x, y) end
|
||||||
|
local wheel = PadCursor.takeWheel()
|
||||||
|
if wheel ~= 0 then Studio.wheelmoved(0, wheel) end
|
||||||
if not Studio.pendingPlay then return end
|
if not Studio.pendingPlay then return end
|
||||||
Studio.pendingPlay = false
|
Studio.pendingPlay = false
|
||||||
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
|
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
|
||||||
if onPlay then onPlay(version, canvas) end
|
if onPlay then onPlay(version, canvas) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Studio.mousepressed(x, y, button)
|
function Studio.mousepressed(x, y, button, fromPad)
|
||||||
if button ~= 1 then return end
|
if button ~= 1 then return end
|
||||||
|
if not fromPad then PadCursor.yieldToPointer() end
|
||||||
Studio.pointerX, Studio.pointerY = x, y
|
Studio.pointerX, Studio.pointerY = x, y
|
||||||
Studio.pointerDown = true
|
Studio.pointerDown = true
|
||||||
Studio.clicked = true
|
Studio.clicked = true
|
||||||
@@ -2760,6 +2776,65 @@ function Studio.touchpressed(id, x, y)
|
|||||||
return Studio.mousepressed(x, y, 1)
|
return Studio.mousepressed(x, y, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function closeFromPad()
|
||||||
|
if Studio.confirm then
|
||||||
|
Studio.confirmNo()
|
||||||
|
elseif Studio.modal then
|
||||||
|
Studio.closeModal()
|
||||||
|
elseif Studio.mode == "editor" then
|
||||||
|
Studio.backToLibrary()
|
||||||
|
elseif Studio.onClose then
|
||||||
|
Studio.onClose()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handlePadAction(action)
|
||||||
|
if action == "a" then
|
||||||
|
local x, y = PadCursor.pointer()
|
||||||
|
Studio.mousepressed(x, y, 1, true)
|
||||||
|
elseif action == "b" then
|
||||||
|
closeFromPad()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.gamepadpressed(joystick, button)
|
||||||
|
handlePadAction(PadCursor.gamepadpressed(joystick, button))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.gamepadreleased(joystick, button)
|
||||||
|
PadCursor.gamepadreleased(joystick, button)
|
||||||
|
if GamepadMap.mapGamepadButton(button) == "a" then
|
||||||
|
local x, y = PadCursor.pointer()
|
||||||
|
Studio.mousereleased(x, y, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.gamepadaxis(joystick, axis, value)
|
||||||
|
PadCursor.gamepadaxis(joystick, axis, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.joystickpressed(joystick, button)
|
||||||
|
handlePadAction(PadCursor.joystickpressed(joystick, button))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.joystickreleased(joystick, button)
|
||||||
|
PadCursor.joystickreleased(joystick, button)
|
||||||
|
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||||
|
local padButton = GamepadMap.mapRawToGamepadButton(button)
|
||||||
|
if padButton and GamepadMap.mapGamepadButton(padButton) == "a" then
|
||||||
|
local x, y = PadCursor.pointer()
|
||||||
|
Studio.mousereleased(x, y, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.joystickaxis(joystick, axis, value)
|
||||||
|
PadCursor.joystickaxis(joystick, axis, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Studio.joystickhat(joystick, hat, direction)
|
||||||
|
PadCursor.joystickhat(joystick, hat, direction)
|
||||||
|
end
|
||||||
|
|
||||||
function Studio.touchmoved(id, x, y)
|
function Studio.touchmoved(id, x, y)
|
||||||
if Studio.touchId ~= id then return end
|
if Studio.touchId ~= id then return end
|
||||||
return Studio.mousemoved(x, y)
|
return Studio.mousemoved(x, y)
|
||||||
@@ -2774,7 +2849,8 @@ end
|
|||||||
function Studio.wheelmoved(_, dy)
|
function Studio.wheelmoved(_, dy)
|
||||||
if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then
|
if Studio.mode == "editor" and not Studio.modalUp() and dy and dy ~= 0 then
|
||||||
local work = Studio.canvasWorkspace
|
local work = Studio.canvasWorkspace
|
||||||
local mx, my = Studio.pointerX, Studio.pointerY
|
local mx, my, active = PadCursor.pointer()
|
||||||
|
if not active then mx, my = Studio.pointerX, Studio.pointerY end
|
||||||
if (not mx or not my) and love and love.mouse and love.mouse.getPosition then
|
if (not mx or not my) and love and love.mouse and love.mouse.getPosition then
|
||||||
mx, my = love.mouse.getPosition()
|
mx, my = love.mouse.getPosition()
|
||||||
end
|
end
|
||||||
|
|||||||
+31
-3
@@ -138,11 +138,39 @@ function Chrome.printThrough(text, tx, ty, palette, invert)
|
|||||||
local paper = pal[1] or { 255, 255, 255 }
|
local paper = pal[1] or { 255, 255, 255 }
|
||||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||||
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
-- Per glyph, not per string: a TTF-mod build still keeps the multi-byte
|
||||||
|
-- charmap sequences (the naming screen's <PK>/<MN>, the 'd/'l/'s ligatures)
|
||||||
|
-- and anything the mod names in ttf.tiles (Font.lua's own note on keeping
|
||||||
|
-- digits tile-based for column alignment) on their ROM tiles, so a string
|
||||||
|
-- can freely mix the two kinds of glyph.
|
||||||
|
local ink = pal[4] or { 0, 0, 0 }
|
||||||
local previous = love.graphics.getShader()
|
local previous = love.graphics.getShader()
|
||||||
|
local shaded = false
|
||||||
|
local pen = tx * 8
|
||||||
|
for _, code in ipairs(Font.encode(text)) do
|
||||||
|
if code >= Font.TTF_BASE then
|
||||||
|
-- The shader below recovers a shade from the RED CHANNEL of an already
|
||||||
|
-- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
|
||||||
|
-- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
|
||||||
|
-- white texture, which this shader reads back as shade 0 no matter how
|
||||||
|
-- solid the glyph looks, painting the character the SAME colour as the
|
||||||
|
-- paper rect just drawn above it: invisible (reported against a real
|
||||||
|
-- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF
|
||||||
|
-- glyph has no discrete shade to recover in the first place, so skip
|
||||||
|
-- the shader for it and tint it with the palette's own ink colour
|
||||||
|
-- (shade 3, the same entry `rgb = pal3` would have mapped a black tile
|
||||||
|
-- pixel to) directly.
|
||||||
|
if shaded then love.graphics.setShader(previous); shaded = false end
|
||||||
|
love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1)
|
||||||
|
elseif not shaded then
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
GbcPalette.useRaw(pal)
|
GbcPalette.useRaw(pal)
|
||||||
Font.draw(text, tx * 8, ty * 8)
|
shaded = true
|
||||||
love.graphics.setShader(previous)
|
end
|
||||||
|
Font.drawCode(code, pen, ty * 8)
|
||||||
|
pen = pen + Font.advanceOf(code)
|
||||||
|
end
|
||||||
|
if shaded then love.graphics.setShader(previous) end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
return width
|
return width
|
||||||
end
|
end
|
||||||
|
|||||||
+11
-10
@@ -24,6 +24,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local MainMenu = {}
|
local MainMenu = {}
|
||||||
MainMenu.__index = MainMenu
|
MainMenu.__index = MainMenu
|
||||||
@@ -73,14 +74,14 @@ local function sameItems(_, items) return items end
|
|||||||
function MainMenu:buildList()
|
function MainMenu:buildList()
|
||||||
local items = {}
|
local items = {}
|
||||||
if self.hasSave then
|
if self.hasSave then
|
||||||
items[#items + 1] = { label = "CONTINUE", value = "continue" }
|
items[#items + 1] = { label = Strings("CONTINUE"), value = "continue" }
|
||||||
end
|
end
|
||||||
items[#items + 1] = { label = "NEW GAME", value = "new" }
|
items[#items + 1] = { label = Strings("NEW GAME"), value = "new" }
|
||||||
items[#items + 1] = { label = "OPTION", value = "option" }
|
items[#items + 1] = { label = Strings("OPTION"), value = "option" }
|
||||||
-- Not on the cart: a cartridge is left by switching the console off, and
|
-- Not on the cart: a cartridge is left by switching the console off, and
|
||||||
-- there is no console here. Mirrors the Gen 1 port's title menu
|
-- there is no console here. Mirrors the Gen 1 port's title menu
|
||||||
-- (src/ui/TitleState.lua), which adds the same row for the same reason.
|
-- (src/ui/TitleState.lua), which adds the same row for the same reason.
|
||||||
items[#items + 1] = { label = "EXIT GAME", value = "exit" }
|
items[#items + 1] = { label = Strings("EXIT GAME"), value = "exit" }
|
||||||
-- The same hook name and the same (game, items) payload the Gen 1 title
|
-- The same hook name and the same (game, items) payload the Gen 1 title
|
||||||
-- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows
|
-- menu raises (src/ui/TitleState.lua:openMenu), so one mod's title rows
|
||||||
-- serve both games; only the row shape differs, because Chrome.List reads
|
-- serve both games; only the row shape differs, because Chrome.List reads
|
||||||
@@ -170,7 +171,7 @@ function MainMenu:drawClockBox()
|
|||||||
-- minutes; the AM/PM half is drawn by PrintHour itself.
|
-- minutes; the AM/PM half is drawn by PrintHour itself.
|
||||||
local display = hour % 12
|
local display = hour % 12
|
||||||
if display == 0 then display = 12 end
|
if display == 0 then display = 12 end
|
||||||
local half = hour < 12 and "AM" or "PM"
|
local half = Strings(hour < 12 and "AM" or "PM")
|
||||||
Chrome.print(("%s:%s %s"):format(
|
Chrome.print(("%s:%s %s"):format(
|
||||||
Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16)
|
Chrome.number(display, 2), Chrome.number(minute, 2, true), half), 4, 16)
|
||||||
end
|
end
|
||||||
@@ -180,15 +181,15 @@ function MainMenu:drawSavePanel()
|
|||||||
-- DisplaySaveInfoOnContinue: a box down the right side listing the trainer.
|
-- DisplaySaveInfoOnContinue: a box down the right side listing the trainer.
|
||||||
Chrome.textbox(4, 0, 14, 9)
|
Chrome.textbox(4, 0, 14, 9)
|
||||||
if not summary then
|
if not summary then
|
||||||
Chrome.print("NO SAVE FILE", 5, 2)
|
Chrome.print(Strings("NO SAVE FILE"), 5, 2)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Chrome.print("PLAYER " .. summary.name, 5, 2)
|
Chrome.print(Strings("PLAYER %s", summary.name), 5, 2)
|
||||||
Chrome.print("BADGES", 5, 4)
|
Chrome.print(Strings("BADGES"), 5, 4)
|
||||||
Chrome.printRight(tostring(summary.badges), 17, 4)
|
Chrome.printRight(tostring(summary.badges), 17, 4)
|
||||||
Chrome.print("POKéDEX", 5, 6)
|
Chrome.print(Strings("POKéDEX"), 5, 6)
|
||||||
Chrome.printRight(tostring(summary.caught), 17, 6)
|
Chrome.printRight(tostring(summary.caught), 17, 6)
|
||||||
Chrome.print("TIME", 5, 8)
|
Chrome.print(Strings("TIME"), 5, 8)
|
||||||
Chrome.printRight(("%d:%s"):format(
|
Chrome.printRight(("%d:%s"):format(
|
||||||
summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8)
|
summary.hours, Chrome.number(summary.minutes, 2, true)), 17, 8)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local NamingScreen = {}
|
local NamingScreen = {}
|
||||||
NamingScreen.__index = NamingScreen
|
NamingScreen.__index = NamingScreen
|
||||||
@@ -67,8 +68,15 @@ local BOX_INPUT_LOWER = {
|
|||||||
-- case target names the board it SWITCHES TO, not the one it is on: the last
|
-- case target names the board it SWITCHES TO, not the one it is on: the last
|
||||||
-- row of NameInputUpper is "lower DEL END" and the last row of
|
-- row of NameInputUpper is "lower DEL END" and the last row of
|
||||||
-- NameInputLower is "UPPER DEL END" (data/text/name_input_chars.asm).
|
-- NameInputLower is "UPPER DEL END" (data/text/name_input_chars.asm).
|
||||||
local BOTTOM_UPPER_LABELS = { "lower", "DEL", "END" }
|
-- Wrapped in Strings.source, not Strings: both tables are built once at
|
||||||
local BOTTOM_LOWER_LABELS = { "UPPER", "DEL", "END" }
|
-- require time, before any mod's Strings.load has a catalog to answer from
|
||||||
|
-- (src/core/Strings.lua's own note on this); drawPanel resolves them live.
|
||||||
|
local BOTTOM_UPPER_LABELS = {
|
||||||
|
Strings.source("lower"), Strings.source("DEL"), Strings.source("END"),
|
||||||
|
}
|
||||||
|
local BOTTOM_LOWER_LABELS = {
|
||||||
|
Strings.source("UPPER"), Strings.source("DEL"), Strings.source("END"),
|
||||||
|
}
|
||||||
-- Cursor tile for each target: NamingScreen_AnimateCursor's .CaseDelEnd adds
|
-- Cursor tile for each target: NamingScreen_AnimateCursor's .CaseDelEnd adds
|
||||||
-- pixel $00 / $30 / $60 to the cursor's own XCOORD of 24 (`depixel 10, 3`),
|
-- pixel $00 / $30 / $60 to the cursor's own XCOORD of 24 (`depixel 10, 3`),
|
||||||
-- which is OAM x 24 / 72 / 120 and so screen tile 2 / 8 / 14. The bracket is
|
-- which is OAM x 24 / 72 / 120 and so screen tile 2 / 8 / 14. The bracket is
|
||||||
@@ -81,11 +89,11 @@ local BOTTOM_CURSOR_TILES = 5
|
|||||||
-- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes.
|
-- NAME_* types (constants/menu_constants.asm order) as prompts + field sizes.
|
||||||
-- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters.
|
-- Lengths are the ASM's *_NAME_LENGTH - 1, i.e. usable characters.
|
||||||
NamingScreen.TYPES = {
|
NamingScreen.TYPES = {
|
||||||
player = { prompt = "YOUR NAME?", maxLength = 7, sprite = "SPRITE_CHRIS",
|
player = { prompt = Strings.source("YOUR NAME?"), maxLength = 7,
|
||||||
spriteFemale = "SPRITE_KRIS" },
|
sprite = "SPRITE_CHRIS", spriteFemale = "SPRITE_KRIS" },
|
||||||
rival = { prompt = "RIVAL'S NAME?", maxLength = 7, sprite = "SPRITE_RIVAL" },
|
rival = { prompt = Strings.source("RIVAL'S NAME?"), maxLength = 7, sprite = "SPRITE_RIVAL" },
|
||||||
mom = { prompt = "MOTHER'S NAME?", maxLength = 7, sprite = "SPRITE_MOM" },
|
mom = { prompt = Strings.source("MOTHER'S NAME?"), maxLength = 7, sprite = "SPRITE_MOM" },
|
||||||
box = { prompt = "BOX NAME?", maxLength = 8, isBox = true },
|
box = { prompt = Strings.source("BOX NAME?"), maxLength = 8, isBox = true },
|
||||||
nickname = { prompt = nil, maxLength = 10 },
|
nickname = { prompt = nil, maxLength = 10 },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +122,7 @@ function NamingScreen.new(game, opts)
|
|||||||
or (game and game.save and game.save.player and game.save.player.gender)
|
or (game and game.save and game.save.player and game.save.player.gender)
|
||||||
self.isBox = opts.isBox or kind.isBox or false
|
self.isBox = opts.isBox or kind.isBox or false
|
||||||
self.maxLength = opts.maxLength or kind.maxLength or 7
|
self.maxLength = opts.maxLength or kind.maxLength or 7
|
||||||
self.prompt = opts.prompt or kind.prompt or "NICKNAME?"
|
self.prompt = opts.prompt or kind.prompt or Strings.source("NICKNAME?")
|
||||||
self.monName = opts.monName
|
self.monName = opts.monName
|
||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
self.onCancel = opts.onCancel
|
self.onCancel = opts.onCancel
|
||||||
@@ -504,11 +512,15 @@ function NamingScreen:drawPanel()
|
|||||||
end
|
end
|
||||||
local pal = self.palette
|
local pal = self.palette
|
||||||
if self.monName then
|
if self.monName then
|
||||||
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?".
|
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?". Kept as two
|
||||||
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
|
-- Strings() calls, one per line (Chrome.printThrough draws a single row),
|
||||||
Chrome.printThrough("NICKNAME?", 5, 4, pal)
|
-- with the mon name folded into the first line's own format string so a
|
||||||
|
-- language whose possessive is not a bare suffix can restructure that
|
||||||
|
-- line rather than being stuck splicing one on.
|
||||||
|
Chrome.printThrough(Strings("%s'S", self.monName), 5, 2, pal)
|
||||||
|
Chrome.printThrough(Strings("NICKNAME?"), 5, 4, pal)
|
||||||
else
|
else
|
||||||
Chrome.printThrough(self.prompt, 5, 2, pal)
|
Chrome.printThrough(Strings(self.prompt), 5, 2, pal)
|
||||||
end
|
end
|
||||||
self:drawEntry(5, self.isBox and 4 or 6)
|
self:drawEntry(5, self.isBox and 4 or 6)
|
||||||
|
|
||||||
@@ -520,14 +532,18 @@ function NamingScreen:drawPanel()
|
|||||||
for col = 0, 8 do
|
for col = 0, 8 do
|
||||||
local ch = line[col + 1]
|
local ch = line[col + 1]
|
||||||
if ch and ch ~= " " and ch ~= "" then
|
if ch and ch ~= " " and ch ~= "" then
|
||||||
Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal)
|
-- Same seam the Gen 1 board's cells go through (src/ui/NamingScreen
|
||||||
|
-- .lua's own Strings(cell)): a script whose alphabet does not fit
|
||||||
|
-- A-Z can swap a cell's glyph without needing the heavier
|
||||||
|
-- ui.naming.grid hook this screen also offers.
|
||||||
|
Chrome.printThrough(Strings(ch), 2 + col * 2, keyboardTop + row * 2, pal)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
|
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
|
||||||
local bottomY = keyboardTop + bottom * 2
|
local bottomY = keyboardTop + bottom * 2
|
||||||
for i, label in ipairs(labels) do
|
for i, label in ipairs(labels) do
|
||||||
Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal)
|
Chrome.printThrough(Strings(label), BOTTOM_LABEL_TX[i], bottomY, pal)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function cursor() self:drawCursorBox(self:cursorTile()) end
|
local function cursor() self:drawCursorBox(self:cursorTile()) end
|
||||||
|
|||||||
+72
-40
@@ -23,6 +23,7 @@ local Logger = require("src.core.Logger")
|
|||||||
local Performance = require("src.core.Performance")
|
local Performance = require("src.core.Performance")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local OptionsMenu = {}
|
local OptionsMenu = {}
|
||||||
OptionsMenu.__index = OptionsMenu
|
OptionsMenu.__index = OptionsMenu
|
||||||
@@ -43,43 +44,60 @@ end
|
|||||||
-- Each row: the label, the option key it edits, and the cycle of values with
|
-- Each row: the label, the option key it edits, and the cycle of values with
|
||||||
-- the exact strings the cart prints (trailing spaces included -- they are what
|
-- the exact strings the cart prints (trailing spaces included -- they are what
|
||||||
-- blank the longer previous value, e.g. "MID " over "SLOW").
|
-- blank the longer previous value, e.g. "MID " over "SLOW").
|
||||||
|
-- Labels and cart-original display strings are wrapped in Strings.source so
|
||||||
|
-- the catalog generator harvests them even though this table is built once
|
||||||
|
-- at require time, before any mod's Strings.load has a catalog to answer
|
||||||
|
-- from (src/core/Strings.lua's own note on this). The lookup itself happens
|
||||||
|
-- live, in drawPanel, through plain Strings(...) calls.
|
||||||
local ROWS = {
|
local ROWS = {
|
||||||
{
|
{
|
||||||
label = "TEXT SPEED", key = "textSpeed",
|
label = Strings.source("TEXT SPEED"), key = "textSpeed",
|
||||||
values = { "FAST", "MID", "SLOW" },
|
values = { "FAST", "MID", "SLOW" },
|
||||||
display = { FAST = "FAST", MID = "MID ", SLOW = "SLOW" },
|
display = {
|
||||||
|
FAST = Strings.source("FAST"), MID = Strings.source("MID "),
|
||||||
|
SLOW = Strings.source("SLOW"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label = "BATTLE SCENE", key = "battleScene",
|
label = Strings.source("BATTLE SCENE"), key = "battleScene",
|
||||||
values = { true, false },
|
values = { true, false },
|
||||||
display = { [true] = "ON ", [false] = "OFF" },
|
display = {
|
||||||
|
[true] = Strings.source("ON "), [false] = Strings.source("OFF"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label = "BATTLE STYLE", key = "battleStyle",
|
label = Strings.source("BATTLE STYLE"), key = "battleStyle",
|
||||||
values = { "SHIFT", "SET" },
|
values = { "SHIFT", "SET" },
|
||||||
display = { SHIFT = "SHIFT", SET = "SET " },
|
display = {
|
||||||
|
SHIFT = Strings.source("SHIFT"), SET = Strings.source("SET "),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label = "SOUND", key = "sound",
|
label = Strings.source("SOUND"), key = "sound",
|
||||||
values = { "MONO", "STEREO" },
|
values = { "MONO", "STEREO" },
|
||||||
display = { MONO = "MONO ", STEREO = "STEREO" },
|
display = {
|
||||||
|
MONO = Strings.source("MONO "), STEREO = Strings.source("STEREO"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label = "PRINT", key = "print",
|
label = Strings.source("PRINT"), key = "print",
|
||||||
values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" },
|
values = { "LIGHTEST", "LIGHTER", "NORMAL", "DARKER", "DARKEST" },
|
||||||
display = {
|
display = {
|
||||||
LIGHTEST = "LIGHTEST", LIGHTER = "LIGHTER ", NORMAL = "NORMAL ",
|
LIGHTEST = Strings.source("LIGHTEST"), LIGHTER = Strings.source("LIGHTER "),
|
||||||
DARKER = "DARKER ", DARKEST = "DARKEST ",
|
NORMAL = Strings.source("NORMAL "), DARKER = Strings.source("DARKER "),
|
||||||
|
DARKEST = Strings.source("DARKEST "),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label = "MENU ACCOUNT", key = "menuAccount",
|
label = Strings.source("MENU ACCOUNT"), key = "menuAccount",
|
||||||
values = { false, true },
|
values = { false, true },
|
||||||
display = { [false] = "OFF", [true] = "ON " },
|
display = {
|
||||||
|
[false] = Strings.source("OFF"), [true] = Strings.source("ON "),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
-- FRAME is the textbox border style, 1-8, and prints its number after the
|
-- FRAME is the textbox border style, 1-8, and prints its number after the
|
||||||
-- word TYPE rather than in the shared value column.
|
-- word TYPE rather than in the shared value column.
|
||||||
{ label = "FRAME", key = "frame", frame = true },
|
{ label = Strings.source("FRAME"), key = "frame", frame = true },
|
||||||
-- Everything from here down is the port's, not the cart's. They are the
|
-- Everything from here down is the port's, not the cart's. They are the
|
||||||
-- same settings the Gen 1 OPTION screen carries and they drive the same
|
-- same settings the Gen 1 OPTION screen carries and they drive the same
|
||||||
-- shared modules, so a player who learns them in Red knows them here. The
|
-- shared modules, so a player who learns them in Red knows them here. The
|
||||||
@@ -87,17 +105,17 @@ local ROWS = {
|
|||||||
--
|
--
|
||||||
-- The two volume rows clamp at the ends rather than wrapping, the way
|
-- The two volume rows clamp at the ends rather than wrapping, the way
|
||||||
-- pokered's text-speed cursor does, so holding left reaches OFF and stays.
|
-- pokered's text-speed cursor does, so holding left reaches OFF and stays.
|
||||||
{ id = "controls", label = "CONTROLS", port = true,
|
{ id = "controls", label = Strings.source("CONTROLS"), port = true,
|
||||||
activate = function(game)
|
activate = function(game)
|
||||||
require("src.ui.Screens").push(game, "BindingsMenu")
|
require("src.ui.Screens").push(game, "BindingsMenu")
|
||||||
end },
|
end },
|
||||||
{ label = "MUSIC VOL", key = "musicVol", port = true,
|
{ label = Strings.source("MUSIC VOL"), key = "musicVol", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
options.musicVol = stepVolume(options.musicVol, delta)
|
options.musicVol = stepVolume(options.musicVol, delta)
|
||||||
require("src.core.Music").setVolumeLevel(options.musicVol)
|
require("src.core.Music").setVolumeLevel(options.musicVol)
|
||||||
end,
|
end,
|
||||||
text = function(options) return volLabel(options.musicVol) end },
|
text = function(options) return volLabel(options.musicVol) end },
|
||||||
{ label = "SFX VOL", key = "sfxVol", port = true,
|
{ label = Strings.source("SFX VOL"), key = "sfxVol", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
options.sfxVol = stepVolume(options.sfxVol, delta)
|
options.sfxVol = stepVolume(options.sfxVol, delta)
|
||||||
require("src.core.Sound").setVolumeLevel(options.sfxVol)
|
require("src.core.Sound").setVolumeLevel(options.sfxVol)
|
||||||
@@ -105,7 +123,7 @@ local ROWS = {
|
|||||||
text = function(options) return volLabel(options.sfxVol) end },
|
text = function(options) return volLabel(options.sfxVol) end },
|
||||||
-- Each filter step keeps 40% of the previous step's treble, so 2X and 3X
|
-- Each filter step keeps 40% of the previous step's treble, so 2X and 3X
|
||||||
-- are the 1X low-pass applied twice and three times over.
|
-- are the 1X low-pass applied twice and three times over.
|
||||||
{ label = "MUSIC FILTER", key = "musicFilter", port = true,
|
{ label = Strings.source("MUSIC FILTER"), key = "musicFilter", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS
|
options.musicFilter = ((options.musicFilter or 0) + delta) % #FILTERS
|
||||||
require("src.core.Music").setFilterLevel(options.musicFilter)
|
require("src.core.Music").setFilterLevel(options.musicFilter)
|
||||||
@@ -119,7 +137,7 @@ local ROWS = {
|
|||||||
-- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle
|
-- file's own `text`/`cycle`) works here unmodified: OptionsMenu:cycle
|
||||||
-- answers `row.step` first, and drawPanel already reads a function
|
-- answers `row.step` first, and drawPanel already reads a function
|
||||||
-- `row.value` -- both written for exactly this kind of shared mod row.
|
-- `row.value` -- both written for exactly this kind of shared mod row.
|
||||||
{ id = "performance", label = "PERFORMANCE", port = true,
|
{ id = "performance", label = Strings.source("PERFORMANCE"), port = true,
|
||||||
value = function(g)
|
value = function(g)
|
||||||
return Performance.label(g.options and g.options.performance)
|
return Performance.label(g.options and g.options.performance)
|
||||||
end,
|
end,
|
||||||
@@ -129,7 +147,7 @@ local ROWS = {
|
|||||||
g:applyOptions()
|
g:applyOptions()
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
{ label = "GAME SPEED", key = "speed", port = true,
|
{ label = Strings.source("GAME SPEED"), key = "speed", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
options.speed = GameSpeed.cycle(options.speed, delta)
|
options.speed = GameSpeed.cycle(options.speed, delta)
|
||||||
@@ -137,7 +155,7 @@ local ROWS = {
|
|||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.core.GameSpeed").levelLabel(options.speed)
|
return require("src.core.GameSpeed").levelLabel(options.speed)
|
||||||
end },
|
end },
|
||||||
{ label = "ZOOM", key = "zoom", port = true,
|
{ label = Strings.source("ZOOM"), key = "zoom", port = true,
|
||||||
cycle = function(options, delta, game)
|
cycle = function(options, delta, game)
|
||||||
local Zoom = require("src.render.Zoom")
|
local Zoom = require("src.render.Zoom")
|
||||||
local scale = Zoom.windowFitScale()
|
local scale = Zoom.windowFitScale()
|
||||||
@@ -153,7 +171,7 @@ local ROWS = {
|
|||||||
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
|
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
|
||||||
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
|
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
|
||||||
-- is Gold's default because that is already what the maps call for).
|
-- is Gold's default because that is already what the maps call for).
|
||||||
{ label = "VOID FILL", key = "voidFill", port = true,
|
{ label = Strings.source("VOID FILL"), key = "voidFill", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local BorderFill = require("src.world.gen2.BorderFill")
|
local BorderFill = require("src.world.gen2.BorderFill")
|
||||||
BorderFill.setVoidFill(options.voidFill or "fade")
|
BorderFill.setVoidFill(options.voidFill or "fade")
|
||||||
@@ -162,7 +180,7 @@ local ROWS = {
|
|||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
|
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
|
||||||
end },
|
end },
|
||||||
{ label = "TILT", key = "tilt", port = true,
|
{ label = Strings.source("TILT"), key = "tilt", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
-- Four levels (OFF, 15, 35, 50); left steps back through them.
|
-- Four levels (OFF, 15, 35, 50); left steps back through them.
|
||||||
@@ -177,7 +195,7 @@ local ROWS = {
|
|||||||
-- CGB game whose colour comes from its own palettes, so there are no packs
|
-- CGB game whose colour comes from its own palettes, so there are no packs
|
||||||
-- to swap -- what there is instead is the choice to turn that colour OFF,
|
-- to swap -- what there is instead is the choice to turn that colour OFF,
|
||||||
-- down to the grey Game Boy or the green one. GBC is the default.
|
-- down to the grey Game Boy or the green one. GBC is the default.
|
||||||
{ label = "COLOR", key = "color", port = true,
|
{ label = Strings.source("COLOR"), key = "color", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
GbcPalette.setMode(options.color or "gbc")
|
GbcPalette.setMode(options.color or "gbc")
|
||||||
@@ -186,12 +204,26 @@ local ROWS = {
|
|||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
|
return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
|
||||||
end },
|
end },
|
||||||
|
{ label = Strings.source("GBC FX"), key = "gbcfx", port = true,
|
||||||
|
cycle = function(options, delta)
|
||||||
|
local GBCFX = require("src.render.GBCFX")
|
||||||
|
if not GBCFX.isSupported() then
|
||||||
|
options.gbcfx = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local level = ((options.gbcfx or 0) + delta) % 5
|
||||||
|
options.gbcfx = level
|
||||||
|
GBCFX.setLevel(level)
|
||||||
|
end,
|
||||||
|
text = function(options)
|
||||||
|
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
|
||||||
|
end },
|
||||||
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
|
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
|
||||||
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
|
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
|
||||||
-- ShaderFXScreen is the shared list screen both generations push, `id`
|
-- ShaderFXScreen is the shared list screen both generations push, `id`
|
||||||
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
|
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
|
||||||
-- reaches Gold.
|
-- reaches Gold.
|
||||||
{ id = "shaderfx", label = "SHADER FX", port = true,
|
{ id = "shaderfx", label = Strings.source("SHADER FX"), port = true,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
local ShaderFX = require("src.render.ShaderFX")
|
local ShaderFX = require("src.render.ShaderFX")
|
||||||
local entry = ShaderFX.activeEntry("main")
|
local entry = ShaderFX.activeEntry("main")
|
||||||
@@ -204,7 +236,7 @@ local ROWS = {
|
|||||||
-- Dual-shader secondary slot, same shared ShaderFXScreen as the row
|
-- Dual-shader secondary slot, same shared ShaderFXScreen as the row
|
||||||
-- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's
|
-- above, opened on "secondary" instead -- see src/ui/OptionsMenu.lua's
|
||||||
-- mirror of this row for the full rationale.
|
-- mirror of this row for the full rationale.
|
||||||
{ id = "shaderfx2", label = "SHADER FX 2", port = true,
|
{ id = "shaderfx2", label = Strings.source("SHADER FX 2"), port = true,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
local ShaderFX = require("src.render.ShaderFX")
|
local ShaderFX = require("src.render.ShaderFX")
|
||||||
local entry = ShaderFX.activeEntry("secondary")
|
local entry = ShaderFX.activeEntry("secondary")
|
||||||
@@ -214,7 +246,7 @@ local ROWS = {
|
|||||||
activate = function(game)
|
activate = function(game)
|
||||||
require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary")
|
require("src.ui.Screens").push(game, "ShaderFXScreen", "secondary")
|
||||||
end },
|
end },
|
||||||
{ label = "VIDEO MODE", key = "videoMode", port = true,
|
{ label = Strings.source("VIDEO MODE"), key = "videoMode", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
options.videoMode = VideoMode.cycle(options.videoMode, delta)
|
options.videoMode = VideoMode.cycle(options.videoMode, delta)
|
||||||
@@ -225,20 +257,20 @@ local ROWS = {
|
|||||||
return VideoMode.normalize(options.videoMode) == "borderless"
|
return VideoMode.normalize(options.videoMode) == "borderless"
|
||||||
and "FULL" or "WINDOWED"
|
and "FULL" or "WINDOWED"
|
||||||
end },
|
end },
|
||||||
{ label = "SCREEN POS", key = "screenPos", port = true,
|
{ label = Strings.source("SCREEN POS"), key = "screenPos", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local ScreenPosition = require("src.core.ScreenPosition")
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
|
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
|
||||||
ScreenPosition.setMode(options.screenPos)
|
ScreenPosition.setMode(options.screenPos)
|
||||||
end,
|
end,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.core.ScreenPosition").label(options.screenPos)
|
return Strings(require("src.core.ScreenPosition").label(options.screenPos))
|
||||||
end },
|
end },
|
||||||
{ id = "touchControls", label = "TOUCH PAD", port = true,
|
{ id = "touchControls", label = Strings.source("TOUCH PAD"), port = true,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
local tc = options.touchControls
|
local tc = options.touchControls
|
||||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||||
return on and "ON" or "OFF"
|
return on and Strings("ON") or Strings("OFF")
|
||||||
end,
|
end,
|
||||||
cycle = function(options, _delta, game)
|
cycle = function(options, _delta, game)
|
||||||
local tc = type(options.touchControls) == "table" and options.touchControls or {}
|
local tc = type(options.touchControls) == "table" and options.touchControls or {}
|
||||||
@@ -247,13 +279,13 @@ local ROWS = {
|
|||||||
require("src.core.TouchControls"):applyOptions(options)
|
require("src.core.TouchControls"):applyOptions(options)
|
||||||
if game and game.persistOptions then game:persistOptions() end
|
if game and game.persistOptions then game:persistOptions() end
|
||||||
end },
|
end },
|
||||||
{ id = "touchLayout", label = "TOUCH LAYOUT", port = true,
|
{ id = "touchLayout", label = Strings.source("TOUCH LAYOUT"), port = true,
|
||||||
activate = function(game)
|
activate = function(game)
|
||||||
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
|
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
|
||||||
end },
|
end },
|
||||||
{ id = "haptics", label = "VIBRATION", port = true,
|
{ id = "haptics", label = Strings.source("VIBRATION"), port = true,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.core.TouchControls").hapticLabel(options.haptics)
|
return Strings(require("src.core.TouchControls").hapticLabel(options.haptics))
|
||||||
end,
|
end,
|
||||||
cycle = function(options, delta, game)
|
cycle = function(options, delta, game)
|
||||||
local TC = require("src.core.TouchControls")
|
local TC = require("src.core.TouchControls")
|
||||||
@@ -262,7 +294,7 @@ local ROWS = {
|
|||||||
TC.buzz(options.haptics)
|
TC.buzz(options.haptics)
|
||||||
if game and game.persistOptions then game:persistOptions() end
|
if game and game.persistOptions then game:persistOptions() end
|
||||||
end },
|
end },
|
||||||
{ label = "MAX FPS", key = "fpsCap", port = true,
|
{ label = Strings.source("MAX FPS"), key = "fpsCap", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local FrameCap = require("src.core.FrameCap")
|
local FrameCap = require("src.core.FrameCap")
|
||||||
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
|
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
|
||||||
@@ -273,10 +305,10 @@ local ROWS = {
|
|||||||
end },
|
end },
|
||||||
-- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE
|
-- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE
|
||||||
-- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only.
|
-- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only.
|
||||||
{ label = "BATTLE BG", key = "battleBg", port = true,
|
{ label = Strings.source("BATTLE BG"), key = "battleBg", port = true,
|
||||||
values = { "white", "black" },
|
values = { "white", "black" },
|
||||||
display = { white = "WHITE", black = "BLACK" } },
|
display = { white = "WHITE", black = "BLACK" } },
|
||||||
{ label = "CANCEL", cancel = true },
|
{ label = Strings.source("CANCEL"), cancel = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- The cart's screen is one full-height textbox with every row on it. This one
|
-- The cart's screen is one full-height textbox with every row on it. This one
|
||||||
@@ -460,9 +492,9 @@ function OptionsMenu:drawPanel()
|
|||||||
local row = self.rows[i]
|
local row = self.rows[i]
|
||||||
if row then
|
if row then
|
||||||
local labelY = 2 + (slot - 1) * 2
|
local labelY = 2 + (slot - 1) * 2
|
||||||
Chrome.print(row.label, 2, labelY)
|
Chrome.print(Strings(row.label), 2, labelY)
|
||||||
if row.frame then
|
if row.frame then
|
||||||
Chrome.print(":TYPE", 10, labelY + 1)
|
Chrome.print(Strings(":TYPE"), 10, labelY + 1)
|
||||||
Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1)
|
Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1)
|
||||||
elseif row.text then
|
elseif row.text then
|
||||||
Chrome.print(":", 10, labelY + 1)
|
Chrome.print(":", 10, labelY + 1)
|
||||||
@@ -470,7 +502,7 @@ function OptionsMenu:drawPanel()
|
|||||||
elseif row.values then
|
elseif row.values then
|
||||||
Chrome.print(":", 10, labelY + 1)
|
Chrome.print(":", 10, labelY + 1)
|
||||||
local value = self.options[row.key]
|
local value = self.options[row.key]
|
||||||
local text = row.display and row.display[value] or tostring(value)
|
local text = row.display and Strings(row.display[value]) or tostring(value)
|
||||||
Chrome.print(text, 11, labelY + 1)
|
Chrome.print(text, 11, labelY + 1)
|
||||||
elseif type(row.value) == "function" then
|
elseif type(row.value) == "function" then
|
||||||
-- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row
|
-- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ local Font = require("src.render.Font")
|
|||||||
local Palettes = require("src.world.gen2.Palettes")
|
local Palettes = require("src.world.gen2.Palettes")
|
||||||
local Phone = require("src.core.gen2.Phone")
|
local Phone = require("src.core.gen2.Phone")
|
||||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||||
|
|
||||||
local Pokegear = {}
|
local Pokegear = {}
|
||||||
@@ -1959,7 +1960,7 @@ function Pokegear:drawClock()
|
|||||||
self:text(Chrome.number(display, 2), 6, 8)
|
self:text(Chrome.number(display, 2), 6, 8)
|
||||||
self:text(":", 8, 8)
|
self:text(":", 8, 8)
|
||||||
self:text(Chrome.number(minute, 2, true), 9, 8)
|
self:text(Chrome.number(minute, 2, true), 9, 8)
|
||||||
self:text(hour < 12 and "AM" or "PM", 12, 8)
|
self:text(Strings(hour < 12 and "AM" or "PM"), 12, 8)
|
||||||
|
|
||||||
-- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and
|
-- The bottom Textbox is part of the card (lb bc, 4, 18 at (0,12)), and
|
||||||
-- PokegearClock_Init prints PokegearPressButtonText straight into it
|
-- PokegearClock_Init prints PokegearPressButtonText straight into it
|
||||||
@@ -2373,7 +2374,7 @@ function Pokegear:drawPlain()
|
|||||||
if display == 0 then display = 12 end
|
if display == 0 then display = 12 end
|
||||||
Chrome.print(("%s:%s %s"):format(
|
Chrome.print(("%s:%s %s"):format(
|
||||||
Chrome.number(display, 2), Chrome.number(minute, 2, true),
|
Chrome.number(display, 2), Chrome.number(minute, 2, true),
|
||||||
hour < 12 and "AM" or "PM"), 5, 9)
|
Strings(hour < 12 and "AM" or "PM")), 5, 9)
|
||||||
Chrome.print(Clock.daytimeLabel(hour), 5, 11)
|
Chrome.print(Clock.daytimeLabel(hour), 5, 11)
|
||||||
elseif id == "radio" then
|
elseif id == "radio" then
|
||||||
-- Without the gear sheet there is no dial art, so the frequencies go down
|
-- Without the gear sheet there is no dial art, so the frequencies go down
|
||||||
|
|||||||
+54
-12
@@ -24,8 +24,10 @@
|
|||||||
-- one-line call.
|
-- one-line call.
|
||||||
|
|
||||||
local Chrome = require("src.ui.gen2.Chrome")
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
local Save = require("src.core.gen2.Save")
|
local Save = require("src.core.gen2.Save")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local SaveMenu = {}
|
local SaveMenu = {}
|
||||||
SaveMenu.__index = SaveMenu
|
SaveMenu.__index = SaveMenu
|
||||||
@@ -55,10 +57,50 @@ local TIME_X, TIME_Y = 13, 8
|
|||||||
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
|
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
|
||||||
|
|
||||||
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
|
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
|
||||||
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save.
|
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save
|
||||||
|
-- (src/ui/gen2/PcMenu.lua:savePrompt() reads these two tables' lines[1]/
|
||||||
|
-- lines[2] directly, so their shape is a cross-file contract: keep them
|
||||||
|
-- plain, untranslated tables).
|
||||||
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
|
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
|
||||||
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
|
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
|
||||||
|
|
||||||
|
-- Translatable copies of the two prompts above, one \n-joined key each, used
|
||||||
|
-- only by this screen's own prompt() below. One key per prompt lets a
|
||||||
|
-- translation write one whole, freely reordered sentence instead of two
|
||||||
|
-- fragments translated in isolation, and lets a cart whose own text is a
|
||||||
|
-- single line (German's SAVING prompt) say so directly by simply omitting
|
||||||
|
-- the "\n" -- the per-line override style used elsewhere requires a
|
||||||
|
-- non-empty value for every line, so it can't express "this line is blank".
|
||||||
|
--
|
||||||
|
-- Written as literals, not `table.concat(OVERWRITE_PROMPT, "\n")`: the
|
||||||
|
-- translation tooling's string harvester only recognizes a literal inside
|
||||||
|
-- Strings.source(...), not a computed expression, so a concat call here
|
||||||
|
-- would quietly never reach a translator. Keep byte-for-byte in sync with
|
||||||
|
-- OVERWRITE_PROMPT/SAVING_PROMPT above (checked by
|
||||||
|
-- tests/engine/gen2_save_menu_translation_test.lua).
|
||||||
|
local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it")
|
||||||
|
local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.")
|
||||||
|
|
||||||
|
-- Splits a translated "line one\nline two" string back into the two-slot
|
||||||
|
-- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a
|
||||||
|
-- single-line message, or German's one-line SAVING prompt) lands whole on
|
||||||
|
-- the first slot, matching the untranslated code's own { text, "" } shape.
|
||||||
|
--
|
||||||
|
-- Only the first "\n" splits, since this box has room for exactly two
|
||||||
|
-- lines. A third line would otherwise draw as a raw newline byte -- garbage
|
||||||
|
-- glyph data -- with no other sign anything went wrong, so this warns once
|
||||||
|
-- per string instead.
|
||||||
|
local warnedTooManyLines = {}
|
||||||
|
local function twoLines(text)
|
||||||
|
local first, second = text:match("^(.-)\n(.*)$")
|
||||||
|
if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then
|
||||||
|
warnedTooManyLines[text] = true
|
||||||
|
Logger.warn("SaveMenu: translation of %q has more than two lines; " ..
|
||||||
|
"only the first two fit this box", text)
|
||||||
|
end
|
||||||
|
return { first or text, second or "" }
|
||||||
|
end
|
||||||
|
|
||||||
function SaveMenu:wantsFillScale() return true end
|
function SaveMenu:wantsFillScale() return true end
|
||||||
function SaveMenu:drawsWidescreen() return true end
|
function SaveMenu:drawsWidescreen() return true end
|
||||||
|
|
||||||
@@ -171,18 +213,18 @@ function SaveMenu:prompt()
|
|||||||
if self.phase == "overwrite" then
|
if self.phase == "overwrite" then
|
||||||
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
|
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
|
||||||
-- when the ID differs. Only the first can happen here.
|
-- when the ID differs. Only the first can happen here.
|
||||||
return OVERWRITE_PROMPT
|
return twoLines(Strings(OVERWRITE_PROMPT_SOURCE))
|
||||||
end
|
end
|
||||||
if self.phase == "saving" then
|
if self.phase == "saving" then
|
||||||
return SAVING_PROMPT
|
return twoLines(Strings(SAVING_PROMPT_SOURCE))
|
||||||
end
|
end
|
||||||
if self.phase == "done" then
|
if self.phase == "done" then
|
||||||
if self.saved then
|
if self.saved then
|
||||||
return { self:playerName() .. " saved", "the game." }
|
return twoLines(Strings("%s saved\nthe game.", self:playerName()))
|
||||||
end
|
end
|
||||||
return { "Could not save.", "" }
|
return twoLines(Strings("Could not save."))
|
||||||
end
|
end
|
||||||
return { "Would you like to", "save the game?" }
|
return twoLines(Strings("Would you like to\nsave the game?"))
|
||||||
end
|
end
|
||||||
|
|
||||||
function SaveMenu:drawPanel()
|
function SaveMenu:drawPanel()
|
||||||
@@ -190,10 +232,10 @@ function SaveMenu:drawPanel()
|
|||||||
local summary = Save.summary(self.save)
|
local summary = Save.summary(self.save)
|
||||||
Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H)
|
Chrome.box(PANEL_X, PANEL_Y, PANEL_W, PANEL_H)
|
||||||
if summary then
|
if summary then
|
||||||
Chrome.print("PLAYER " .. summary.name, LABEL_X, LABEL_Y)
|
Chrome.print(Strings("PLAYER %s", summary.name), LABEL_X, LABEL_Y)
|
||||||
Chrome.print("BADGES", LABEL_X, LABEL_Y + 2)
|
Chrome.print(Strings("BADGES"), LABEL_X, LABEL_Y + 2)
|
||||||
Chrome.print("POKéDEX", LABEL_X, LABEL_Y + 4)
|
Chrome.print(Strings("POKéDEX"), LABEL_X, LABEL_Y + 4)
|
||||||
Chrome.print("TIME", LABEL_X, LABEL_Y + 6)
|
Chrome.print(Strings("TIME"), LABEL_X, LABEL_Y + 6)
|
||||||
-- PrintNum fills its field from the left, space padded.
|
-- PrintNum fills its field from the left, space padded.
|
||||||
Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y)
|
Chrome.print(Chrome.number(summary.badges, 2), BADGES_X, BADGES_Y)
|
||||||
Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y)
|
Chrome.print(Chrome.number(summary.caught, 3), DEX_X, DEX_Y)
|
||||||
@@ -211,8 +253,8 @@ function SaveMenu:drawPanel()
|
|||||||
|
|
||||||
if self.phase == "confirm" or self.phase == "overwrite" then
|
if self.phase == "confirm" or self.phase == "overwrite" then
|
||||||
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
||||||
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
|
Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1)
|
||||||
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
|
Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3)
|
||||||
Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3))
|
Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.choice == 1 and 1 or 3))
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
@@ -343,7 +343,9 @@ function Check.shutdown()
|
|||||||
if cmdCh then cmdCh:push({ cmd = "quit" }) end
|
if cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||||
if worker then pcall(function() worker:wait() end) end
|
if worker then pcall(function() worker:wait() end) end
|
||||||
worker, cmdCh, stateCh = nil, nil, nil
|
worker, cmdCh, stateCh = nil, nil, nil
|
||||||
workerReady = false
|
workerReady = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
require("src.core.SessionLifecycle").registerProcessShutdown(Check.shutdown)
|
||||||
|
|
||||||
return Check
|
return Check
|
||||||
|
|||||||
+15
-2
@@ -114,12 +114,25 @@ function MapLoader.invalidateAll()
|
|||||||
lru = {}
|
lru = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Eagerly release every resident map's TileRenderer GPU objects. Only safe
|
||||||
|
-- when nothing live holds map instances (session end after game:reset).
|
||||||
|
function MapLoader.releaseAll()
|
||||||
|
local ids = {}
|
||||||
|
for id in pairs(cache) do ids[#ids + 1] = id end
|
||||||
|
for _, id in ipairs(ids) do MapLoader.evict(id) end
|
||||||
|
end
|
||||||
|
|
||||||
-- kept as the pre-v2 name
|
-- kept as the pre-v2 name
|
||||||
MapLoader.clearCache = MapLoader.invalidateAll
|
MapLoader.clearCache = MapLoader.invalidateAll
|
||||||
|
|
||||||
-- the cached Map objects own the per-map TileRenderer instances, so a flush
|
-- the cached Map objects own the per-map TileRenderer instances, so a flush
|
||||||
-- that skipped this one would leave live SpriteBatches built from the old
|
-- that skipped this one would leave live SpriteBatches built from the old
|
||||||
-- search path (14 cache-invalidation contract, rows 1 and 3)
|
-- search path (14 cache-invalidation contract, rows 1 and 3). invalidateAll
|
||||||
Assets.register(MapLoader.invalidateAll)
|
-- deliberately does NOT release GPU (hot reload / live overworld); releaseAll
|
||||||
|
-- is the session-end path only.
|
||||||
|
Assets.register({
|
||||||
|
invalidate = MapLoader.invalidateAll,
|
||||||
|
release = MapLoader.releaseAll,
|
||||||
|
})
|
||||||
|
|
||||||
return MapLoader
|
return MapLoader
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function OverworldState:exit()
|
||||||
|
self.map = nil
|
||||||
|
self.neighbors = nil
|
||||||
|
end
|
||||||
|
|
||||||
function OverworldState:enter(mapId, x, y, facing, opts)
|
function OverworldState:enter(mapId, x, y, facing, opts)
|
||||||
Game = require("src.core.Game")
|
Game = require("src.core.Game")
|
||||||
Game.overworld = self
|
Game.overworld = self
|
||||||
@@ -2125,6 +2130,10 @@ function OverworldState:pushableAtCell(cx, cy)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- world.talk's fallthrough, hoisted so the A press does not build a closure
|
||||||
|
-- on every press just to have one to hand a hook nobody may have wrapped
|
||||||
|
local function vanillaTalk(ow, target) ow:talkTo(target) end
|
||||||
|
|
||||||
-- what the A press resolved to, for world.interacted's listeners
|
-- what the A press resolved to, for world.interacted's listeners
|
||||||
local function interacted(self, fx, fy, kind, target)
|
local function interacted(self, fx, fy, kind, target)
|
||||||
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
|
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
|
||||||
@@ -2154,7 +2163,12 @@ function OverworldState:interact()
|
|||||||
-- talk() lands the follower on its cell first.
|
-- talk() lands the follower on its cell first.
|
||||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||||
elseif not npc.moving then
|
elseif not npc.moving then
|
||||||
self:talkTo(npc)
|
-- world.talk: the A press on an object, before the map's text tables
|
||||||
|
-- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries
|
||||||
|
-- no TEXT_* id, so the vanilla path has nothing to say for it; a mod
|
||||||
|
-- that owns the object wraps this and simply does not call next().
|
||||||
|
-- Everything else falls straight through to talkTo as before.
|
||||||
|
Runtime.call("world.talk", vanillaTalk, self, npc)
|
||||||
end
|
end
|
||||||
interacted(self, fx, fy, "npc", npc)
|
interacted(self, fx, fy, "npc", npc)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-- quiet no-op, never a crash. Reaching into OverworldState internals
|
-- quiet no-op, never a crash. Reaching into OverworldState internals
|
||||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||||
|
|
||||||
|
local Collision = require("src.world.Collision")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
@@ -117,6 +118,47 @@ function WorldAPI:current()
|
|||||||
facing = p and p.facing }
|
facing = p and p.facing }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function validBlockCoordinate(value)
|
||||||
|
return type(value) == "number" and value == value
|
||||||
|
and value ~= math.huge and value ~= -math.huge
|
||||||
|
and value == math.floor(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Read one block from the active Gen 1 map without exposing the mutable block
|
||||||
|
-- array. Requiring the expected map id makes a stale signature fail closed
|
||||||
|
-- if a warp or reload moved the player before the caller completed its check.
|
||||||
|
-- Block coordinates are zero-based, matching replaceBlock.
|
||||||
|
function WorldAPI:activeBlockAt(mapId, bx, by)
|
||||||
|
local ow = self:overworld()
|
||||||
|
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||||
|
local map = ow.map
|
||||||
|
if map.id ~= mapId then return nil, "map is not active" end
|
||||||
|
if not validBlockCoordinate(bx) or not validBlockCoordinate(by) then
|
||||||
|
return nil, "invalid block coordinates"
|
||||||
|
end
|
||||||
|
local def = map.def
|
||||||
|
if not def or not validBlockCoordinate(def.width) or def.width <= 0
|
||||||
|
or not validBlockCoordinate(def.height) or def.height <= 0 then
|
||||||
|
return nil, "block unavailable"
|
||||||
|
end
|
||||||
|
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||||
|
return nil, "block coordinates out of bounds"
|
||||||
|
end
|
||||||
|
if type(def.blocks) ~= "table" or type(map.blockAt) ~= "function" then
|
||||||
|
return nil, "block unavailable"
|
||||||
|
end
|
||||||
|
local stored = def.blocks[by * def.width + bx + 1]
|
||||||
|
if not validBlockCoordinate(stored) or stored < 0 then
|
||||||
|
return nil, "block unavailable"
|
||||||
|
end
|
||||||
|
local ok, blockId = pcall(map.blockAt, map, bx, by)
|
||||||
|
if not ok or not validBlockCoordinate(blockId) or blockId < 0
|
||||||
|
or blockId ~= stored then
|
||||||
|
return nil, "block unavailable"
|
||||||
|
end
|
||||||
|
return blockId
|
||||||
|
end
|
||||||
|
|
||||||
-- Companion UIs may offer party ordering while the player is in free roam.
|
-- Companion UIs may offer party ordering while the player is in free roam.
|
||||||
-- The same guard that makes opening a menu safe keeps scripts, transitions,
|
-- The same guard that makes opening a menu safe keeps scripts, transitions,
|
||||||
-- movement and screens above the overworld from observing a mid-action swap.
|
-- movement and screens above the overworld from observing a mid-action swap.
|
||||||
@@ -407,6 +449,72 @@ function Handle:position()
|
|||||||
return self.npc.cellX, self.npc.cellY
|
return self.npc.cellX, self.npc.cellY
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Walk one tile starting NOW, outside the scripted-movement queue.
|
||||||
|
--
|
||||||
|
-- scriptMove queues onto OverworldState.scriptMoves, and a non-empty
|
||||||
|
-- scriptMoves is how the overworld knows a cutscene is running -- it gates
|
||||||
|
-- handleInput (OverworldController "local scripted = ... #self.scriptMoves >
|
||||||
|
-- 0"), so an actor animated that way freezes the player's controls for as
|
||||||
|
-- long as it walks. That is right for Oak marching to his lab and wrong for
|
||||||
|
-- an actor that moves on its own schedule: a networked player's ghost, an
|
||||||
|
-- ambient walker. This is the same per-tile state scriptMove sets, minus
|
||||||
|
-- the queue and therefore minus the lockout.
|
||||||
|
--
|
||||||
|
-- Collision is deliberately not checked. The caller is replaying a move
|
||||||
|
-- that was already decided somewhere else (validated on the peer's machine,
|
||||||
|
-- or authored), and re-judging it here would let the two copies disagree
|
||||||
|
-- about where the actor is. Use canStep first if you want the check.
|
||||||
|
function Handle:stepNow(dir)
|
||||||
|
local npc = self.npc
|
||||||
|
if not Collision.DELTA[dir] then return nil, "bad direction: " .. tostring(dir) end
|
||||||
|
if npc.moving then return nil, "already moving" end
|
||||||
|
npc.facing = dir
|
||||||
|
npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, dir)
|
||||||
|
npc.moving = true
|
||||||
|
npc.progress = 0
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Would stepNow land somewhere legal? Exposed separately so a caller that
|
||||||
|
-- does want the map's opinion can ask for it without giving up the "replay
|
||||||
|
-- verbatim" default above.
|
||||||
|
function Handle:canStep(dir)
|
||||||
|
local ow = self.ow
|
||||||
|
if not (ow and ow.map) then return false end
|
||||||
|
return Collision.canMove(ow.map, ow.entities, self.npc, dir) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Snap to a cell with no animation: a warp arrival, or a resync that has
|
||||||
|
-- drifted too far to walk off. Clears any step in flight so the entity
|
||||||
|
-- cannot land on its old target a frame later.
|
||||||
|
function Handle:placeAt(x, y, facing)
|
||||||
|
local npc = self.npc
|
||||||
|
npc.moving = false
|
||||||
|
npc.marching = false
|
||||||
|
npc.targetX, npc.targetY = nil, nil
|
||||||
|
npc.progress = 0
|
||||||
|
npc.cellX, npc.cellY = x, y
|
||||||
|
npc.px, npc.py = x * 16, y * 16
|
||||||
|
if facing then npc.facing = facing end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- True while a step is still animating, so a driver can pace itself rather
|
||||||
|
-- than stomping a move in flight.
|
||||||
|
function Handle:isMoving()
|
||||||
|
return self.npc.moving and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether the player may walk through this object (Collision.occupied skips
|
||||||
|
-- passable entities -- Yellow's companion Pikachu is the engine's own user
|
||||||
|
-- of the flag). It still draws and can still be talked to; it just stops
|
||||||
|
-- being an obstacle, which is what a dynamic actor wants when standing in a
|
||||||
|
-- doorway would otherwise wall someone in.
|
||||||
|
function Handle:setPassable(passable)
|
||||||
|
self.npc.passable = passable and true or false
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function WorldAPI:npc(mapId, indexOrName)
|
function WorldAPI:npc(mapId, indexOrName)
|
||||||
local ow = self:overworld()
|
local ow = self:overworld()
|
||||||
if not ow then return nil, NO_OVERWORLD end
|
if not ow then return nil, NO_OVERWORLD end
|
||||||
|
|||||||
@@ -5470,6 +5470,37 @@ function World:dropMapImages(mapId)
|
|||||||
if self.connectionMaps then self.connectionMaps[mapId] = nil end
|
if self.connectionMaps then self.connectionMaps[mapId] = nil end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function safeRelease(obj)
|
||||||
|
if obj and obj ~= false and obj.release then pcall(obj.release, obj) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Eagerly free session-owned GPU caches. Assets.image-backed atlases are
|
||||||
|
-- nilled without release; unique bakes, strips, and roof composites are released.
|
||||||
|
function World:release()
|
||||||
|
if self.mapImages then
|
||||||
|
for _, img in pairs(self.mapImages) do safeRelease(img) end
|
||||||
|
self.mapImages = {}
|
||||||
|
end
|
||||||
|
if self.scrollStrips then
|
||||||
|
for _, strip in pairs(self.scrollStrips) do safeRelease(strip) end
|
||||||
|
self.scrollStrips = {}
|
||||||
|
end
|
||||||
|
safeRelease(self.tiltCanvas)
|
||||||
|
self.tiltCanvas = nil
|
||||||
|
if self.grassAtlases then
|
||||||
|
for _, atlas in pairs(self.grassAtlases) do safeRelease(atlas) end
|
||||||
|
self.grassAtlases = {}
|
||||||
|
end
|
||||||
|
if self.atlasCache then
|
||||||
|
for key, atlas in pairs(self.atlasCache) do
|
||||||
|
if key:find("|", 1, true) then safeRelease(atlas) end
|
||||||
|
end
|
||||||
|
self.atlasCache = {}
|
||||||
|
end
|
||||||
|
self.animQuads = nil
|
||||||
|
self.connectionMaps = nil
|
||||||
|
end
|
||||||
|
|
||||||
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
|
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
|
||||||
-- strips share the same buffer on the cart, so a connection crossing reloads
|
-- strips share the same buffer on the cart, so a connection crossing reloads
|
||||||
-- them too: this runs on any setMap, seamless or not.
|
-- them too: this runs on any setMap, seamless or not.
|
||||||
|
|||||||
@@ -88,8 +88,13 @@ check(destroy < destroyTeardown and destroyTeardown < destroySuper,
|
|||||||
local mainFile = assert(io.open("main.lua", "rb"))
|
local mainFile = assert(io.open("main.lua", "rb"))
|
||||||
local main = mainFile:read("*a")
|
local main = mainFile:read("*a")
|
||||||
mainFile:close()
|
mainFile:close()
|
||||||
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
check(main:find("SessionLifecycle.endGameSession", 1, true),
|
||||||
"returning from a game disables mod-owned secondary output")
|
"returning from a game goes through SessionLifecycle.endGameSession")
|
||||||
|
local lifecycleFile = assert(io.open("src/core/SessionLifecycle.lua", "rb"))
|
||||||
|
local lifecycle = lifecycleFile:read("*a")
|
||||||
|
lifecycleFile:close()
|
||||||
|
check(lifecycle:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||||
|
"endGameSession disables mod-owned secondary output")
|
||||||
|
|
||||||
check(not source:lower():find("openxr", 1, true),
|
check(not source:lower():find("openxr", 1, true),
|
||||||
"generic Android activity must not require OpenXR")
|
"generic Android activity must not require OpenXR")
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
-- Validation and fail-closed behavior for battle.field_residual descriptors.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Checkpoint = require("src.core.Checkpoint")
|
||||||
|
local Events = require("src.mods.Events")
|
||||||
|
local GameMethods = require("src.core.Game")
|
||||||
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
|
||||||
|
local data = T.fixtures.fresh()
|
||||||
|
TypeChart.load(data)
|
||||||
|
|
||||||
|
local function newBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||||
|
local game = {
|
||||||
|
data = data,
|
||||||
|
save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end },
|
||||||
|
}
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||||
|
battle.phase, battle.queue = "menu", {}
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local savedEvents, savedHooks, savedErrors = Runtime.events, Runtime.hooks,
|
||||||
|
Runtime.errors
|
||||||
|
local hooks = Hooks.new()
|
||||||
|
Runtime.install(savedEvents, hooks, {})
|
||||||
|
|
||||||
|
local battle = newBattle()
|
||||||
|
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
|
||||||
|
local playerType = battle.player.curTypes[1]
|
||||||
|
local cyclic = { label = "cycle-data" }
|
||||||
|
cyclic.self = cyclic
|
||||||
|
local metatableData = setmetatable({ label = "plain-data" }, {
|
||||||
|
__index = { hidden = "metatable-data" },
|
||||||
|
})
|
||||||
|
battle.field.weather = {
|
||||||
|
id = "probe", turns = 3,
|
||||||
|
callback = function() end,
|
||||||
|
handle = io.stdout,
|
||||||
|
worker = coroutine.create(function() end),
|
||||||
|
cyclic = cyclic,
|
||||||
|
metatableData = metatableData,
|
||||||
|
}
|
||||||
|
battle.field.tokens[1] = { id = "nested", turns = 2,
|
||||||
|
state = { intensity = 4 } }
|
||||||
|
battle:enter()
|
||||||
|
hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local vanilla = next(context)
|
||||||
|
T.same(vanilla, {}, "the vanilla contribution is an empty descriptor list")
|
||||||
|
context.battlers.player.hp = 0
|
||||||
|
context.battlers.player.types[1] = "MUTATED"
|
||||||
|
T.eq(context.field.sides, nil,
|
||||||
|
"the field view has the checkpoint shape and no live side graph")
|
||||||
|
T.eq(context.field.weather.callback, nil,
|
||||||
|
"the field view omits executable values")
|
||||||
|
T.eq(context.field.weather.handle, nil,
|
||||||
|
"the field view omits userdata")
|
||||||
|
T.eq(context.field.weather.worker, nil,
|
||||||
|
"the field view omits threads")
|
||||||
|
T.eq(context.field.weather.cyclic.label, "cycle-data",
|
||||||
|
"the field view retains scalar data around a cycle")
|
||||||
|
T.eq(context.field.weather.cyclic.self, nil,
|
||||||
|
"the field view omits cyclic edges")
|
||||||
|
T.eq(getmetatable(context.field.weather.metatableData), nil,
|
||||||
|
"the field view carries no metatable")
|
||||||
|
T.eq(context.field.weather.metatableData.label, "plain-data",
|
||||||
|
"the field view retains raw data from a metatable-bearing table")
|
||||||
|
T.eq(context.field.weather.metatableData.hidden, nil,
|
||||||
|
"the field view does not expose metatable-provided values")
|
||||||
|
context.field.weather.turns = 0
|
||||||
|
context.field.tokens[1].state.intensity = 99
|
||||||
|
return {
|
||||||
|
false,
|
||||||
|
{ side = "unknown", amount = 20, message = "invalid side" },
|
||||||
|
{ side = "player", amount = "3", message = "numeric string" },
|
||||||
|
{ side = "player", amount = 0, message = "zero" },
|
||||||
|
{ side = "player", amount = -2, message = "negative" },
|
||||||
|
{ side = "player", amount = 1.5, message = "fractional" },
|
||||||
|
{ side = "player", amount = "not a number", message = "bad amount" },
|
||||||
|
{ side = "player", amount = 0 / 0, message = "not finite" },
|
||||||
|
{ side = "player", amount = math.huge, message = "non-finite" },
|
||||||
|
{ side = "player", amount = 2, message = function() end },
|
||||||
|
{ side = "enemy", amount = 3 },
|
||||||
|
}
|
||||||
|
end, 0, "validation_probe")
|
||||||
|
|
||||||
|
battle:applyFieldResiduals()
|
||||||
|
T.eq(battle.player.mon.hp, playerHp,
|
||||||
|
"a descriptor with a non-string message fails closed")
|
||||||
|
T.eq(battle.enemy.mon.hp, enemyHp - 3,
|
||||||
|
"a valid descriptor may omit its message")
|
||||||
|
T.eq(battle.player.curTypes[1], playerType,
|
||||||
|
"mutating the detached type view cannot mutate the live battler")
|
||||||
|
T.check(battle.player.mon.hp ~= 0,
|
||||||
|
"mutating detached HP cannot replace engine damage authority")
|
||||||
|
T.eq(battle.field.weather.turns, 3,
|
||||||
|
"mutating the detached weather view cannot mutate live field state")
|
||||||
|
T.eq(battle.field.tokens[1].state.intensity, 4,
|
||||||
|
"mutating nested detached token state cannot mutate live field state")
|
||||||
|
|
||||||
|
local guarded = newBattle()
|
||||||
|
local fieldReads, runtimeCalls = 0, 0
|
||||||
|
guarded.field = setmetatable({}, { __index = function()
|
||||||
|
fieldReads = fieldReads + 1
|
||||||
|
return nil
|
||||||
|
end })
|
||||||
|
local realRuntimeCall = Runtime.call
|
||||||
|
Runtime.call = function(...)
|
||||||
|
runtimeCalls = runtimeCalls + 1
|
||||||
|
return realRuntimeCall(...)
|
||||||
|
end
|
||||||
|
Runtime.install(savedEvents, Hooks.new(), {})
|
||||||
|
guarded:applyFieldResiduals()
|
||||||
|
Runtime.call = realRuntimeCall
|
||||||
|
T.eq(runtimeCalls, 0,
|
||||||
|
"a disabled field hook never enters Runtime.call")
|
||||||
|
T.eq(fieldReads, 0,
|
||||||
|
"a disabled field hook does not construct its field context")
|
||||||
|
|
||||||
|
local nilBattle = newBattle()
|
||||||
|
local nilHp = nilBattle.player.mon.hp
|
||||||
|
local nilHooks = Hooks.new()
|
||||||
|
Runtime.install(savedEvents, nilHooks, {})
|
||||||
|
nilHooks:wrap("battle.field_residual", function() return nil end,
|
||||||
|
0, "nil_probe")
|
||||||
|
nilBattle:applyFieldResiduals()
|
||||||
|
T.eq(nilBattle.player.mon.hp, nilHp,
|
||||||
|
"a non-table hook result fails closed")
|
||||||
|
|
||||||
|
local settled = newBattle()
|
||||||
|
local settledCalls = 0
|
||||||
|
local settledHooks = Hooks.new()
|
||||||
|
Runtime.install(savedEvents, settledHooks, {})
|
||||||
|
settledHooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
settledCalls = settledCalls + 1
|
||||||
|
return next(context)
|
||||||
|
end, 0, "settled_probe")
|
||||||
|
settled.result = "win"
|
||||||
|
settled:endOfTurn()
|
||||||
|
T.eq(settledCalls, 0,
|
||||||
|
"a settled battle never invokes field residual policy")
|
||||||
|
|
||||||
|
local function drainQueue(battle)
|
||||||
|
local rows, guard = {}, 0
|
||||||
|
while battle.queue[1] and guard < 1000 do
|
||||||
|
guard = guard + 1
|
||||||
|
local row = table.remove(battle.queue, 1)
|
||||||
|
rows[#rows + 1] = row
|
||||||
|
if row.fn then
|
||||||
|
battle.nextInsert = 0
|
||||||
|
row.fn()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
T.check(guard < 1000, "the simultaneous-faint queue completes")
|
||||||
|
return rows
|
||||||
|
end
|
||||||
|
|
||||||
|
local function simultaneousTerminal(order)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||||
|
local game = {
|
||||||
|
data = data,
|
||||||
|
save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end },
|
||||||
|
}
|
||||||
|
local double = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||||
|
double.phase, double.queue = "menu", {}
|
||||||
|
local originalEnemy, originalEnemyIndex = double.enemy, double.enemyIndex
|
||||||
|
local startingExp = double.player.mon.exp
|
||||||
|
local events, doubleHooks = Events.new(), Hooks.new()
|
||||||
|
local awardCalls, expEvents, switches = 0, 0, 0
|
||||||
|
events:on("battle.exp_gained", function() expEvents = expEvents + 1 end,
|
||||||
|
0, "double_probe")
|
||||||
|
events:on("battle.battler_switched", function() switches = switches + 1 end,
|
||||||
|
0, "double_probe")
|
||||||
|
Runtime.install(events, doubleHooks, {})
|
||||||
|
doubleHooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
local rows = next(context)
|
||||||
|
for _, side in ipairs(order) do
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = side,
|
||||||
|
amount = context.battlers[side].hp,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return rows
|
||||||
|
end, 0, "double_probe")
|
||||||
|
doubleHooks:wrap("battle.exp_award", function(next, context)
|
||||||
|
awardCalls = awardCalls + 1
|
||||||
|
return next(context)
|
||||||
|
end, 0, "double_probe")
|
||||||
|
double:endOfTurn()
|
||||||
|
local queued = drainQueue(double)
|
||||||
|
T.eq(double.player.mon.hp, 0,
|
||||||
|
"simultaneous residuals settle the player side")
|
||||||
|
T.eq(double.enemy.mon.hp, 0,
|
||||||
|
"simultaneous residuals settle the enemy side")
|
||||||
|
T.eq(double.result, "lose",
|
||||||
|
"a simultaneous terminal residual resolves as player blackout")
|
||||||
|
T.eq(double.afterQueue, "finish",
|
||||||
|
"the completed simultaneous-faint queue closes the battle")
|
||||||
|
T.eq(double.player.faintQueued, true,
|
||||||
|
"the terminal hook batch queues player faint authority")
|
||||||
|
T.eq(double.enemy.faintQueued, nil,
|
||||||
|
"the terminal hook batch suppresses only its enemy faint authority")
|
||||||
|
T.eq(double.player.mon.exp, startingExp,
|
||||||
|
"a blackout does not award contradictory enemy-faint EXP")
|
||||||
|
T.eq(awardCalls, 0,
|
||||||
|
"a blackout never enters the enemy EXP-award policy")
|
||||||
|
T.eq(expEvents, 0,
|
||||||
|
"a blackout emits no contradictory EXP event")
|
||||||
|
T.eq(switches, 0,
|
||||||
|
"a blackout does not send the trainer's reserve into battle")
|
||||||
|
T.eq(double.enemyIndex, originalEnemyIndex,
|
||||||
|
"a blackout leaves the enemy roster position unchanged")
|
||||||
|
T.check(double.enemy == originalEnemy,
|
||||||
|
"a blackout queues no contradictory enemy replacement")
|
||||||
|
for _, row in ipairs(queued) do
|
||||||
|
T.eq(row.ui, nil,
|
||||||
|
"a simultaneous terminal residual queues no replacement UI")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
simultaneousTerminal({ "player", "enemy" })
|
||||||
|
simultaneousTerminal({ "enemy", "player" })
|
||||||
|
|
||||||
|
local timing = newBattle()
|
||||||
|
local timingOrder = {}
|
||||||
|
timing.ruleset = require("src.battle.rulesets.modern_clean")
|
||||||
|
timing.player.mon.status = "PSN"
|
||||||
|
timing.field.tokens[1] = { id = "expires", turns = 1,
|
||||||
|
onExpire = function() timingOrder[#timingOrder + 1] = "token_expired" end }
|
||||||
|
local timingEvents, timingHooks = Events.new(), Hooks.new()
|
||||||
|
timingEvents:on("battle.turn_ended", function()
|
||||||
|
timingOrder[#timingOrder + 1] = "turn_ended"
|
||||||
|
end, 0, "timing_probe")
|
||||||
|
Runtime.install(timingEvents, timingHooks, {})
|
||||||
|
local preStatusHp = timing.player.mon.hp
|
||||||
|
timingHooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
timingOrder[#timingOrder + 1] = "field_residual"
|
||||||
|
T.check(context.battlers.player.hp < preStatusHp,
|
||||||
|
"the hook snapshot observes completed vanilla status residuals")
|
||||||
|
return next(context)
|
||||||
|
end, 0, "timing_probe")
|
||||||
|
timing:endOfTurn()
|
||||||
|
T.same(timingOrder,
|
||||||
|
{ "field_residual", "token_expired", "turn_ended" },
|
||||||
|
"the hook runs before token expiry and battle.turn_ended")
|
||||||
|
|
||||||
|
local oldGetState, oldSetState = love.math.getRandomState,
|
||||||
|
love.math.setRandomState
|
||||||
|
local checkpointRng = "field-residual-rng"
|
||||||
|
love.math.getRandomState = function() return checkpointRng end
|
||||||
|
love.math.setRandomState = function(state) checkpointRng = state end
|
||||||
|
|
||||||
|
local function checkpointBattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.meta.playthroughId = "field-residual-checkpoint"
|
||||||
|
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||||
|
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(restored, origin)
|
||||||
|
restored.onFinish = function() end
|
||||||
|
return origin.kind == "wild_encounter" and origin.map == self.map.id
|
||||||
|
end
|
||||||
|
local game = setmetatable({ data = data, save = save, stack = stack,
|
||||||
|
overworld = overworld }, { __index = GameMethods })
|
||||||
|
stack.states[1] = overworld
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||||
|
battle.phase, battle.queue = "menu", {}
|
||||||
|
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
|
||||||
|
battle.musicKind = battle:computeMusicKind()
|
||||||
|
battle.onFinish = function() end
|
||||||
|
battle.field.weather = { id = "checkpoint-weather", turns = 5 }
|
||||||
|
stack.states[2] = battle
|
||||||
|
return game, battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local checkpointGame = checkpointBattle()
|
||||||
|
local checkpointHooks, restoredCalls = Hooks.new(), 0
|
||||||
|
Runtime.install(Events.new(), checkpointHooks, {})
|
||||||
|
checkpointHooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
restoredCalls = restoredCalls + 1
|
||||||
|
T.same(context.field.weather,
|
||||||
|
{ id = "checkpoint-weather", turns = 5 },
|
||||||
|
"the enabled hook observes checkpointed field state after restore")
|
||||||
|
return next(context)
|
||||||
|
end, 0, "checkpoint_probe")
|
||||||
|
local snapshot, captureCode = Checkpoint.capture(checkpointGame)
|
||||||
|
T.check(snapshot ~= nil,
|
||||||
|
"an enabled process-local hook does not enter the checkpoint: "
|
||||||
|
.. tostring(captureCode))
|
||||||
|
if snapshot then
|
||||||
|
checkpointGame.stack:top().field.weather.turns = 1
|
||||||
|
local restored, restoreCode, restoreMessage =
|
||||||
|
Checkpoint.restore(checkpointGame, snapshot)
|
||||||
|
T.check(restored == true,
|
||||||
|
"field state reconstructs while the hook remains enabled: "
|
||||||
|
.. tostring(restoreCode or restoreMessage))
|
||||||
|
if restored then checkpointGame.stack:top():applyFieldResiduals() end
|
||||||
|
if restored then
|
||||||
|
T.same(Checkpoint.capture(checkpointGame), snapshot,
|
||||||
|
"enabled-hook field state completes capture/restore/capture round-trip")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
T.eq(restoredCalls, 1,
|
||||||
|
"the process-local hook still runs after checkpoint reconstruction")
|
||||||
|
love.math.getRandomState, love.math.setRandomState = oldGetState, oldSetState
|
||||||
|
|
||||||
|
Runtime.install(savedEvents, savedHooks, savedErrors)
|
||||||
|
T.finish("battle.field_residual validation")
|
||||||
@@ -100,8 +100,8 @@ local function probe(version)
|
|||||||
end
|
end
|
||||||
return { type = "file" }
|
return { type = "file" }
|
||||||
end
|
end
|
||||||
love.filesystem.getRealDirectory = function() return "/nowhere" end
|
love.filesystem.getRealDirectory = function() return "/source" end
|
||||||
love.filesystem.getSource = function() return "/elsewhere" end
|
love.filesystem.getSource = function() return "/source" end
|
||||||
RomImporter.isReady(version)
|
RomImporter.isReady(version)
|
||||||
return seen, order
|
return seen, order
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
-- Chrome.printThrough (src/ui/gen2/Chrome.lua) used to run every string
|
||||||
|
-- through the GbcPalette shade-remap shader whenever a palette was given,
|
||||||
|
-- with no regard for whether the glyph it was about to draw came from a
|
||||||
|
-- tile page or from a TTF. The shader recovers a shade by reading the
|
||||||
|
-- RED CHANNEL of an already-rasterized 2bpp tile pixel (SHADER_SOURCE in
|
||||||
|
-- src/render/GbcPalette.lua); a TTF glyph is LÖVE's own anti-aliased
|
||||||
|
-- coverage mask, drawn as plain white with the current tint carrying the
|
||||||
|
-- ink colour, which that same channel read always reports as shade 0 --
|
||||||
|
-- painting every character the SAME colour as the paper rect printThrough
|
||||||
|
-- had just drawn behind it, i.e. invisible. Reported against a real Gold
|
||||||
|
-- build running a TTF translation mod: the naming screen's keyboard,
|
||||||
|
-- Diploma and Pokegear text all vanish, since all three draw through this
|
||||||
|
-- one routine (gen1recomp#1642).
|
||||||
|
--
|
||||||
|
-- The switch is per GLYPH, not per string: a TTF-mod build still keeps
|
||||||
|
-- multi-byte charmap sequences (the naming screen's own <PK>/<MN> cells,
|
||||||
|
-- the 'd/'l/'s ligatures) and anything a mod names in ttf.tiles on their ROM
|
||||||
|
-- tiles (src/render/Font.lua's Font.split), so one call can mix both kinds
|
||||||
|
-- of glyph and each must take its own path.
|
||||||
|
--
|
||||||
|
-- No real shader runs headless (love_stub does not stub newShader), so this
|
||||||
|
-- cannot check a rendered pixel. Font.encode/drawCode/advanceOf/width are
|
||||||
|
-- replaced with fakes that hand printThrough a fixed list of glyph codes,
|
||||||
|
-- so what is checked is the two things that decide the outcome: which
|
||||||
|
-- glyphs skip the shader, and what colour is active when each one draws.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
|
||||||
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
|
||||||
|
-- Palette arrays are 1-indexed shade 0..3, same order GbcPalette.useRaw
|
||||||
|
-- reads (src/render/GbcPalette.lua channel()).
|
||||||
|
local PALETTE = { { 200, 220, 255 }, { 150, 170, 220 }, { 90, 100, 160 }, { 10, 10, 30 } }
|
||||||
|
|
||||||
|
local TILE_CODE = 0x80
|
||||||
|
local TTF_CODE = Font.TTF_BASE + 65 -- 'A', were it decoded
|
||||||
|
|
||||||
|
-- "text" is a plain list of glyph codes for this suite; Font.width/encode
|
||||||
|
-- pass it straight through, matching how a real caller's string would
|
||||||
|
-- decode to a list of codes.
|
||||||
|
Font.width = function(codes) return #codes * 8 end
|
||||||
|
Font.encode = function(codes) return codes end
|
||||||
|
Font.advanceOf = function(_code) return 8 end
|
||||||
|
|
||||||
|
local drawn
|
||||||
|
Font.drawCode = function(code, x, y)
|
||||||
|
drawn[#drawn + 1] = { code = code, x = x, y = y, color = { love.graphics.getColor() } }
|
||||||
|
end
|
||||||
|
|
||||||
|
local useRawCalls
|
||||||
|
local realUseRaw = GbcPalette.useRaw
|
||||||
|
GbcPalette.useRaw = function(...)
|
||||||
|
useRawCalls = useRawCalls + 1
|
||||||
|
return realUseRaw(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
GbcPalette.available = function() return true end
|
||||||
|
|
||||||
|
local function colorsEq(a, b)
|
||||||
|
return math.abs(a[1] - b[1]) < 1e-9 and math.abs(a[2] - b[2]) < 1e-9
|
||||||
|
and math.abs(a[3] - b[3]) < 1e-9
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------- all tile glyphs
|
||||||
|
do
|
||||||
|
useRawCalls = 0
|
||||||
|
drawn = {}
|
||||||
|
Chrome.printThrough({ TILE_CODE, TILE_CODE }, 0, 0, PALETTE)
|
||||||
|
T.eq(useRawCalls, 1, "one shaded run binds the shader once, not per glyph")
|
||||||
|
T.eq(#drawn, 2, "both glyphs drew")
|
||||||
|
for i, d in ipairs(drawn) do
|
||||||
|
T.check(colorsEq(d.color, { 1, 1, 1, 1 }),
|
||||||
|
("tile glyph %d is tinted white, letting the shader pick the colour"):format(i))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -------------------------------------------------------- all TTF glyphs
|
||||||
|
do
|
||||||
|
useRawCalls = 0
|
||||||
|
drawn = {}
|
||||||
|
Chrome.printThrough({ TTF_CODE, TTF_CODE }, 0, 0, PALETTE)
|
||||||
|
T.eq(useRawCalls, 0, "a TTF glyph never binds the shade-remap shader")
|
||||||
|
T.eq(#drawn, 2, "both glyphs drew")
|
||||||
|
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||||
|
for i, d in ipairs(drawn) do
|
||||||
|
T.check(colorsEq(d.color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||||
|
("TTF glyph %d is tinted with the palette's own ink colour"):format(i))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --------------------------------------------- mixed: tile, TTF, then tile
|
||||||
|
do
|
||||||
|
useRawCalls = 0
|
||||||
|
drawn = {}
|
||||||
|
Chrome.printThrough({ TILE_CODE, TTF_CODE, TILE_CODE }, 0, 0, PALETTE)
|
||||||
|
T.eq(useRawCalls, 2,
|
||||||
|
"the shader re-binds once per return to a tile glyph, not once for the whole string")
|
||||||
|
T.eq(#drawn, 3, "all three glyphs drew")
|
||||||
|
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||||
|
T.check(colorsEq(drawn[1].color, { 1, 1, 1, 1 }), "1st (tile) glyph: white/shaded")
|
||||||
|
T.check(colorsEq(drawn[2].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||||
|
"2nd (TTF) glyph, mid-string, still gets the ink tint")
|
||||||
|
T.check(colorsEq(drawn[3].color, { 1, 1, 1, 1 }), "3rd (tile) glyph: shaded again")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------- inverted TTF ink
|
||||||
|
do
|
||||||
|
drawn = {}
|
||||||
|
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE, true)
|
||||||
|
local ink = Chrome.throughPalette(PALETTE, true)[4]
|
||||||
|
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||||
|
"an inverted call tints TTF ink with the inverted palette's own shade-3 entry")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------- DMG mode's own TTF ink
|
||||||
|
do
|
||||||
|
GbcPalette.setMode("dmg")
|
||||||
|
drawn = {}
|
||||||
|
Chrome.printThrough({ TTF_CODE }, 0, 0, PALETTE)
|
||||||
|
local ink = Chrome.throughPalette(PALETTE, false)[4]
|
||||||
|
T.check(colorsEq(drawn[1].color, { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 }),
|
||||||
|
"DMG mode's own resolved palette (four grey hardware shades) still reaches the TTF ink")
|
||||||
|
GbcPalette.setMode("gbc")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2_chrome_print_through_ttf_test")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
-- Gold's title/main menu (src/ui/gen2/MainMenu.lua) drew every row label
|
||||||
|
-- (CONTINUE/NEW GAME/OPTION/EXIT GAME), the clock box's AM/PM half, and the
|
||||||
|
-- CONTINUE save-summary panel's labels (PLAYER <name>/BADGES/POKéDEX/TIME,
|
||||||
|
-- or NO SAVE FILE) as bare literals, invisible to a translation mod's
|
||||||
|
-- `strings` registry -- unlike the Gen 1 port's own title menu
|
||||||
|
-- (src/ui/TitleState.lua/StartMenu.lua), which already routes the same rows
|
||||||
|
-- through Strings(). Drives MainMenu:drawPanel()/:drawSavePanel() with a
|
||||||
|
-- mod-loaded Strings catalog and checks the translated text reaches
|
||||||
|
-- Font.draw, same technique as
|
||||||
|
-- tests/engine/gen2_naming_screen_translation_test.lua.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
|
||||||
|
local drawn
|
||||||
|
package.loaded["src.render.Font"] = {
|
||||||
|
draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||||
|
end,
|
||||||
|
drawCode = function() end,
|
||||||
|
drawBox = function() end,
|
||||||
|
width = function() return 0 end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
|
local function drawnAt(x, y)
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.x == x and d.y == y then return d.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
|
||||||
|
-- List item 1 lands at (self.x, self.y) = (2, 2); the clock box's day name
|
||||||
|
-- at (1, 14) and the hour:minute half at (4, 16); the save panel's PLAYER
|
||||||
|
-- row at (5, 2).
|
||||||
|
local FIRST_ITEM_X, FIRST_ITEM_Y = 2 * 8, 2 * 8
|
||||||
|
local CLOCK_HALF_X, CLOCK_HALF_Y = 4 * 8, 16 * 8
|
||||||
|
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
|
||||||
|
|
||||||
|
local SAVE = { player = { name = "GOLD" } }
|
||||||
|
local CLOCK = { hour = 13, minute = 5, weekday = 1 } -- 1 PM, SUNDAY
|
||||||
|
|
||||||
|
-- ---------------------------------------------- vanilla: no mod catalog
|
||||||
|
do
|
||||||
|
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUE",
|
||||||
|
"the title menu's first row draws in English with no mod loaded")
|
||||||
|
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM",
|
||||||
|
"and the clock box's AM/PM half")
|
||||||
|
|
||||||
|
drawn = {}
|
||||||
|
menu:drawSavePanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
|
||||||
|
"the CONTINUE save-summary panel too")
|
||||||
|
|
||||||
|
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
|
||||||
|
drawn = {}
|
||||||
|
noSaveMenu:drawSavePanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "NO SAVE FILE",
|
||||||
|
"and its no-summary fallback")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- a translation mod's turn
|
||||||
|
do
|
||||||
|
Strings.load({
|
||||||
|
strings = {
|
||||||
|
["CONTINUE"] = "CONTINUAR",
|
||||||
|
["NEW GAME"] = "NUEVA PARTIDA",
|
||||||
|
["OPTION"] = "OPCIÓN",
|
||||||
|
["EXIT GAME"] = "SALIR",
|
||||||
|
["PM"] = "PM_ES",
|
||||||
|
["PLAYER %s"] = "JUGADOR %s",
|
||||||
|
["BADGES"] = "MEDALLAS",
|
||||||
|
["POKéDEX"] = "POKéDEX_ES",
|
||||||
|
["TIME"] = "TIEMPO",
|
||||||
|
["NO SAVE FILE"] = "SIN PARTIDA",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
local menu = MainMenu.new({}, { hasSave = true, save = SAVE, clock = CLOCK })
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(FIRST_ITEM_X, FIRST_ITEM_Y), "CONTINUAR",
|
||||||
|
"a mod catalog reaches the title menu's first row")
|
||||||
|
T.eq(drawnAt(CLOCK_HALF_X, CLOCK_HALF_Y), " 1:05 PM_ES",
|
||||||
|
"and the clock box's AM/PM half")
|
||||||
|
|
||||||
|
drawn = {}
|
||||||
|
menu:drawSavePanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JUGADOR GOLD",
|
||||||
|
"the save-summary panel's PLAYER row takes the mod's own word order")
|
||||||
|
T.eq(drawnAt(5 * 8, 4 * 8), "MEDALLAS", "and BADGES")
|
||||||
|
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_ES", "and POKéDEX")
|
||||||
|
T.eq(drawnAt(5 * 8, 8 * 8), "TIEMPO", "and TIME")
|
||||||
|
|
||||||
|
local noSaveMenu = MainMenu.new({}, { hasSave = false, save = false, clock = CLOCK })
|
||||||
|
drawn = {}
|
||||||
|
noSaveMenu:drawSavePanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "SIN PARTIDA",
|
||||||
|
"and the no-summary fallback")
|
||||||
|
|
||||||
|
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||||
|
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||||
|
-- leaving the catalog loaded past this point would still mistranslate
|
||||||
|
-- every check below it in this file.
|
||||||
|
Strings.load({})
|
||||||
|
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2_main_menu_translation_test")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
-- Gold's naming/keyboard screen (src/ui/gen2/NamingScreen.lua) had zero
|
||||||
|
-- Strings() calls: every prompt (YOUR NAME?/RIVAL'S NAME?/MOTHER'S NAME?/
|
||||||
|
-- BOX NAME?/NICKNAME?), the on-screen keyboard's own letters, and the
|
||||||
|
-- lower/UPPER/DEL/END bottom-row labels were bare literals, invisible to a
|
||||||
|
-- translation mod's `strings` registry (reported against a real Gold build,
|
||||||
|
-- gen1recomp#1642). The Gen 1 naming screen (src/ui/NamingScreen.lua) already
|
||||||
|
-- routes its title and every keyboard cell through Strings().
|
||||||
|
--
|
||||||
|
-- GbcPalette.available() is false headless (no real shader compiles), so
|
||||||
|
-- Chrome.printThrough already falls back to the plain, unshaded Chrome.print
|
||||||
|
-- -- this drives that path directly and checks the translated text reaches
|
||||||
|
-- Font.draw, the same technique
|
||||||
|
-- tests/engine/gen2_options_menu_translation_test.lua uses for the OPTION
|
||||||
|
-- screen.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
|
||||||
|
local drawn
|
||||||
|
package.loaded["src.render.Font"] = {
|
||||||
|
draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||||
|
end,
|
||||||
|
drawCode = function() end,
|
||||||
|
drawBox = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
|
local function drawnAt(x, y)
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.x == x and d.y == y then return d.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
|
||||||
|
-- the prompt lands at tile (5, 2), the keyboard's first cell at (2, 8).
|
||||||
|
local PROMPT_X, PROMPT_Y = 5 * 8, 2 * 8
|
||||||
|
local FIRST_CELL_X, FIRST_CELL_Y = 2 * 8, 8 * 8
|
||||||
|
|
||||||
|
-- ---------------------------------------------- vanilla: no mod catalog
|
||||||
|
do
|
||||||
|
local screen = NamingScreen.new({}, { type = "player" })
|
||||||
|
drawn = {}
|
||||||
|
screen:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "YOUR NAME?",
|
||||||
|
"the player-name prompt draws in English with no mod loaded")
|
||||||
|
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "A",
|
||||||
|
"and the keyboard's first cell too")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- a translation mod's turn
|
||||||
|
do
|
||||||
|
Strings.load({
|
||||||
|
strings = {
|
||||||
|
["YOUR NAME?"] = "TON NOM?",
|
||||||
|
["A"] = "À",
|
||||||
|
["lower"] = "minusc",
|
||||||
|
["END"] = "FIN",
|
||||||
|
["%s'S"] = "DE %s",
|
||||||
|
["NICKNAME?"] = "SURNOM?",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
local screen = NamingScreen.new({}, { type = "player" })
|
||||||
|
drawn = {}
|
||||||
|
screen:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "TON NOM?",
|
||||||
|
"a mod catalog reaches the prompt")
|
||||||
|
T.eq(drawnAt(FIRST_CELL_X, FIRST_CELL_Y), "À",
|
||||||
|
"and a keyboard cell")
|
||||||
|
|
||||||
|
-- The bottom row: lower/DEL/END at tile y = keyboardTop + bottom*2.
|
||||||
|
local bottomY = (screen:keyboardTop() + screen:bottomRow() * 2) * 8
|
||||||
|
T.eq(drawnAt(2 * 8, bottomY), "minusc", "the case-switch label is translated")
|
||||||
|
T.eq(drawnAt(15 * 8, bottomY), "FIN", "and END, the way out of the screen")
|
||||||
|
|
||||||
|
-- The nickname header: two lines, the mon name folded into the first.
|
||||||
|
local nickScreen = NamingScreen.new({}, { type = "nickname", monName = "BULBASAUR" })
|
||||||
|
drawn = {}
|
||||||
|
nickScreen:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT_X, PROMPT_Y), "DE BULBASAUR",
|
||||||
|
"the nickname header's first line takes the mod's own word order")
|
||||||
|
T.eq(drawnAt(PROMPT_X, 4 * 8), "SURNOM?", "and its second line")
|
||||||
|
|
||||||
|
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||||
|
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||||
|
-- leaving the catalog loaded past this point would still mistranslate
|
||||||
|
-- every check below it in this file.
|
||||||
|
Strings.load({})
|
||||||
|
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2_naming_screen_translation_test")
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- Gold's OPTION screen (src/ui/gen2/OptionsMenu.lua) used to draw every row
|
||||||
|
-- label -- and the cart-original value strings (FAST/MID/SLOW, ON/OFF,
|
||||||
|
-- SHIFT/SET, ...) -- as bare literals baked into the module-level ROWS
|
||||||
|
-- table, invisible to a translation mod's `strings` registry (reported
|
||||||
|
-- against a real Gold build, gen1recomp#1642). This drives
|
||||||
|
-- OptionsMenu:drawPanel() with a mod-loaded Strings catalog and checks the
|
||||||
|
-- translated text reaches Font.draw, for both a cart-original row (label +
|
||||||
|
-- display value) and a port-added row (label only -- its value already
|
||||||
|
-- comes pre-translated from the shared module it calls, same as the Gen 1
|
||||||
|
-- OPTION screen's equivalent rows), plus a vanilla no-mod case proving the
|
||||||
|
-- fallback is unchanged.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
|
||||||
|
-- Chrome.print (the only draw call this screen makes) goes straight to
|
||||||
|
-- Font.draw, so recording that call is enough to see exactly what text
|
||||||
|
-- reached the screen -- same technique as
|
||||||
|
-- tests/engine/status_abbreviation_translation_test.lua. Stubbed before
|
||||||
|
-- OptionsMenu (and the Chrome module it requires) ever loads, so Chrome's
|
||||||
|
-- own `local Font = require(...)` captures this stub instead of the real
|
||||||
|
-- module.
|
||||||
|
local drawn
|
||||||
|
package.loaded["src.render.Font"] = {
|
||||||
|
draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||||
|
end,
|
||||||
|
drawCode = function() end,
|
||||||
|
drawBox = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
|
local function drawnAt(x, y)
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.x == x and d.y == y then return d.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua);
|
||||||
|
-- drawPanel puts labels at tile x=2 and values at tile x=11.
|
||||||
|
local LABEL_X = 2 * 8
|
||||||
|
local VALUE_X = 11 * 8
|
||||||
|
|
||||||
|
local function rowIndex(rows, id)
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if row.id == id then return i end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------- vanilla: no mod catalog
|
||||||
|
do
|
||||||
|
local menu = OptionsMenu.new({})
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED",
|
||||||
|
"row 1's label draws in English with no mod loaded")
|
||||||
|
-- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default.
|
||||||
|
T.eq(drawnAt(VALUE_X, 3 * 8), "MID ",
|
||||||
|
"and its cart-original display value too")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- a translation mod's turn
|
||||||
|
do
|
||||||
|
Strings.load({
|
||||||
|
strings = {
|
||||||
|
["TEXT SPEED"] = "VITESSE TEXTE",
|
||||||
|
["MID "] = "MOY ",
|
||||||
|
["CONTROLS"] = "COMMANDES",
|
||||||
|
["CANCEL"] = "ANNULER",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
local menu = OptionsMenu.new({})
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE",
|
||||||
|
"a mod catalog reaches a cart-original row's label")
|
||||||
|
T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ",
|
||||||
|
"and its cart-original display value")
|
||||||
|
|
||||||
|
-- CONTROLS is the first port-added row; scroll to it so it lands in the
|
||||||
|
-- VISIBLE_ROWS=7 window drawPanel actually draws.
|
||||||
|
local index = rowIndex(menu.rows, "controls")
|
||||||
|
T.check(index ~= nil, "CONTROLS is one of the rows")
|
||||||
|
menu.index = index
|
||||||
|
menu:ensureVisible()
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
local slot = index - menu.scroll
|
||||||
|
T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES",
|
||||||
|
"and a port-added row's label is translated too")
|
||||||
|
|
||||||
|
-- CANCEL is the last row, built into ROWS like any other -- there is no
|
||||||
|
-- separate hook to fall through if this one row is missed.
|
||||||
|
local cancelMenu = OptionsMenu.new({})
|
||||||
|
local cancelIndex = #cancelMenu.rows
|
||||||
|
T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL")
|
||||||
|
cancelMenu.index = cancelIndex
|
||||||
|
cancelMenu:ensureVisible()
|
||||||
|
drawn = {}
|
||||||
|
cancelMenu:drawPanel()
|
||||||
|
local cancelSlot = cancelIndex - cancelMenu.scroll
|
||||||
|
T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER",
|
||||||
|
"CANCEL, the way out of the menu, is translated too")
|
||||||
|
|
||||||
|
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||||
|
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||||
|
-- leaving the catalog loaded past this point would still mistranslate
|
||||||
|
-- every check below it in this file.
|
||||||
|
Strings.load({})
|
||||||
|
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2_options_menu_translation_test")
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
-- Gold's SAVE screen (src/ui/gen2/SaveMenu.lua) drew every prompt ("Would
|
||||||
|
-- you like to save the game?", the overwrite/saving/saved messages), the
|
||||||
|
-- YES/NO choice, and the summary panel's labels (PLAYER <name>/BADGES/
|
||||||
|
-- POKéDEX/TIME) as bare literals, invisible to a translation mod's
|
||||||
|
-- `strings` registry -- unlike the Gen 1 port's own SAVE screen
|
||||||
|
-- (src/ui/StartMenu.lua), which already routes the same rows through
|
||||||
|
-- Strings(). Drives SaveMenu:drawPanel() directly at each phase with a
|
||||||
|
-- mod-loaded Strings catalog and checks the translated text reaches
|
||||||
|
-- Font.draw, same technique as
|
||||||
|
-- tests/engine/gen2_naming_screen_translation_test.lua.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
love = require("tests.love_stub")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
|
||||||
|
local drawn
|
||||||
|
package.loaded["src.render.Font"] = {
|
||||||
|
draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = text, x = x, y = y }
|
||||||
|
end,
|
||||||
|
drawCode = function() end,
|
||||||
|
drawBox = function() end,
|
||||||
|
width = function() return 0 end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local SaveMenu = require("src.ui.gen2.SaveMenu")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
|
local function drawnAt(x, y)
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.x == x and d.y == y then return d.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Chrome.print multiplies tile coordinates by 8 (src/ui/gen2/Chrome.lua).
|
||||||
|
-- PLAYER row at (5, 2); the two prompt lines at (1, 14)/(1, 16); YES/NO at
|
||||||
|
-- (2, 8)/(2, 10) (YESNO_X + 2, YESNO_Y + 1 / + 3).
|
||||||
|
local PANEL_PLAYER_X, PANEL_PLAYER_Y = 5 * 8, 2 * 8
|
||||||
|
local PROMPT1_X, PROMPT1_Y = 1 * 8, 14 * 8
|
||||||
|
local PROMPT2_X, PROMPT2_Y = 1 * 8, 16 * 8
|
||||||
|
local YES_X, YES_Y = 2 * 8, 8 * 8
|
||||||
|
local NO_X, NO_Y = 2 * 8, 10 * 8
|
||||||
|
|
||||||
|
local SAVE = { player = { name = "GOLD" } }
|
||||||
|
|
||||||
|
-- ---------------------------------------------- vanilla: no mod catalog
|
||||||
|
do
|
||||||
|
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "PLAYER GOLD",
|
||||||
|
"the summary panel draws in English with no mod loaded")
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Would you like to",
|
||||||
|
"and the confirm prompt's first line")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save the game?", "and its second line")
|
||||||
|
T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES")
|
||||||
|
T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO")
|
||||||
|
|
||||||
|
menu.phase = "overwrite"
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "the overwrite prompt")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "its second line")
|
||||||
|
|
||||||
|
menu.phase = "saving"
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAVING… DON'T TURN", "the saving message")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OFF THE POWER.", "its second line")
|
||||||
|
|
||||||
|
menu.phase, menu.saved = "done", true
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD saved", "the saved message")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "the game.", "its second line")
|
||||||
|
|
||||||
|
menu.phase, menu.saved = "done", false
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------- a translation mod's turn
|
||||||
|
do
|
||||||
|
Strings.load({
|
||||||
|
strings = {
|
||||||
|
["PLAYER %s"] = "JOUEUR %s",
|
||||||
|
["BADGES"] = "BADGES_FR",
|
||||||
|
["POKéDEX"] = "POKéDEX_FR",
|
||||||
|
["TIME"] = "TEMPS",
|
||||||
|
["YES"] = "OUI",
|
||||||
|
["NO"] = "NON",
|
||||||
|
["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?",
|
||||||
|
["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce",
|
||||||
|
["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.",
|
||||||
|
["%s saved\nthe game."] = "%s a sauvegarde\nla partie.",
|
||||||
|
["Could not save."] = "Echec de sauvegarde.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PANEL_PLAYER_X, PANEL_PLAYER_Y), "JOUEUR GOLD",
|
||||||
|
"the summary panel's PLAYER row takes the mod's own word order")
|
||||||
|
T.eq(drawnAt(5 * 8, 4 * 8), "BADGES_FR", "and BADGES")
|
||||||
|
T.eq(drawnAt(5 * 8, 6 * 8), "POKéDEX_FR", "and POKéDEX")
|
||||||
|
T.eq(drawnAt(5 * 8, 8 * 8), "TEMPS", "and TIME")
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Voulez-vous", "the confirm prompt")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarder ?", "its second line")
|
||||||
|
T.eq(drawnAt(YES_X, YES_Y), "OUI", "and YES")
|
||||||
|
T.eq(drawnAt(NO_X, NO_Y), "NON", "and NO")
|
||||||
|
|
||||||
|
menu.phase = "overwrite"
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Un fichier existe", "the overwrite prompt")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "deja. Est-ce", "its second line")
|
||||||
|
|
||||||
|
menu.phase = "saving"
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "SAUVEGARDE...", "the saving message")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "N'ETEIGNEZ PAS.", "its second line")
|
||||||
|
|
||||||
|
menu.phase, menu.saved = "done", true
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "GOLD a sauvegarde",
|
||||||
|
"the saved message folds the player name into the mod's own word order")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "la partie.", "its second line")
|
||||||
|
|
||||||
|
menu.phase, menu.saved = "done", false
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Echec de sauvegarde.", "the failed-save message")
|
||||||
|
|
||||||
|
-- Module state is process-global (see tests/gen2_clock_test.lua's own
|
||||||
|
-- note); this suite gets its own process from tests/tier_runner.lua, but
|
||||||
|
-- leaving the catalog loaded past this point would still mistranslate
|
||||||
|
-- every check below it in this file.
|
||||||
|
Strings.load({})
|
||||||
|
T.check(not Strings.active(), "the catalog is unloaded for the checks after this one")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- src/ui/gen2/PcMenu.lua:savePrompt() returns SaveMenu.OVERWRITE_PROMPT/
|
||||||
|
-- SAVING_PROMPT straight through to its own `lines[1]`/`lines[2]`
|
||||||
|
-- Chrome.print calls (the PC's CHANGE BOX save uses the same two prompts).
|
||||||
|
-- Indexing a plain string with [1]/[2] returns nil, not characters, so this
|
||||||
|
-- shape is a cross-file contract: it caught a real regression during review,
|
||||||
|
-- where routing these through a single Strings.source()-wrapped string (to
|
||||||
|
-- translate SaveMenu's own screen) silently turned them into non-table
|
||||||
|
-- values and left PcMenu's overwrite/saving prompt blank.
|
||||||
|
do
|
||||||
|
T.eq(type(SaveMenu.OVERWRITE_PROMPT), "table", "OVERWRITE_PROMPT stays a table for PcMenu.lua")
|
||||||
|
T.eq(SaveMenu.OVERWRITE_PROMPT[1], "There is already a", "and its first line stays indexable")
|
||||||
|
T.eq(SaveMenu.OVERWRITE_PROMPT[2], "save file. Is it", "and its second line")
|
||||||
|
T.eq(type(SaveMenu.SAVING_PROMPT), "table", "SAVING_PROMPT stays a table for PcMenu.lua")
|
||||||
|
T.eq(SaveMenu.SAVING_PROMPT[1], "SAVING… DON'T TURN", "and its first line stays indexable")
|
||||||
|
T.eq(SaveMenu.SAVING_PROMPT[2], "OFF THE POWER.", "and its second line")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A translation with a THIRD line (a second embedded "\n") has nowhere on
|
||||||
|
-- screen to go -- drawPanel's box has room for exactly two Chrome.print
|
||||||
|
-- calls -- so it must not silently draw the literal newline byte as glyph
|
||||||
|
-- garbage on the second line, and should warn so a translator notices.
|
||||||
|
do
|
||||||
|
Strings.load({
|
||||||
|
strings = {
|
||||||
|
["Would you like to\nsave the game?"] = "Ligne un\nLigne deux\nLigne trois",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
local warned = {}
|
||||||
|
require("src.core.Logger").warn = function(fmt, ...)
|
||||||
|
warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||||
|
end
|
||||||
|
|
||||||
|
local menu = SaveMenu.new({}, { save = SAVE, existed = false })
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box")
|
||||||
|
T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois",
|
||||||
|
"the rest lands in the second slot rather than vanishing")
|
||||||
|
T.check(#warned == 1, "and a single warning is logged")
|
||||||
|
|
||||||
|
drawn = {}
|
||||||
|
menu:drawPanel()
|
||||||
|
T.check(#warned == 1, "the warning does not repeat for the same text")
|
||||||
|
|
||||||
|
require("src.core.Logger").warn = function() end
|
||||||
|
Strings.load({})
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("gen2_save_menu_translation_test")
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
|
-- In-process launcher session teardown: Game:reset, Renderer canvas release,
|
||||||
-- Runtime/Assets/LegacyCompat cleanup, and editor package.loaded discovery flush.
|
-- SessionLifecycle mount/game tiers, and editor package.loaded discovery flush.
|
||||||
-- luajit tests/engine/launcher_session_teardown_test.lua
|
-- luajit tests/engine/launcher_session_teardown_test.lua
|
||||||
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
@@ -12,8 +12,12 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local LegacyCompat = require("src.mods.LegacyCompat")
|
local LegacyCompat = require("src.mods.LegacyCompat")
|
||||||
local Game = require("src.core.Game")
|
local Game = require("src.core.Game")
|
||||||
|
local Game2 = require("src.core.Game2")
|
||||||
local Renderer = require("src.render.Renderer")
|
local Renderer = require("src.render.Renderer")
|
||||||
local StateStack = require("src.core.StateStack")
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||||
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local World = require("src.world.gen2.World")
|
||||||
|
|
||||||
-- ---- Game:reset drops instance state, keeps methods ----------------------
|
-- ---- Game:reset drops instance state, keeps methods ----------------------
|
||||||
do
|
do
|
||||||
@@ -38,6 +42,35 @@ do
|
|||||||
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
|
check(StateStack:top() == nil, "Game:reset cleared the shared StateStack")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
||||||
|
do
|
||||||
|
local game2 = Game2.new()
|
||||||
|
local canvas = love.graphics.newCanvas(4, 4)
|
||||||
|
game2.world = World.new({})
|
||||||
|
game2.world.mapImages = { ["MAP|d|1"] = canvas }
|
||||||
|
game2._canvases = { love.graphics.newCanvas(8, 8) }
|
||||||
|
game2:reset()
|
||||||
|
check(canvas.released == true, "Game2:reset releases World mapImages")
|
||||||
|
check(game2.world == nil, "Game2:reset clears world reference")
|
||||||
|
check(game2._canvases == nil, "Game2:reset clears _canvases")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- World:release frees owned GPU caches --------------------------------
|
||||||
|
do
|
||||||
|
local world = World.new({})
|
||||||
|
local bake = love.graphics.newCanvas(16, 16)
|
||||||
|
local strip = love.graphics.newCanvas(8, 64)
|
||||||
|
local tilt = love.graphics.newCanvas(160, 144)
|
||||||
|
world.mapImages = { ["R1|DAY|1"] = bake }
|
||||||
|
world.scrollStrips = { ["TS|1|0,0"] = strip }
|
||||||
|
world.tiltCanvas = tilt
|
||||||
|
world:release()
|
||||||
|
check(bake.released == true, "World:release frees map bake canvases")
|
||||||
|
check(strip.released == true, "World:release frees scroll strips")
|
||||||
|
check(tilt.released == true, "World:release frees tiltCanvas")
|
||||||
|
eq(next(world.mapImages), nil, "World:release clears mapImages table")
|
||||||
|
end
|
||||||
|
|
||||||
-- ---- Renderer:init releases prior canvases before realloc ----------------
|
-- ---- Renderer:init releases prior canvases before realloc ----------------
|
||||||
do
|
do
|
||||||
local first = love.graphics.newCanvas(16, 16)
|
local first = love.graphics.newCanvas(16, 16)
|
||||||
@@ -52,14 +85,13 @@ do
|
|||||||
"Renderer:init allocates a fresh primary canvas")
|
"Renderer:init allocates a fresh primary canvas")
|
||||||
check(Renderer.canvas.released ~= true,
|
check(Renderer.canvas.released ~= true,
|
||||||
"the new primary canvas is not released")
|
"the new primary canvas is not released")
|
||||||
-- second init also releases the one just created
|
|
||||||
local second = Renderer.canvas
|
local second = Renderer.canvas
|
||||||
Renderer:init()
|
Renderer:init()
|
||||||
check(second.released == true,
|
check(second.released == true,
|
||||||
"a second Renderer:init releases the canvas from the prior init")
|
"a second Renderer:init releases the canvas from the prior init")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Shared singleton teardown contract (closeEditor / returnToLauncher)
|
-- ---- SessionLifecycle.endMountedSession (closeEditor / returnToLauncher) --
|
||||||
do
|
do
|
||||||
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
|
Runtime.install({ emit = function() end }, { call = function() end }, { "e" })
|
||||||
Assets.installLoader({
|
Assets.installLoader({
|
||||||
@@ -68,44 +100,79 @@ do
|
|||||||
})
|
})
|
||||||
LegacyCompat.reports = { some_mod = { order = {} } }
|
LegacyCompat.reports = { some_mod = { order = {} } }
|
||||||
|
|
||||||
-- Mirrors main.lua teardownMountedSession without mounting CacheFs.
|
SessionLifecycle.endMountedSession(nil)
|
||||||
require("src.core.Data"):unloadGenerated()
|
|
||||||
Runtime.reset()
|
|
||||||
Assets.installLoader(nil)
|
|
||||||
LegacyCompat.reset()
|
|
||||||
|
|
||||||
check(Runtime.errors == nil, "teardown clears Runtime.errors")
|
check(Runtime.errors == nil, "endMountedSession clears Runtime.errors")
|
||||||
check(Assets.loader == nil, "teardown clears Assets.loader")
|
check(Assets.loader == nil, "endMountedSession clears Assets.loader")
|
||||||
eq(next(LegacyCompat.reports), nil, "teardown clears LegacyCompat.reports")
|
eq(next(LegacyCompat.reports), nil, "endMountedSession clears LegacyCompat.reports")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Editor package.loaded discovery flush (no panel whitelist) ---------
|
-- ---- releaseSession empties MapLoader via releaseAll, not flush -------------
|
||||||
|
do
|
||||||
|
local data = {
|
||||||
|
maps = { T1 = { id = "T1", tileset = "TS", width = 1, height = 1,
|
||||||
|
blocks = { 0 }, borderBlock = 0, objects = {}, warps = {}, signs = {} } },
|
||||||
|
tilesets = { TS = { id = "TS", image = "assets/generated/t.png",
|
||||||
|
walkable = {}, blocks = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } },
|
||||||
|
tilesPerRow = 1 } },
|
||||||
|
}
|
||||||
|
MapLoader.load(data, "T1")
|
||||||
|
check(MapLoader.cached("T1") ~= nil, "MapLoader holds a map before release")
|
||||||
|
Assets.releaseSession()
|
||||||
|
check(MapLoader.cached("T1") == nil,
|
||||||
|
"releaseSession evicts MapLoader via releaseAll")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- Editor package.loaded discovery flush via endEditorSession -----------
|
||||||
do
|
do
|
||||||
love.filesystem.write("tools/save-editor/App.lua", "return {}")
|
love.filesystem.write("tools/save-editor/App.lua", "return {}")
|
||||||
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
|
love.filesystem.write("tools/save-editor/panels/NewPanel.lua", "return {}")
|
||||||
package.loaded["App"] = { stale = true }
|
package.loaded["App"] = { stale = true }
|
||||||
package.loaded["NewPanel"] = { stale = true }
|
package.loaded["NewPanel"] = { stale = true }
|
||||||
package.loaded["src.core.Data"] = package.loaded["src.core.Data"] -- keep
|
package.loaded["src.core.Data"] = package.loaded["src.core.Data"]
|
||||||
|
|
||||||
local function isEditorFlat(name)
|
SessionLifecycle.endEditorSession({ version = nil, app = nil })
|
||||||
if name:find("[./]") then return false end
|
|
||||||
return love.filesystem.getInfo("tools/save-editor/" .. name .. ".lua") ~= nil
|
|
||||||
or love.filesystem.getInfo("tools/save-editor/panels/" .. name .. ".lua") ~= nil
|
|
||||||
end
|
|
||||||
for k in pairs(package.loaded) do
|
|
||||||
if type(k) == "string"
|
|
||||||
and (k:find("save%-editor", 1, false) or isEditorFlat(k)) then
|
|
||||||
package.loaded[k] = nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
check(package.loaded["App"] == nil, "discovery flush drops flat App")
|
check(package.loaded["App"] == nil, "endEditorSession drops flat App")
|
||||||
check(package.loaded["NewPanel"] == nil,
|
check(package.loaded["NewPanel"] == nil,
|
||||||
"discovery flush drops a new panel without a hardcoded list")
|
"endEditorSession drops a new panel without a hardcoded list")
|
||||||
check(package.loaded["src.core.Data"] ~= nil,
|
check(package.loaded["src.core.Data"] ~= nil,
|
||||||
"discovery flush leaves engine modules alone")
|
"endEditorSession leaves engine modules alone")
|
||||||
love.filesystem.remove("tools/save-editor/App.lua")
|
love.filesystem.remove("tools/save-editor/App.lua")
|
||||||
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
|
love.filesystem.remove("tools/save-editor/panels/NewPanel.lua")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---- Fetch shutdown clears ready so Play-again can respawn workers ---------
|
||||||
|
do
|
||||||
|
local Fetch = require("src.net.Fetch")
|
||||||
|
local spawnAttempts = 0
|
||||||
|
love.thread = love.thread or {}
|
||||||
|
local savedNewThread = love.thread.newThread
|
||||||
|
local savedGetChannel = love.thread.getChannel
|
||||||
|
love.thread.getChannel = function()
|
||||||
|
return {
|
||||||
|
clear = function() end,
|
||||||
|
push = function() end,
|
||||||
|
pop = function() return nil end,
|
||||||
|
demand = function() end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
love.thread.newThread = function()
|
||||||
|
spawnAttempts = spawnAttempts + 1
|
||||||
|
return {
|
||||||
|
start = function() end,
|
||||||
|
wait = function() end,
|
||||||
|
getError = function() return nil end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
Fetch.available()
|
||||||
|
local afterFirst = spawnAttempts
|
||||||
|
Fetch.shutdown()
|
||||||
|
Fetch.available()
|
||||||
|
check(spawnAttempts > afterFirst,
|
||||||
|
"Fetch.available retries worker spawn after shutdown (ready=nil)")
|
||||||
|
love.thread.newThread = savedNewThread
|
||||||
|
love.thread.getChannel = savedGetChannel
|
||||||
|
end
|
||||||
|
|
||||||
T.finish("launcher_session_teardown_test")
|
T.finish("launcher_session_teardown_test")
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Static privacy gate for the narrow Mew dock engine branch. It checks the
|
||||||
|
-- Git publication set, not ignored local imports: user ROMs and progress
|
||||||
|
-- saves may exist on a developer machine but must never become tracked files.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
local T = require("tests.harness")
|
||||||
|
|
||||||
|
local pipe = io.popen("git ls-files 2>" .. (package.config:sub(1, 1) == "\\" and "nul" or "/dev/null"))
|
||||||
|
local paths = {}
|
||||||
|
if pipe then
|
||||||
|
for path in pipe:lines() do paths[#paths + 1] = path:gsub("\\", "/") end
|
||||||
|
pipe:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(#paths > 100,
|
||||||
|
"privacy gate inspects a real Git publication set instead of passing vacuously")
|
||||||
|
|
||||||
|
local forbiddenExtensions = {
|
||||||
|
gb = true, gbc = true, gba = true, sav = true, srm = true,
|
||||||
|
rom = true, z64 = true, v64 = true, n64 = true, nds = true,
|
||||||
|
sfc = true, smc = true,
|
||||||
|
}
|
||||||
|
local forbiddenRuntimeRoots = {
|
||||||
|
["save.lua"] = true,
|
||||||
|
["save_blue.lua"] = true,
|
||||||
|
["save_yellow.lua"] = true,
|
||||||
|
["save_gold.lua"] = true,
|
||||||
|
["options.lua"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local binaryLeaks, runtimeLeaks = {}, {}
|
||||||
|
for _, path in ipairs(paths) do
|
||||||
|
local lower = path:lower()
|
||||||
|
local ext = lower:match("%.([^./\\]+)$")
|
||||||
|
if forbiddenExtensions[ext] then binaryLeaks[#binaryLeaks + 1] = path end
|
||||||
|
if forbiddenRuntimeRoots[lower]
|
||||||
|
or lower:match("^saves/")
|
||||||
|
or lower:match("^imports/")
|
||||||
|
or lower:match("^mods%-data/") then
|
||||||
|
runtimeLeaks[#runtimeLeaks + 1] = path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
T.eq(#binaryLeaks, 0,
|
||||||
|
"tracked publication contains no ROM/save binaries: " .. table.concat(binaryLeaks, ", "))
|
||||||
|
T.eq(#runtimeLeaks, 0,
|
||||||
|
"tracked publication contains no runtime save/import data: " .. table.concat(runtimeLeaks, ", "))
|
||||||
|
|
||||||
|
T.finish("mew dock private artifact gate")
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
-- Contract gate for the two narrow Gen 1 seams added for a composable
|
||||||
|
-- post-departure S.S. Anne dock mod. The suite is ROM-free: it drives the
|
||||||
|
-- real hook bus and WorldAPI against hand-written maps and save snapshots.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local Hooks = require("src.mods.Hooks")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local WorldAPI = require("src.world.WorldAPI")
|
||||||
|
|
||||||
|
local oldHooks = Runtime.hooks
|
||||||
|
local oldTextBox = package.loaded["src.render.TextBox"]
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, done) return { text = text, done = done } end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local story = dofile("data/scripts/story3.lua")
|
||||||
|
local VERSIONS = { "red", "blue", "yellow" }
|
||||||
|
|
||||||
|
local function newDock(version, wrappers)
|
||||||
|
local blocks, pushes, warps = {}, {}, {}
|
||||||
|
local rebuilds = 0
|
||||||
|
local save = {
|
||||||
|
version = version,
|
||||||
|
flags = { EVENT_SS_ANNE_LEFT = true },
|
||||||
|
marker = "save-must-not-change",
|
||||||
|
}
|
||||||
|
local map = {
|
||||||
|
id = "VERMILION_DOCK",
|
||||||
|
setBlock = function(_, bx, by, block)
|
||||||
|
blocks[bx .. "," .. by] = block
|
||||||
|
end,
|
||||||
|
renderer = { rebuild = function() rebuilds = rebuilds + 1 end },
|
||||||
|
}
|
||||||
|
local game = {
|
||||||
|
save = save,
|
||||||
|
data = { text = {
|
||||||
|
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||||
|
} },
|
||||||
|
stack = { push = function(_, value) pushes[#pushes + 1] = value end },
|
||||||
|
}
|
||||||
|
local ow = {
|
||||||
|
map = map,
|
||||||
|
player = { cellX = 14, cellY = 2, facing = "down" },
|
||||||
|
startWarpTo = function(_, ...)
|
||||||
|
warps[#warps + 1] = { ... }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local hooks = Hooks.new()
|
||||||
|
Runtime.hooks = hooks
|
||||||
|
for _, entry in ipairs(wrappers or {}) do
|
||||||
|
hooks:wrap("map.occupancy_allowed", entry.fn, entry.priority or 0,
|
||||||
|
entry.owner)
|
||||||
|
end
|
||||||
|
|
||||||
|
story.VERMILION_DOCK.onEnter(game, ow)
|
||||||
|
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||||
|
return {
|
||||||
|
blocks = blocks, pushes = pushes, warps = warps, rebuilds = rebuilds,
|
||||||
|
save = save, map = map, game = game, ow = ow, hooks = hooks,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function allowedWrapper(owner, inspect)
|
||||||
|
return {
|
||||||
|
owner = owner,
|
||||||
|
fn = function(nextFn, game, ctx)
|
||||||
|
if inspect then inspect(game, ctx) end
|
||||||
|
local downstream = nextFn(game, ctx)
|
||||||
|
local ownClaim = true
|
||||||
|
return downstream == true or ownClaim == true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- With no subscriber, the post-departure branch remains byte-for-byte
|
||||||
|
-- vanilla in effect: erase the ship, show its line, and eject the player.
|
||||||
|
do
|
||||||
|
local run = newDock("red")
|
||||||
|
T.eq(run.rebuilds, 1, "vanilla re-entry rebuilds the erased dock")
|
||||||
|
T.eq(run.blocks["5,1"], 1, "vanilla re-entry erases the upper hull")
|
||||||
|
T.eq(run.blocks["8,2"], 13, "vanilla re-entry erases the lower hull")
|
||||||
|
T.eq(#run.pushes, 1, "vanilla re-entry shows the ship-set-sail line")
|
||||||
|
T.eq(run.pushes[1].text, "The ship set sail.",
|
||||||
|
"vanilla re-entry preserves its dialogue")
|
||||||
|
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||||
|
"vanilla re-entry ejects to Vermilion City")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The new permission seam is post-departure only. An ordinary HM01 exit
|
||||||
|
-- must not invoke it or change the existing departure-script path.
|
||||||
|
do
|
||||||
|
local hookCalls, queued = 0, nil
|
||||||
|
local hooks = Hooks.new()
|
||||||
|
Runtime.hooks = hooks
|
||||||
|
hooks:wrap("map.occupancy_allowed", function(nextFn, game, ctx)
|
||||||
|
hookCalls = hookCalls + 1
|
||||||
|
return nextFn(game, ctx)
|
||||||
|
end, 0, "must_not_run")
|
||||||
|
|
||||||
|
local savedMusic = package.loaded["src.core.Music"]
|
||||||
|
package.loaded["src.core.Music"] = {
|
||||||
|
stop = function() end,
|
||||||
|
play = function() end,
|
||||||
|
}
|
||||||
|
local game = {
|
||||||
|
save = { version = "red", flags = { EVENT_GOT_HM01 = true } },
|
||||||
|
data = {},
|
||||||
|
}
|
||||||
|
local ow = {
|
||||||
|
player = { cellX = 14, cellY = 2 },
|
||||||
|
startDustAnim = function(_, _, _, done) if done then done() end end,
|
||||||
|
queueScript = function(_, rows) queued = rows end,
|
||||||
|
}
|
||||||
|
story.VERMILION_DOCK.onEnter(game, ow)
|
||||||
|
package.loaded["src.core.Music"] = savedMusic
|
||||||
|
|
||||||
|
T.eq(hookCalls, 0, "normal HM01 departure never calls the occupancy seam")
|
||||||
|
T.eq(game.save.flags.EVENT_SS_ANNE_LEFT, true,
|
||||||
|
"normal HM01 departure still sets the vanilla event flag")
|
||||||
|
T.check(type(queued) == "table" and #queued > 0,
|
||||||
|
"normal HM01 departure still queues its sail-away script")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One cooperative claimant may permit occupancy. The callback receives a
|
||||||
|
-- detached data snapshot, not the live overworld, map, player, or save.
|
||||||
|
do
|
||||||
|
local seenGame, seenCtx
|
||||||
|
local run = newDock("red", { allowedWrapper("mew_fixture", function(game, ctx)
|
||||||
|
seenGame = game
|
||||||
|
seenCtx = {
|
||||||
|
mapId = ctx.mapId, reason = ctx.reason, gameVersion = ctx.gameVersion,
|
||||||
|
x = ctx.x, y = ctx.y,
|
||||||
|
}
|
||||||
|
ctx.mapId, ctx.x, ctx.y = "MUTATED", -1, -1
|
||||||
|
end) })
|
||||||
|
T.eq(seenGame, run.game, "occupancy callback receives the live game explicitly")
|
||||||
|
T.same(seenCtx, {
|
||||||
|
mapId = "VERMILION_DOCK", reason = "ss_anne_departed",
|
||||||
|
gameVersion = "red", x = 14, y = 2,
|
||||||
|
}, "occupancy context is the exact detached Red dock snapshot")
|
||||||
|
T.eq(run.ow.map.id, "VERMILION_DOCK", "context mutation cannot change the map")
|
||||||
|
T.eq(run.ow.player.cellX, 14, "context mutation cannot change player X")
|
||||||
|
T.eq(run.ow.player.cellY, 2, "context mutation cannot change player Y")
|
||||||
|
T.eq(run.save.marker, "save-must-not-change", "permission check does not mutate save")
|
||||||
|
T.same(run.save, {
|
||||||
|
version = "red", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||||
|
marker = "save-must-not-change",
|
||||||
|
}, "permission check preserves the complete save snapshot")
|
||||||
|
T.eq(#run.pushes, 0, "an exact true suppresses the vanilla rejection dialog")
|
||||||
|
T.eq(#run.warps, 0, "an exact true permits post-departure dock occupancy")
|
||||||
|
T.eq(run.blocks["5,1"], 1, "permitted occupancy still erases the departed ship")
|
||||||
|
T.eq(run.rebuilds, 1, "permitted occupancy still rebuilds the water layout")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Standard hook composition also means a non-cooperative false/no-next
|
||||||
|
-- wrapper can suppress downstream claims. This remains safe because false
|
||||||
|
-- is denial; it cannot accidentally grant occupancy.
|
||||||
|
do
|
||||||
|
local downstreamCalls = 0
|
||||||
|
local run = newDock("red", {
|
||||||
|
{
|
||||||
|
owner = "denier", priority = 10,
|
||||||
|
fn = function() return false end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
owner = "unreached_claimant", priority = 0,
|
||||||
|
fn = function()
|
||||||
|
downstreamCalls = downstreamCalls + 1
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
T.eq(downstreamCalls, 0, "no-next denial suppresses downstream by hook semantics")
|
||||||
|
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||||
|
"non-cooperative false remains fail-closed")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cooperative peers all run through next(). A lower-priority peer claim is
|
||||||
|
-- preserved by a higher-priority peer that has no claim of its own.
|
||||||
|
do
|
||||||
|
local calls, contextIdentity = {}, nil
|
||||||
|
local run = newDock("blue", {
|
||||||
|
{
|
||||||
|
owner = "peer_high", priority = 10,
|
||||||
|
fn = function(nextFn, game, ctx)
|
||||||
|
calls[#calls + 1] = "high-before"
|
||||||
|
contextIdentity = ctx
|
||||||
|
local allowed = nextFn(game, ctx)
|
||||||
|
calls[#calls + 1] = "high-after"
|
||||||
|
return allowed == true or false
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
owner = "peer_low", priority = 0,
|
||||||
|
fn = function(nextFn, game, ctx)
|
||||||
|
calls[#calls + 1] = "low"
|
||||||
|
T.eq(ctx, contextIdentity, "peer wrappers share one detached snapshot instance")
|
||||||
|
local allowed = nextFn(game, ctx)
|
||||||
|
local ownClaim = true
|
||||||
|
return allowed == true or ownClaim == true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
T.same(calls, { "high-before", "low", "high-after" },
|
||||||
|
"multiple peer handlers preserve hook-chain order")
|
||||||
|
T.eq(#run.warps, 0, "a cooperative peer claim survives the whole chain")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Absent, throwing, or malformed callbacks fail closed. Only boolean true
|
||||||
|
-- can turn off ejection; truthy strings/tables/numbers do not grant access.
|
||||||
|
do
|
||||||
|
local malformed = {
|
||||||
|
{ label = "nil", value = nil },
|
||||||
|
{ label = "false", value = false },
|
||||||
|
{ label = "string", value = "yes" },
|
||||||
|
{ label = "number", value = 1 },
|
||||||
|
{ label = "table", value = {} },
|
||||||
|
}
|
||||||
|
for _, case in ipairs(malformed) do
|
||||||
|
local run = newDock("red", { {
|
||||||
|
owner = "malformed_" .. case.label,
|
||||||
|
fn = function() return case.value end,
|
||||||
|
} })
|
||||||
|
T.eq(run.warps[1] and run.warps[1][1], "VERMILION_CITY",
|
||||||
|
"malformed " .. case.label .. " permission fails closed")
|
||||||
|
end
|
||||||
|
|
||||||
|
local beforeNext = newDock("red", { {
|
||||||
|
owner = "throws_before_next",
|
||||||
|
fn = function() error("fixture throws before next", 0) end,
|
||||||
|
} })
|
||||||
|
T.eq(beforeNext.warps[1] and beforeNext.warps[1][1], "VERMILION_CITY",
|
||||||
|
"throwing callback before next fails closed")
|
||||||
|
|
||||||
|
local afterNext = newDock("red", { {
|
||||||
|
owner = "throws_after_next",
|
||||||
|
fn = function(nextFn, game, ctx)
|
||||||
|
nextFn(game, ctx)
|
||||||
|
error("fixture throws after next", 0)
|
||||||
|
end,
|
||||||
|
} })
|
||||||
|
T.eq(afterNext.warps[1] and afterNext.warps[1][1], "VERMILION_CITY",
|
||||||
|
"throwing callback after next keeps the downstream denial")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The context carries one version only. A Red-only claimant must not leak
|
||||||
|
-- access into Blue or Yellow, and each call gets its own snapshot.
|
||||||
|
do
|
||||||
|
local contexts = {}
|
||||||
|
for _, version in ipairs(VERSIONS) do
|
||||||
|
local run = newDock(version, { {
|
||||||
|
owner = "red_only",
|
||||||
|
fn = function(nextFn, game, ctx)
|
||||||
|
contexts[#contexts + 1] = ctx
|
||||||
|
local downstream = nextFn(game, ctx)
|
||||||
|
return downstream == true or ctx.gameVersion == "red"
|
||||||
|
end,
|
||||||
|
} })
|
||||||
|
T.eq(#run.warps == 0, version == "red",
|
||||||
|
version .. " occupancy is decided only by its own version context")
|
||||||
|
end
|
||||||
|
T.eq(contexts[1].gameVersion, "red", "Red context stays Red")
|
||||||
|
T.eq(contexts[2].gameVersion, "blue", "Blue context stays Blue")
|
||||||
|
T.eq(contexts[3].gameVersion, "yellow", "Yellow context stays Yellow")
|
||||||
|
T.check(contexts[1] ~= contexts[2] and contexts[2] ~= contexts[3],
|
||||||
|
"Red, Blue, and Yellow calls do not share context tables")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Removing the owner is the engine's disable/uninstall path. It restores
|
||||||
|
-- vanilla denial immediately and leaves no save flag or serialized state.
|
||||||
|
do
|
||||||
|
local run = newDock("yellow", { allowedWrapper("removable") })
|
||||||
|
T.eq(#run.warps, 0, "installed owner may grant Yellow dock occupancy")
|
||||||
|
run.hooks:removeOwner("removable")
|
||||||
|
local pushes, warps = {}, {}
|
||||||
|
run.game.stack.push = function(_, value) pushes[#pushes + 1] = value end
|
||||||
|
run.ow.startWarpTo = function(_, ... ) warps[#warps + 1] = { ... } end
|
||||||
|
story.VERMILION_DOCK.onEnter(run.game, run.ow)
|
||||||
|
if pushes[1] and pushes[1].done then pushes[1].done() end
|
||||||
|
T.eq(warps[1] and warps[1][1], "VERMILION_CITY",
|
||||||
|
"disabling the owner restores vanilla ejection")
|
||||||
|
T.eq(run.hooks.chains["map.occupancy_allowed"], nil,
|
||||||
|
"uninstall removes the occupancy chain itself")
|
||||||
|
T.eq(run.save.marker, "save-must-not-change",
|
||||||
|
"disable/uninstall writes no persistent permission state")
|
||||||
|
T.same(run.save, {
|
||||||
|
version = "yellow", flags = { EVENT_SS_ANNE_LEFT = true },
|
||||||
|
marker = "save-must-not-change",
|
||||||
|
}, "disable/uninstall preserves the complete Yellow save snapshot")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- activeBlockAt is read-only and fail-closed. A successful call exposes
|
||||||
|
-- only one scalar from the active runtime layout, never its backing table.
|
||||||
|
local function blockApi(version, blockAt)
|
||||||
|
local backing = { 4, 5, 6, 8, 9, 10 }
|
||||||
|
local map = {
|
||||||
|
id = "VERMILION_DOCK",
|
||||||
|
def = { width = 3, height = 2, blocks = backing },
|
||||||
|
blockAt = blockAt or function(_, bx, by)
|
||||||
|
return backing[by * 3 + bx + 1]
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local world = { isOverworld = true, map = map }
|
||||||
|
local game = {
|
||||||
|
save = { version = version },
|
||||||
|
stack = { states = { world } },
|
||||||
|
overworld = world,
|
||||||
|
}
|
||||||
|
return WorldAPI.new(game, "contract_fixture"), backing, game, map
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
for _, version in ipairs(VERSIONS) do
|
||||||
|
local api, backing = blockApi(version)
|
||||||
|
local block, err = api:activeBlockAt("VERMILION_DOCK", 1, 0)
|
||||||
|
T.eq(block, 5, version .. " reads its active dock block")
|
||||||
|
T.eq(err, nil, version .. " valid active block has no error")
|
||||||
|
block = 99
|
||||||
|
T.eq(backing[2], 5, version .. " scalar result cannot mutate the map")
|
||||||
|
end
|
||||||
|
|
||||||
|
local api = blockApi("red")
|
||||||
|
local wrong, wrongErr = api:activeBlockAt("VERMILION_CITY", 1, 0)
|
||||||
|
T.eq(wrong, nil, "wrong map has no block result")
|
||||||
|
T.eq(wrongErr, "map is not active", "wrong map fails closed explicitly")
|
||||||
|
|
||||||
|
local invalid = {
|
||||||
|
{ "nil x", nil, 0 }, { "string x", "1", 0 }, { "table x", {}, 0 },
|
||||||
|
{ "fraction x", 0.5, 0 }, { "negative infinity x", -math.huge, 0 },
|
||||||
|
{ "infinity y", 0, math.huge }, { "NaN y", 0, 0 / 0 },
|
||||||
|
}
|
||||||
|
for _, case in ipairs(invalid) do
|
||||||
|
local value, err = api:activeBlockAt("VERMILION_DOCK", case[2], case[3])
|
||||||
|
T.eq(value, nil, case[1] .. " returns no block")
|
||||||
|
T.eq(err, "invalid block coordinates", case[1] .. " is rejected by type")
|
||||||
|
end
|
||||||
|
for _, coords in ipairs({ { -1, 0 }, { 0, -1 }, { 3, 0 }, { 0, 2 } }) do
|
||||||
|
local value, err = api:activeBlockAt("VERMILION_DOCK", coords[1], coords[2])
|
||||||
|
T.eq(value, nil, "out-of-bounds coordinate returns no block")
|
||||||
|
T.eq(err, "block coordinates out of bounds", "bounds fail closed explicitly")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local malformed = {
|
||||||
|
{ label = "nil", get = function() return nil end },
|
||||||
|
{ label = "negative", get = function() return -1 end },
|
||||||
|
{ label = "fractional", get = function() return 1.5 end },
|
||||||
|
{ label = "infinite", get = function() return math.huge end },
|
||||||
|
{ label = "NaN", get = function() return 0 / 0 end },
|
||||||
|
{ label = "string", get = function() return "4" end },
|
||||||
|
{ label = "table", get = function() return {} end },
|
||||||
|
{ label = "throwing", get = function() error("bad map", 0) end },
|
||||||
|
}
|
||||||
|
for _, case in ipairs(malformed) do
|
||||||
|
local api = blockApi("red", case.get)
|
||||||
|
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "malformed active block " .. case.label .. " returns no value")
|
||||||
|
T.eq(err, "block unavailable",
|
||||||
|
"malformed active block " .. case.label .. " fails closed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
-- Invalid map shapes are untrusted runtime data too. None may escape as a
|
||||||
|
-- block or raise through the mod facade.
|
||||||
|
do
|
||||||
|
local badDefs = {
|
||||||
|
{ label = "missing def", value = nil },
|
||||||
|
{ label = "missing width", value = { height = 2, blocks = {} } },
|
||||||
|
{ label = "string width", value = { width = "3", height = 2, blocks = {} } },
|
||||||
|
{ label = "fractional width", value = { width = 1.5, height = 2, blocks = {} } },
|
||||||
|
{ label = "nonpositive width", value = { width = 0, height = 2, blocks = {} } },
|
||||||
|
{ label = "infinite height", value = { width = 3, height = math.huge, blocks = {} } },
|
||||||
|
{ label = "missing blocks", value = { width = 3, height = 2 } },
|
||||||
|
{ label = "scalar blocks", value = { width = 3, height = 2, blocks = 4 } },
|
||||||
|
}
|
||||||
|
for _, case in ipairs(badDefs) do
|
||||||
|
local api, _, _, map = blockApi("red")
|
||||||
|
map.def = case.value
|
||||||
|
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, case.label .. " returns no block")
|
||||||
|
T.eq(err, "block unavailable", case.label .. " fails closed")
|
||||||
|
end
|
||||||
|
|
||||||
|
local api, _, _, map = blockApi("red")
|
||||||
|
map.blockAt = nil
|
||||||
|
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "missing blockAt returns no block")
|
||||||
|
T.eq(err, "block unavailable", "missing blockAt fails closed")
|
||||||
|
map.blockAt = "not a function"
|
||||||
|
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "malformed blockAt returns no block")
|
||||||
|
T.eq(err, "block unavailable", "malformed blockAt fails closed")
|
||||||
|
|
||||||
|
api, _, _, map = blockApi("red")
|
||||||
|
map.def.blocks = {}
|
||||||
|
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "sparse stored block slot returns no block")
|
||||||
|
T.eq(err, "block unavailable", "sparse stored block slot fails closed")
|
||||||
|
|
||||||
|
api, _, _, map = blockApi("red")
|
||||||
|
map.def.blocks[1] = "4"
|
||||||
|
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "malformed stored block slot returns no block")
|
||||||
|
T.eq(err, "block unavailable", "malformed stored block slot fails closed")
|
||||||
|
|
||||||
|
api, _, _, map = blockApi("red", function() return 5 end)
|
||||||
|
block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "stored/accessor mismatch returns no block")
|
||||||
|
T.eq(err, "block unavailable", "stored/accessor mismatch fails closed")
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local api = WorldAPI.new({ stack = { states = {} } }, "contract_fixture")
|
||||||
|
local block, err = api:activeBlockAt("VERMILION_DOCK", 0, 0)
|
||||||
|
T.eq(block, nil, "no-overworld lookup returns no block")
|
||||||
|
T.eq(err, "no overworld", "no-overworld lookup reports its state")
|
||||||
|
end
|
||||||
|
|
||||||
|
Runtime.hooks = oldHooks
|
||||||
|
package.loaded["src.render.TextBox"] = oldTextBox
|
||||||
|
T.finish("mew dock seam contract")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- No-mod and API-v1 parity for the additive mod.developer surface.
|
||||||
|
--
|
||||||
|
-- The production break this catches is a developer-mode loader path that
|
||||||
|
-- mutates vanilla data, creates mod state, or changes existing API-v1
|
||||||
|
-- behavior merely because the new public signal exists.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
local function pristine()
|
||||||
|
return {
|
||||||
|
pokemon = { KEEP = { hp = 7 } },
|
||||||
|
moves = {},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, dev in ipairs({ false, true }) do
|
||||||
|
local data = pristine()
|
||||||
|
local files = {}
|
||||||
|
local run = T.sdk.loadNone({
|
||||||
|
data = data,
|
||||||
|
fs = T.sdk.memfs(files),
|
||||||
|
dev = dev,
|
||||||
|
})
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"no-mod load stays clean with developer=" .. tostring(dev))
|
||||||
|
T.eq(next(run.loader.mods), nil,
|
||||||
|
"no-mod load discovers nothing with developer=" .. tostring(dev))
|
||||||
|
T.eq(data.pokemon.KEEP.hp, 7,
|
||||||
|
"no-mod load preserves vanilla data with developer=" .. tostring(dev))
|
||||||
|
T.eq(next(files), nil,
|
||||||
|
"no-mod load creates no files with developer=" .. tostring(dev))
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
local V1 = {
|
||||||
|
["mods/v1_probe/manifest.json"] = [[{
|
||||||
|
"id": "v1_probe",
|
||||||
|
"name": "V1 Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 1
|
||||||
|
}]],
|
||||||
|
["mods/v1_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.exports.identity = mod.id .. "@" .. mod.version
|
||||||
|
mod.exports.payload = mod:read("payload.txt")
|
||||||
|
mod.options:define({ { key = "enabled", type = "toggle", default = true } })
|
||||||
|
mod.exports.defaultOption = mod.options:get("enabled")
|
||||||
|
]],
|
||||||
|
["mods/v1_probe/payload.txt"] = "unchanged-v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
local legacy = T.sdk.loadMods({ "mods/v1_probe" }, {
|
||||||
|
fs = T.sdk.memfs(V1),
|
||||||
|
dev = false,
|
||||||
|
})
|
||||||
|
T.eq(#legacy.errors, 0, "existing API-v1 mod loads unchanged")
|
||||||
|
local out = legacy.loader.exports.v1_probe
|
||||||
|
T.eq(out.identity, "v1_probe@1.0.0", "API-v1 identity stays unchanged")
|
||||||
|
T.eq(out.payload, "unchanged-v1", "API-v1 mod:read stays unchanged")
|
||||||
|
T.eq(out.defaultOption, true, "API-v1 options stay unchanged")
|
||||||
|
legacy.release()
|
||||||
|
|
||||||
|
T.finish("mod developer mode parity")
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
-- Public load-time developer-mode signal for sandboxed mods.
|
||||||
|
--
|
||||||
|
-- The production break this catches is a loader that computes dev mode but
|
||||||
|
-- does not expose the same fixed answer to the public mod object before the
|
||||||
|
-- entry chunk runs. It also protects the data-only contract: the public
|
||||||
|
-- value is a boolean snapshot, not a live loader or environment handle.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
|
||||||
|
local FILES = {
|
||||||
|
["mods/dev_probe/manifest.json"] = [[{
|
||||||
|
"id": "dev_probe",
|
||||||
|
"name": "Developer Mode Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2,
|
||||||
|
"games": ["all"]
|
||||||
|
}]],
|
||||||
|
["mods/dev_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.exports.seenAtLoad = mod.developer
|
||||||
|
mod.exports.kind = type(mod.developer)
|
||||||
|
if mod.developer then
|
||||||
|
mod.commands:register("dev_probe:diagnostics", function() return true end)
|
||||||
|
end
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
local function load(dev, generation)
|
||||||
|
return T.sdk.loadMods({ "mods/dev_probe" }, {
|
||||||
|
fs = T.sdk.memfs(FILES),
|
||||||
|
dev = dev,
|
||||||
|
generation = generation,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local run = load(true)
|
||||||
|
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
|
||||||
|
local out = run.loader.exports.dev_probe
|
||||||
|
T.eq(out.seenAtLoad, true,
|
||||||
|
"sandboxed entry code sees developer mode at load time")
|
||||||
|
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
|
||||||
|
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||||
|
"entry code can register diagnostics only in developer mode")
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local run = load(false)
|
||||||
|
T.eq(#run.errors, 0, "production-mode public probe loads clean")
|
||||||
|
local out = run.loader.exports.dev_probe
|
||||||
|
T.eq(out.seenAtLoad, false,
|
||||||
|
"sandboxed entry code sees production mode at load time")
|
||||||
|
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
|
||||||
|
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||||
|
"production load does not register developer diagnostics")
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, provided in ipairs({ "yes", 1 }) do
|
||||||
|
local run = load(provided)
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
|
||||||
|
local out = run.loader.exports.dev_probe
|
||||||
|
T.eq(out.seenAtLoad, false,
|
||||||
|
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
|
||||||
|
T.eq(out.kind, "boolean",
|
||||||
|
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
|
||||||
|
T.eq(run.loader.dev, false,
|
||||||
|
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
|
||||||
|
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||||
|
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local run = load(true, 2)
|
||||||
|
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
|
||||||
|
local out = run.loader.exports.dev_probe
|
||||||
|
T.eq(out.seenAtLoad, true,
|
||||||
|
"Gen 2 entry code sees the same developer-mode answer")
|
||||||
|
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||||
|
"Gen 2 entry code can gate diagnostics on the same signal")
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local saved = _G.POKEPORT_DEV_MODE
|
||||||
|
_G.POKEPORT_DEV_MODE = true
|
||||||
|
local ok, run = pcall(load, nil)
|
||||||
|
_G.POKEPORT_DEV_MODE = saved
|
||||||
|
if not ok then error(run, 0) end
|
||||||
|
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
|
||||||
|
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
|
||||||
|
"the --developer boot decision reaches the public signal")
|
||||||
|
run.release()
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("mod developer mode public API")
|
||||||
@@ -182,11 +182,20 @@ check(checkSrc:match('cmd%.cmd == "quit"%s*then%s*\n%s*break') ~= nil,
|
|||||||
local mainSrc = source("main.lua")
|
local mainSrc = source("main.lua")
|
||||||
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
|
local quitHook = mainSrc:match("\nfunction love%.quit%(%).-\nend\n")
|
||||||
check(quitHook ~= nil, "love.quit is still a single top-level function")
|
check(quitHook ~= nil, "love.quit is still a single top-level function")
|
||||||
quitHook = quitHook or ""
|
check(mainSrc:find("SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||||
check(quitHook:find('package.loaded["src.core.ChipAudio"].shutdown', 1, true) ~= nil,
|
"love.quit shuts workers down via SessionLifecycle.endProcess")
|
||||||
"love.quit shuts the chip worker down")
|
|
||||||
check(quitHook:find('package.loaded["src.update.Check"].shutdown', 1, true) ~= nil,
|
local lifecycleSrc = source("src/core/SessionLifecycle.lua")
|
||||||
"love.quit shuts the update worker down")
|
check(lifecycleSrc:find("registerProcessShutdown", 1, true) ~= nil,
|
||||||
|
"SessionLifecycle exposes registerProcessShutdown")
|
||||||
|
check(lifecycleSrc:find("function SessionLifecycle.endProcess()", 1, true) ~= nil,
|
||||||
|
"SessionLifecycle.endProcess fans out registered hooks")
|
||||||
|
check(source("src/core/ChipAudio.lua"):find("registerProcessShutdown(ChipAudio.shutdown)", 1, true) ~= nil,
|
||||||
|
"ChipAudio registers its shutdown hook at load")
|
||||||
|
check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdown)", 1, true) ~= nil,
|
||||||
|
"Check registers its shutdown hook at load")
|
||||||
|
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
||||||
|
"Fetch registers its shutdown hook at load")
|
||||||
|
|
||||||
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
||||||
-- so the quit event exits the process outright. It has to sit after the
|
-- so the quit event exits the process outright. It has to sit after the
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
-- The cache contract is the shared Lua-side publication boundary. A writer
|
||||||
|
-- may stage outputs in any order, but readiness is published only after the
|
||||||
|
-- version-specific required set exists.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check = T.check
|
||||||
|
local eq = T.eq
|
||||||
|
local CacheContract = require("src.import.CacheContract")
|
||||||
|
|
||||||
|
local fs = { prefix = "initial/", files = {} }
|
||||||
|
local writes = {}
|
||||||
|
function fs.exists(path)
|
||||||
|
return fs.files[fs.prefix .. path] ~= nil
|
||||||
|
end
|
||||||
|
function fs.read(path)
|
||||||
|
return fs.files[fs.prefix .. path]
|
||||||
|
end
|
||||||
|
function fs.write(path, value)
|
||||||
|
writes[#writes + 1] = fs.prefix .. path
|
||||||
|
fs.files[fs.prefix .. path] = value
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
function fs.remove(path)
|
||||||
|
fs.files[fs.prefix .. path] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local required, isOverride = CacheContract.requiredFilesFor("red")
|
||||||
|
check(not isOverride, "Red uses the shared required-file list")
|
||||||
|
check(#required > 0, "Red has required outputs")
|
||||||
|
eq(CacheContract.markerFor("red"),
|
||||||
|
CacheContract.FORMAT .. "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||||
|
"marker contains format and Red SHA-1")
|
||||||
|
|
||||||
|
for index = 1, #required - 1 do
|
||||||
|
fs.files["red/" .. required[index]] = true
|
||||||
|
end
|
||||||
|
local complete, missing = CacheContract.allRequiredFilesExist("red", fs)
|
||||||
|
check(not complete, "missing output keeps cache incomplete")
|
||||||
|
eq(missing, required[#required], "missing output is reported")
|
||||||
|
local published, publishError = CacheContract.publish("red", fs)
|
||||||
|
check(not published, "incomplete cache is not published")
|
||||||
|
check(publishError ~= nil, "incomplete publication explains the missing output")
|
||||||
|
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
|
||||||
|
"incomplete cache has no completion marker")
|
||||||
|
|
||||||
|
-- Publication must remove a stale marker left by an interrupted replacement,
|
||||||
|
-- and must restore the caller prefix on both the success and failure paths.
|
||||||
|
fs.files["red/" .. CacheContract.MARKER_PATH] = "old-marker"
|
||||||
|
local removedMarker = CacheContract.publish("red", fs)
|
||||||
|
check(not removedMarker, "incomplete retry is still rejected")
|
||||||
|
check(fs.files["red/" .. CacheContract.MARKER_PATH] == nil,
|
||||||
|
"incomplete retry removes a stale completion marker")
|
||||||
|
eq(fs.prefix, "initial/", "incomplete publication restores the caller prefix")
|
||||||
|
|
||||||
|
fs.files["red/" .. required[#required]] = true
|
||||||
|
fs.prefix = "caller/prefix/"
|
||||||
|
fs.files["red/" .. required[#required]] = true
|
||||||
|
for _, path in ipairs(required) do fs.files["red/" .. path] = true end
|
||||||
|
published, publishError = CacheContract.publish("red", fs)
|
||||||
|
check(published, "complete cache is published")
|
||||||
|
eq(publishError, nil, "complete publication has no error")
|
||||||
|
eq(fs.prefix, "caller/prefix/", "publication restores the caller prefix")
|
||||||
|
eq(fs.files["red/" .. CacheContract.MARKER_PATH], CacheContract.markerFor("red"),
|
||||||
|
"marker is written under the version prefix")
|
||||||
|
local marker = CacheContract.readMarker("red", fs)
|
||||||
|
eq(marker, CacheContract.markerFor("red"), "marker reads through the version prefix")
|
||||||
|
eq(writes[#writes], "red/" .. CacheContract.MARKER_PATH,
|
||||||
|
"the marker is the only publication write and comes last")
|
||||||
|
|
||||||
|
-- Every supported version gets its own marker and complete cache semantics;
|
||||||
|
-- Yellow adds its three outputs, while Gold/Silver replace the Gen 1 set.
|
||||||
|
for _, version in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
|
||||||
|
local versionFiles, override = CacheContract.requiredFilesFor(version)
|
||||||
|
for _, path in ipairs(versionFiles) do
|
||||||
|
fs.files[version .. "/" .. path] = true
|
||||||
|
end
|
||||||
|
if not override then
|
||||||
|
for _, path in ipairs(CacheContract.VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
|
fs.files[version .. "/" .. path] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ready, missing = CacheContract.allRequiredFilesExist(version, fs)
|
||||||
|
check(ready, version .. " complete cache is ready (" .. tostring(missing) .. ")")
|
||||||
|
local didPublish = CacheContract.publish(version, fs)
|
||||||
|
check(didPublish, version .. " complete cache publishes")
|
||||||
|
eq(fs.files[version .. "/" .. CacheContract.MARKER_PATH],
|
||||||
|
CacheContract.markerFor(version), version .. " marker is version-scoped")
|
||||||
|
eq(fs.prefix, "caller/prefix/", version .. " publication restores prefix")
|
||||||
|
check(CacheContract.isReady(version, fs), version .. " complete cache is ready")
|
||||||
|
end
|
||||||
|
|
||||||
|
local gold, goldOverride = CacheContract.requiredFilesFor("gold")
|
||||||
|
check(goldOverride, "Gold uses a version-specific required set")
|
||||||
|
local goldSet = {}
|
||||||
|
for _, path in ipairs(gold) do goldSet[path] = true end
|
||||||
|
check(goldSet["assets/generated/battle/hud/balls.png"],
|
||||||
|
"Gold required set includes trainer HUD art")
|
||||||
|
check(not goldSet["assets/generated/trade/game_boy.png"],
|
||||||
|
"Gold required set excludes Gen 1 trade art")
|
||||||
|
check(goldSet["data/generated/rom_text.lua"],
|
||||||
|
"Gold required set includes the Gen 2 engine text table")
|
||||||
|
local silver = CacheContract.requiredFilesFor("silver")
|
||||||
|
local silverSet = {}
|
||||||
|
for _, path in ipairs(silver) do silverSet[path] = true end
|
||||||
|
check(silverSet["data/generated/rom_text.lua"],
|
||||||
|
"Silver required set includes the Gen 2 engine text table")
|
||||||
|
check(not silverSet["assets/generated/trade/game_boy.png"],
|
||||||
|
"Silver required set excludes Gen 1 trade art")
|
||||||
|
check(CacheContract.VERSION_REQUIRED_FILES.yellow ~= nil,
|
||||||
|
"Yellow has version-specific required outputs")
|
||||||
|
|
||||||
|
-- A throwing adapter must not strand the process in its temporary prefix.
|
||||||
|
local throwingFs = { prefix = "before/" }
|
||||||
|
function throwingFs.exists() error("probe failed") end
|
||||||
|
local probed, probeError = CacheContract.allRequiredFilesExist("blue", throwingFs)
|
||||||
|
check(not probed and probeError ~= nil, "filesystem probe errors are returned")
|
||||||
|
eq(throwingFs.prefix, "before/", "probe errors restore the caller prefix")
|
||||||
|
function throwingFs.write() error("write failed") end
|
||||||
|
function throwingFs.remove() end
|
||||||
|
for _, path in ipairs(CacheContract.REQUIRED_FILES) do
|
||||||
|
throwingFs.files = throwingFs.files or {}
|
||||||
|
throwingFs.files["blue/" .. path] = true
|
||||||
|
end
|
||||||
|
function throwingFs.exists(path)
|
||||||
|
return throwingFs.files[throwingFs.prefix .. path] ~= nil
|
||||||
|
end
|
||||||
|
local wrote = CacheContract.publish("blue", throwingFs)
|
||||||
|
check(not wrote, "write errors are returned")
|
||||||
|
eq(throwingFs.prefix, "before/", "write errors restore the caller prefix")
|
||||||
|
|
||||||
|
-- Source-tree readiness must use the same version lists and reject a cache
|
||||||
|
-- when LÖVE cannot identify a real source directory.
|
||||||
|
local oldLove = love
|
||||||
|
love = nil
|
||||||
|
check(not CacheContract.sourceTreeHasData("red"),
|
||||||
|
"source-tree check is safe without LÖVE")
|
||||||
|
local sourceFiles = {}
|
||||||
|
love = {
|
||||||
|
filesystem = {
|
||||||
|
getRealDirectory = function(path) return sourceFiles[path] end,
|
||||||
|
getSource = function() return "/source" end,
|
||||||
|
getInfo = function(path)
|
||||||
|
return sourceFiles[path] and { type = "file" } or nil
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, path in ipairs(CacheContract.REQUIRED_FILES) do sourceFiles[path] = "/source" end
|
||||||
|
check(CacheContract.sourceTreeHasData("red"),
|
||||||
|
"Red source tree uses the shared required set")
|
||||||
|
sourceFiles[CacheContract.REQUIRED_FILES[2]] = "/save"
|
||||||
|
check(not CacheContract.sourceTreeHasData("red"),
|
||||||
|
"source-tree readiness rejects a cache-overlaid required file")
|
||||||
|
sourceFiles = {}
|
||||||
|
local goldFiles = CacheContract.requiredFilesFor("gold")
|
||||||
|
for _, path in ipairs(goldFiles) do sourceFiles["gold/" .. path] = "/source" end
|
||||||
|
check(CacheContract.sourceTreeHasData("gold"),
|
||||||
|
"Gold source tree uses its override set")
|
||||||
|
love = oldLove
|
||||||
|
|
||||||
|
-- Both importer completion paths must call the shared publication boundary.
|
||||||
|
local importerFile = assert(io.open("src/import/RomImporter.lua", "r"))
|
||||||
|
local importerSource = importerFile:read("*a")
|
||||||
|
importerFile:close()
|
||||||
|
local completionCalls = 0
|
||||||
|
for _ in importerSource:gmatch("CacheContract%.publish%(%s*version") do
|
||||||
|
completionCalls = completionCalls + 1
|
||||||
|
end
|
||||||
|
eq(completionCalls, 1,
|
||||||
|
"both thread and coroutine paths converge on one publishing helper")
|
||||||
|
check(importerSource:find("self:_completeImport%(version, prefix, displayName%)")
|
||||||
|
~= nil, "coroutine completion uses the shared helper")
|
||||||
|
check(importerSource:find("pcall%(self%._completeImport") ~= nil,
|
||||||
|
"thread completion uses the shared helper")
|
||||||
|
|
||||||
|
T.finish("rom cache contract")
|
||||||
@@ -1,32 +1,34 @@
|
|||||||
-- sourceTreeHasData must use each version's required-file list. Gold's
|
-- sourceTreeHasData must use the engine-owned cache contract. Gold's cache has
|
||||||
-- cache has no Gen 1 trade art / pikachu.png; validating it against
|
-- no Gen 1 trade art; validating it against the Gen 1 list made a Gold source
|
||||||
-- REQUIRED_FILES made a Gold source tree look incomplete forever.
|
-- tree look incomplete forever.
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
local T = require("tests.harness")
|
local T = require("tests.harness")
|
||||||
local check = T.check
|
local check = T.check
|
||||||
|
local CacheContract = require("src.import.CacheContract")
|
||||||
|
|
||||||
local f = assert(io.open("src/import/RomImporter.lua", "r"))
|
local f = assert(io.open("src/import/RomImporter.lua", "r"))
|
||||||
local src = f:read("*a")
|
local src = f:read("*a")
|
||||||
f:close()
|
f:close()
|
||||||
|
|
||||||
local start = src:find("local function sourceTreeHasData", 1, true)
|
local readyStart = src:find("function RomImporter.isReady", 1, true)
|
||||||
check(start ~= nil, "sourceTreeHasData is defined")
|
check(readyStart ~= nil, "isReady is defined")
|
||||||
local finish = src:find("\nfunction RomImporter.isReady", start, true)
|
local readyEnd = src:find("\nfunction RomImporter.syncAndroidShortcuts", readyStart, true)
|
||||||
check(finish ~= nil, "sourceTreeHasData ends before isReady")
|
check(readyEnd ~= nil, "isReady ends before the next importer helper")
|
||||||
local body = src:sub(start, finish)
|
local readyBody = src:sub(readyStart, readyEnd)
|
||||||
|
|
||||||
check(body:find("requiredFilesFor", 1, true) ~= nil,
|
check(readyBody:find("CacheContract.isReady", 1, true) ~= nil,
|
||||||
"sourceTreeHasData uses requiredFilesFor (Gold override, not Gen 1 only)")
|
"isReady delegates source-tree and cache readiness to the contract")
|
||||||
check(body:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
|
check(readyBody:find("ipairs(REQUIRED_FILES)", 1, true) == nil,
|
||||||
"sourceTreeHasData does not iterate the Gen 1 REQUIRED_FILES list raw")
|
"isReady does not iterate the Gen 1 REQUIRED_FILES list raw")
|
||||||
|
|
||||||
local helperStart = src:find("local function requiredFilesFor", 1, true)
|
local required, isOverride = CacheContract.requiredFilesFor("gold")
|
||||||
check(helperStart ~= nil, "requiredFilesFor helper exists")
|
check(isOverride, "Gold uses the override required-file list")
|
||||||
local helper = src:sub(helperStart, start)
|
local requiredSet = {}
|
||||||
check(helper:find("VERSION_REQUIRED_FILES_OVERRIDE", 1, true) ~= nil,
|
for _, path in ipairs(required) do requiredSet[path] = true end
|
||||||
"requiredFilesFor consults VERSION_REQUIRED_FILES_OVERRIDE")
|
check(requiredSet["assets/generated/battle/hud/balls.png"],
|
||||||
check(src:find('"assets/generated/battle/hud/balls.png"', 1, true) ~= nil,
|
|
||||||
"Gold caches require the trainer HUD ball sheet")
|
"Gold caches require the trainer HUD ball sheet")
|
||||||
|
check(not requiredSet["assets/generated/trade/game_boy.png"],
|
||||||
|
"Gold does not inherit the Gen 1 trade-art requirement")
|
||||||
|
|
||||||
T.finish()
|
T.finish()
|
||||||
|
|||||||
@@ -84,15 +84,15 @@ if extractor then
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- a cache imported before #750 has none of the art; listing one of the
|
-- a cache imported before #750 has none of the art; listing one of the
|
||||||
-- files in REQUIRED_FILES is what makes it re-import
|
-- files in the engine-owned cache contract is what makes it re-import
|
||||||
local importer = readFile("src/import/RomImporter.lua")
|
local contract = readFile("src/import/CacheContract.lua")
|
||||||
T.check(importer ~= nil, "src/import/RomImporter.lua is readable")
|
T.check(contract ~= nil, "src/import/CacheContract.lua is readable")
|
||||||
if importer then
|
if contract then
|
||||||
local required = importer:match("local REQUIRED_FILES = {(.-)\n}")
|
local required = contract:match("CacheContract.REQUIRED_FILES = {(.-)\n}")
|
||||||
T.check(required ~= nil, "REQUIRED_FILES parses")
|
T.check(required ~= nil, "CacheContract.REQUIRED_FILES parses")
|
||||||
T.check(required ~= nil and required:find(
|
T.check(required ~= nil and required:find(
|
||||||
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
|
'"assets/generated/trade/game_boy.png"', 1, true) ~= nil,
|
||||||
"REQUIRED_FILES makes pre-#750 caches re-import the trade art")
|
"cache contract makes pre-#750 caches re-import the trade art")
|
||||||
end
|
end
|
||||||
|
|
||||||
T.finish("trade art import")
|
T.finish("trade art import")
|
||||||
|
|||||||
@@ -216,8 +216,12 @@ for name, module in pairs({ Assets = Assets, TileRenderer = TileRenderer,
|
|||||||
end
|
end
|
||||||
check(type(require("src.world.MapLoader").invalidateAll) == "function",
|
check(type(require("src.world.MapLoader").invalidateAll) == "function",
|
||||||
"MapLoader keeps its wave-1 invalidateAll")
|
"MapLoader keeps its wave-1 invalidateAll")
|
||||||
|
check(type(require("src.world.MapLoader").releaseAll) == "function",
|
||||||
|
"MapLoader exposes releaseAll for session end")
|
||||||
check(type(require("src.core.Sound").invalidate) == "function",
|
check(type(require("src.core.Sound").invalidate) == "function",
|
||||||
"Sound keeps its wave-1 invalidate")
|
"Sound keeps its wave-1 invalidate")
|
||||||
|
check(type(Assets.releaseSession) == "function",
|
||||||
|
"Assets exposes releaseSession for in-process session end")
|
||||||
|
|
||||||
-- the central cache hands back one image per resolved path, and flush
|
-- the central cache hands back one image per resolved path, and flush
|
||||||
-- fans out to every registered downstream cache
|
-- fans out to every registered downstream cache
|
||||||
@@ -238,6 +242,14 @@ Assets.register(function() reached = true end)
|
|||||||
Assets.flush()
|
Assets.flush()
|
||||||
check(reached, "a throwing invalidator does not stop the fan-out")
|
check(reached, "a throwing invalidator does not stop the fan-out")
|
||||||
|
|
||||||
|
-- flush/invalidate must not run release hooks (HotReload / live overworld safe)
|
||||||
|
local releaseCalls = 0
|
||||||
|
Assets.register({ release = function() releaseCalls = releaseCalls + 1 end })
|
||||||
|
Assets.flush()
|
||||||
|
check(releaseCalls == 0, "flush() does not call release hooks")
|
||||||
|
Assets.releaseSession()
|
||||||
|
check(releaseCalls == 1, "releaseSession() calls registered release hooks")
|
||||||
|
|
||||||
-- ------- animated tiles as tileset data
|
-- ------- animated tiles as tileset data
|
||||||
|
|
||||||
local overworld = TileRenderer.defaultAnimatedTiles(
|
local overworld = TileRenderer.defaultAnimatedTiles(
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- A sandboxed mod can contribute data-only field residual damage while the
|
||||||
|
-- engine retains HP, queue, and faint authority. The case also proves that
|
||||||
|
-- no-mod battles allocate no hook context and remain byte-equivalent.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/field_residual_probe/manifest.json"] = [[{
|
||||||
|
"id": "field_residual_probe",
|
||||||
|
"name": "Field Residual Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/field_residual_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.hooks:wrap("battle.field_residual", function(next, context)
|
||||||
|
mod.exports.calls = (mod.exports.calls or 0) + 1
|
||||||
|
mod.exports.context = context
|
||||||
|
local callback = context.field.tokens[1]
|
||||||
|
and context.field.tokens[1].onExpire
|
||||||
|
mod.exports.callbackType = type(callback)
|
||||||
|
if callback then callback() end
|
||||||
|
local rows = next(context)
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "player", amount = 7,
|
||||||
|
message = context.battlers.player.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
side = "enemy", amount = 999,
|
||||||
|
message = context.battlers.enemy.name .. " is buffeted!",
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
end)
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
local function newBattle(data)
|
||||||
|
TypeChart.load(data)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(data, "FIXMON_A", 30) }
|
||||||
|
local game = {
|
||||||
|
data = data,
|
||||||
|
save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end },
|
||||||
|
}
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||||
|
battle.phase, battle.queue = "menu", {}
|
||||||
|
battle.field.weather = { id = "sand", turns = 4, source = "probe" }
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanilla = T.sdk.loadNone({})
|
||||||
|
local plain = newBattle(vanilla.data)
|
||||||
|
local plainPlayerHp, plainEnemyHp = plain.player.mon.hp, plain.enemy.mon.hp
|
||||||
|
plain:endOfTurn()
|
||||||
|
T.eq(plain.player.mon.hp, plainPlayerHp,
|
||||||
|
"no-mod end of turn preserves player HP")
|
||||||
|
T.eq(plain.enemy.mon.hp, plainEnemyHp,
|
||||||
|
"no-mod end of turn preserves enemy HP")
|
||||||
|
T.eq(plain.field.weather.turns, 4,
|
||||||
|
"no-mod path does not reinterpret an unknown data-only field extension")
|
||||||
|
vanilla.release()
|
||||||
|
|
||||||
|
local run = T.sdk.loadMods({ "mods/field_residual_probe" }, {
|
||||||
|
fs = T.sdk.memfs(FIXTURE),
|
||||||
|
})
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"the public field-residual probe loads cleanly")
|
||||||
|
local battle = newBattle(run.data)
|
||||||
|
local playerHp, enemyHp = battle.player.mon.hp, battle.enemy.mon.hp
|
||||||
|
local callbackInvocations = 0
|
||||||
|
battle.field.tokens[1] = {
|
||||||
|
id = "callback-bearing", turns = 2,
|
||||||
|
state = { intensity = 4 },
|
||||||
|
onExpire = function() callbackInvocations = callbackInvocations + 1 end,
|
||||||
|
}
|
||||||
|
battle.player.invulnerable = true
|
||||||
|
battle:endOfTurn()
|
||||||
|
|
||||||
|
local out = run.loader.exports.field_residual_probe or {}
|
||||||
|
T.eq(out.calls, 1, "the public hook runs exactly once at round end")
|
||||||
|
T.check(out.context.field ~= battle.field,
|
||||||
|
"the hook receives a detached checkpoint-shaped field view")
|
||||||
|
T.same(out.context.field.weather,
|
||||||
|
{ id = "sand", turns = 4, source = "probe" },
|
||||||
|
"the detached field view carries checkpointed weather state")
|
||||||
|
T.eq(out.context.field.sides, nil,
|
||||||
|
"the detached field view exposes no live battler aliases")
|
||||||
|
T.eq(out.callbackType, "nil",
|
||||||
|
"the sandboxed wrapper cannot obtain a live field callback")
|
||||||
|
T.eq(callbackInvocations, 0,
|
||||||
|
"the sandboxed wrapper cannot invoke the engine-owned callback")
|
||||||
|
T.same(out.context.field.tokens[1], {
|
||||||
|
id = "callback-bearing", turns = 2, state = { intensity = 4 },
|
||||||
|
}, "the public field view retains data while omitting the callback")
|
||||||
|
T.eq(out.context.turn, battle.turnCount or 0,
|
||||||
|
"the hook receives the current turn counter")
|
||||||
|
T.eq(out.context.battle, nil,
|
||||||
|
"the hook does not expose the live engine battle object")
|
||||||
|
T.eq(out.context.battlers.player.side, "player",
|
||||||
|
"the detached player snapshot identifies its side")
|
||||||
|
T.eq(out.context.battlers.enemy.side, "enemy",
|
||||||
|
"the detached enemy snapshot identifies its side")
|
||||||
|
T.eq(out.context.battlers.player.vanished, true,
|
||||||
|
"the detached view reports Gen1 semi-invulnerability")
|
||||||
|
T.eq(out.context.battlers.player.hp, playerHp,
|
||||||
|
"the player snapshot carries pre-residual HP")
|
||||||
|
T.eq(out.context.battlers.enemy.hp, enemyHp,
|
||||||
|
"the enemy snapshot carries pre-residual HP")
|
||||||
|
T.check(out.context.battlers.player ~= battle.player,
|
||||||
|
"the public battler view is detached from the engine wrapper")
|
||||||
|
T.check(out.context.battlers.player.types ~= battle.player.curTypes,
|
||||||
|
"the public type list is detached")
|
||||||
|
|
||||||
|
T.eq(battle.player.mon.hp, playerHp - 7,
|
||||||
|
"the engine applies the validated player residual amount")
|
||||||
|
T.eq(battle.enemy.mon.hp, 0,
|
||||||
|
"the engine clamps residual damage to current HP")
|
||||||
|
T.eq(battle.enemy.faintQueued, true,
|
||||||
|
"the engine, not the mod, owns residual faint orchestration")
|
||||||
|
|
||||||
|
local sawPlayerMessage, sawEnemyMessage, drains = false, false, 0
|
||||||
|
for _, row in ipairs(battle.queue) do
|
||||||
|
local text = row.text and tostring(row.text) or ""
|
||||||
|
if text:find("buffeted", 1, true) then
|
||||||
|
if text:find(battle.player.name, 1, true) then sawPlayerMessage = true end
|
||||||
|
if text:find(battle.enemy.name, 1, true) then sawEnemyMessage = true end
|
||||||
|
end
|
||||||
|
if row.drain then drains = drains + 1 end
|
||||||
|
end
|
||||||
|
T.check(sawPlayerMessage and sawEnemyMessage,
|
||||||
|
"validated public messages enter the normal battle queue")
|
||||||
|
T.check(drains >= 2,
|
||||||
|
"residual HP changes use normal engine drain rows")
|
||||||
|
run.release()
|
||||||
|
|
||||||
|
T.finish("battle.field_residual public seam")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- A sandboxed mod can read the outcome of a link battle -- who won, and the
|
||||||
|
-- lockstep party copies -- through the public event surface.
|
||||||
|
--
|
||||||
|
-- The copies are the point. Cable rules leave the real party untouched, so
|
||||||
|
-- a mode built on link battles (a tournament ladder, a battle royale) has no
|
||||||
|
-- other way to learn what the fight cost; by the time the state unwinds the
|
||||||
|
-- battle object is gone.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local LinkState = require("src.link.LinkState")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/outcome_probe/manifest.json"] = [[{
|
||||||
|
"id": "outcome_probe",
|
||||||
|
"name": "Outcome Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/outcome_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.exports.seen = 0
|
||||||
|
mod.events:on("link.battle_ended", function(ev)
|
||||||
|
mod.exports.seen = mod.exports.seen + 1
|
||||||
|
mod.exports.result = ev.result
|
||||||
|
mod.exports.role = ev.role
|
||||||
|
mod.exports.peerName = ev.peerName
|
||||||
|
mod.exports.myLead = ev.myParty and ev.myParty[1]
|
||||||
|
mod.exports.theirLead = ev.theirParty and ev.theirParty[1]
|
||||||
|
end)
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
-- a link session parked at the end of a battle: no transport, so update()
|
||||||
|
-- goes straight to the stage that reports the outcome
|
||||||
|
local function finishedSession(result, isHost)
|
||||||
|
local ls
|
||||||
|
ls = setmetatable({
|
||||||
|
stage = "battleRunning",
|
||||||
|
isHost = isHost,
|
||||||
|
peerName = "BLUE",
|
||||||
|
net = nil,
|
||||||
|
battle = {
|
||||||
|
result = result,
|
||||||
|
playerParty = { { species = "RATTATA", hp = 3 } },
|
||||||
|
enemyParty = { { species = "PIDGEY", hp = 0 } },
|
||||||
|
},
|
||||||
|
game = { input = {}, stack = { top = function() return ls end } },
|
||||||
|
}, { __index = LinkState })
|
||||||
|
-- the real exit unwinds the whole link stack; the event is what is under
|
||||||
|
-- test, so record the exit rather than run it
|
||||||
|
ls.exitWith = function() ls.exited = true end
|
||||||
|
return ls
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- no mod: the battle still ends, nothing observes it
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local vanilla = T.sdk.loadNone({})
|
||||||
|
T.check(not Runtime.wants("link.battle_ended"),
|
||||||
|
"with nothing subscribed the event is not wanted, so no payload is built")
|
||||||
|
local quiet = finishedSession("win", true)
|
||||||
|
quiet:update(0)
|
||||||
|
T.check(quiet.exited, "with no mod loaded the finished battle still unwinds")
|
||||||
|
T.eq(quiet.battle, nil, "and lets go of the battle")
|
||||||
|
vanilla.release()
|
||||||
|
|
||||||
|
-- ------- a mod reads the outcome and both party copies
|
||||||
|
|
||||||
|
local run = T.sdk.loadMods({ "mods/outcome_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"the public outcome probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local session = finishedSession("win", true)
|
||||||
|
session:update(0)
|
||||||
|
local out = run.loader.exports.outcome_probe or {}
|
||||||
|
T.eq(out.seen, 1, "a finished link battle raises the event once")
|
||||||
|
T.eq(out.result, "win", "the outcome is reported")
|
||||||
|
T.eq(out.role, "host", "so is which side of the cable we were")
|
||||||
|
T.eq(out.peerName, "BLUE", "and who we played")
|
||||||
|
T.eq(out.myLead and out.myLead.species, "RATTATA",
|
||||||
|
"the lockstep copy of our party comes with it")
|
||||||
|
T.eq(out.myLead and out.myLead.hp, 3,
|
||||||
|
"carrying the damage the real party never took")
|
||||||
|
T.eq(out.theirLead and out.theirLead.species, "PIDGEY",
|
||||||
|
"and the copy of theirs")
|
||||||
|
T.check(session.exited, "the state unwinds afterwards, as it always did")
|
||||||
|
T.eq(session.battle, nil, "and the battle is released")
|
||||||
|
|
||||||
|
-- the guest side says so
|
||||||
|
local guest = finishedSession("lose", false)
|
||||||
|
guest:update(0)
|
||||||
|
T.eq(run.loader.exports.outcome_probe.seen, 2, "the guest reports too")
|
||||||
|
T.eq(run.loader.exports.outcome_probe.role, "guest", "as the guest")
|
||||||
|
T.eq(run.loader.exports.outcome_probe.result, "lose", "with its own outcome")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
|
||||||
|
T.finish("link_battle_ended")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
-- A sandboxed mod can claim the A press on an object it owns, using only
|
||||||
|
-- public mod surfaces, and an unhooked build still talks to it as before.
|
||||||
|
--
|
||||||
|
-- The seam exists because a runtime object (WorldAPI:spawnNpc) carries no
|
||||||
|
-- TEXT_* id: the vanilla talk path has nothing to say for one, so a mod that
|
||||||
|
-- spawned it has to be able to answer instead.
|
||||||
|
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local FIXTURE = {
|
||||||
|
["mods/talk_probe/manifest.json"] = [[{
|
||||||
|
"id": "talk_probe",
|
||||||
|
"name": "Talk Probe",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"entry": "main.lua",
|
||||||
|
"api": 2
|
||||||
|
}]],
|
||||||
|
["mods/talk_probe/main.lua"] = [[
|
||||||
|
local mod = ...
|
||||||
|
mod.hooks:wrap("world.talk", function(next, ow, target)
|
||||||
|
if target and target.claimedByMod then
|
||||||
|
mod.exports.claimed = target.id
|
||||||
|
return -- deliberately not calling next(): the mod answers instead
|
||||||
|
end
|
||||||
|
return next(ow, target)
|
||||||
|
end)
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
|
||||||
|
-- enough of an overworld to reach the A press: a player facing one cell, an
|
||||||
|
-- object standing on it, and a map with no counter to talk across
|
||||||
|
local function fixtureOverworld(npc)
|
||||||
|
local ow
|
||||||
|
ow = setmetatable({
|
||||||
|
npcs = { npc },
|
||||||
|
player = {
|
||||||
|
facing = "up",
|
||||||
|
facingCell = function() return 4, 5 end,
|
||||||
|
},
|
||||||
|
map = {
|
||||||
|
id = "FIX_ROUTE",
|
||||||
|
isCounterCell = function() return false end,
|
||||||
|
},
|
||||||
|
talked = {},
|
||||||
|
}, { __index = OverworldState })
|
||||||
|
-- the vanilla destination, recorded rather than run: talkTo walks into the
|
||||||
|
-- map's text tables, which a fixture map does not have
|
||||||
|
ow.talkTo = function(_, target) ow.talked[#ow.talked + 1] = target.id end
|
||||||
|
return ow
|
||||||
|
end
|
||||||
|
|
||||||
|
local function objectAt(id, claimed)
|
||||||
|
return { id = id, cellX = 4, cellY = 5, targetX = 4, targetY = 5,
|
||||||
|
moving = false, claimedByMod = claimed or nil, def = {} }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- no mod: the A press lands in the vanilla talk path
|
||||||
|
|
||||||
|
local vanilla = T.sdk.loadNone({})
|
||||||
|
local plain = fixtureOverworld(objectAt("SIGNPOST_MAN"))
|
||||||
|
plain:interact()
|
||||||
|
T.eq(#plain.talked, 1, "with no mod loaded the A press reaches talkTo")
|
||||||
|
T.eq(plain.talked[1], "SIGNPOST_MAN", "and it is handed the object it faced")
|
||||||
|
vanilla.release()
|
||||||
|
|
||||||
|
-- ------- a mod that owns the object answers for it
|
||||||
|
|
||||||
|
local run = T.sdk.loadMods({ "mods/talk_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||||
|
T.eq(#run.errors, 0,
|
||||||
|
"the public talk probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||||
|
|
||||||
|
local owned = fixtureOverworld(objectAt("GHOST_PLAYER", true))
|
||||||
|
owned:interact()
|
||||||
|
local out = run.loader.exports.talk_probe or {}
|
||||||
|
T.eq(out.claimed, "GHOST_PLAYER", "a public hook sees the object it owns")
|
||||||
|
T.eq(#owned.talked, 0,
|
||||||
|
"and a hook that does not call next keeps the vanilla text path out of it")
|
||||||
|
|
||||||
|
-- ...while everything the mod does not own falls straight through
|
||||||
|
local other = fixtureOverworld(objectAt("NURSE_JOY"))
|
||||||
|
other:interact()
|
||||||
|
T.eq(#other.talked, 1, "an object the mod does not claim still reaches talkTo")
|
||||||
|
T.eq(other.talked[1], "NURSE_JOY", "unchanged")
|
||||||
|
|
||||||
|
-- an object mid-step is not talkable in either build (walk_npc.asm), so the
|
||||||
|
-- hook must not fire for one either
|
||||||
|
local walking = fixtureOverworld(objectAt("WALKER", true))
|
||||||
|
walking.npcs[1].moving = true
|
||||||
|
walking:interact()
|
||||||
|
T.eq(run.loader.exports.talk_probe.claimed, "GHOST_PLAYER",
|
||||||
|
"an object mid-step raises no talk hook")
|
||||||
|
T.eq(#walking.talked, 0, "and reaches no talk path at all")
|
||||||
|
|
||||||
|
run.release()
|
||||||
|
|
||||||
|
T.finish("world_talk")
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
if not _G.love then _G.love = require("tests.love_stub") end
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local S = require("tests.harness").suite("parity double faint")
|
local S = require("tests.harness").suite("parity double faint")
|
||||||
local check, eq = S.check, S.eq
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
@@ -107,4 +108,21 @@ do
|
|||||||
check(not saidBlackout(b), "and does not black out")
|
check(not saidBlackout(b), "and does not black out")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- enemyMonFainted is also a native authority path used by move effects. A
|
||||||
|
-- field-residual hook must not change what that path does when no hook is
|
||||||
|
-- installed, even if both active mons are already at zero HP.
|
||||||
|
do
|
||||||
|
Runtime.reset()
|
||||||
|
local b = battleWith({ 0 }, nil)
|
||||||
|
b.player = { mon = b.game.save.party[1] }
|
||||||
|
b.enemy = { mon = { hp = 0 } }
|
||||||
|
b.awards = 0
|
||||||
|
b.awardExp = function(self) self.awards = self.awards + 1 end
|
||||||
|
BattleState.enemyMonFainted(b)
|
||||||
|
eq(b.awards, 1,
|
||||||
|
"no-hook simultaneous faint still enters native enemy EXP authority")
|
||||||
|
eq(b.result, "win",
|
||||||
|
"no-hook simultaneous faint preserves native enemy-faint resolution")
|
||||||
|
end
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
Reference in New Issue
Block a user