mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Merge branch 'dev' into spidercar2
This commit is contained in:
@@ -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
|
||||
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.**
|
||||
Generation-agnostic; nothing to adapt.
|
||||
**`mod.save`, `mod.options`, `mod.log`, `mod.assets`, `mod.find`,
|
||||
`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
|
||||
(`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:
|
||||
|
||||
- `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
|
||||
`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
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -687,15 +821,38 @@ the hook context.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||
`--developer` on the command line:
|
||||
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 `--developer`
|
||||
on the command line:
|
||||
|
||||
```sh
|
||||
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`,
|
||||
`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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user