mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-26 07:21:22 +02:00
Merge pull request #1746 from campavao/engine-mod-seams
engine: generic seams for mod-driven multiplayer modes
This commit is contained in:
@@ -265,6 +265,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
|
||||||
|
|||||||
@@ -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.
|
||||||
+30
-17
@@ -147,29 +147,42 @@ function Game:bootConfig()
|
|||||||
return boot
|
return boot
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
|
||||||
|
-- can reshape it), the overworld at the skeleton's spawn, and the intro
|
||||||
|
-- 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")
|
||||||
|
while self.stack:top() do self.stack:pop() end
|
||||||
|
-- New Game keeps the standalone options.lua preferences
|
||||||
|
self.sessionStartedAt = os.time()
|
||||||
|
self.save = SaveData.newGame(self:bootConfig())
|
||||||
|
-- no bucket carry-over: mod state from an abandoned session must
|
||||||
|
-- not leak into a fresh slot; mods seed via save.created instead
|
||||||
|
self:adoptSave(self.save)
|
||||||
|
ModRuntime.emit("save.created", { save = self.save })
|
||||||
|
self:applyOptions(self.save.options)
|
||||||
|
self.stack:push(OverworldState, self.save.player.map,
|
||||||
|
self.save.player.x, self.save.player.y,
|
||||||
|
self.save.player.facing,
|
||||||
|
{ via = "boot", freshBoot = true })
|
||||||
|
if not (opts and opts.intro == false) then
|
||||||
|
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
||||||
|
function() end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||||
-- and by the START-menu QUIT confirmation
|
-- and by the START-menu QUIT confirmation
|
||||||
function Game:makeTitleState()
|
function Game:makeTitleState()
|
||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||||
local title = factory.new(self, {
|
local title = factory.new(self, {
|
||||||
onNewGame = function()
|
onNewGame = function() self:startNewGame() end,
|
||||||
while self.stack:top() do self.stack:pop() end
|
|
||||||
-- New Game keeps the standalone options.lua preferences
|
|
||||||
self.sessionStartedAt = os.time()
|
|
||||||
self.save = SaveData.newGame(self:bootConfig())
|
|
||||||
-- no bucket carry-over: mod state from an abandoned session must
|
|
||||||
-- not leak into a fresh slot; mods seed via save.created instead
|
|
||||||
self:adoptSave(self.save)
|
|
||||||
ModRuntime.emit("save.created", { save = self.save })
|
|
||||||
self:applyOptions(self.save.options)
|
|
||||||
self.stack:push(OverworldState, self.save.player.map,
|
|
||||||
self.save.player.x, self.save.player.y,
|
|
||||||
self.save.player.facing,
|
|
||||||
{ via = "boot", freshBoot = true })
|
|
||||||
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
|
|
||||||
function() end)
|
|
||||||
end,
|
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -1969,6 +1969,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,
|
||||||
@@ -1998,7 +2002,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")
|
||||||
@@ -407,6 +408,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
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user