mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
feat(mod-api): expose developer mode
This commit is contained in:
@@ -469,8 +469,10 @@ 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.
|
||||||
|
|
||||||
**`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
|
||||||
|
|||||||
@@ -695,6 +695,25 @@ hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
|||||||
love . --developer
|
love . --developer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Every sandboxed entry chunk receives the same boot decision as the boolean
|
||||||
|
`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` through the same engine-owned decision that enables the console and hot
|
||||||
|
reload. Use a mod option for player-facing feature toggles rather than treating
|
||||||
|
developer mode as configuration.
|
||||||
|
|
||||||
While developer mode is active:
|
While developer mode is active:
|
||||||
|
|
||||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# RFC 0017: Public mod developer-mode signal
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
The loader already has one boot-time developer-mode decision. It enables the
|
||||||
|
console and hot reload and can be forced in headless tests, but a sandboxed mod
|
||||||
|
cannot read it. `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`, the
|
||||||
|
`--developer` command-line path, and the loader's existing injected `opts.dev`
|
||||||
|
test seam produce `true` through the same decision that already controls the
|
||||||
|
developer console, hot reload, and loader diagnostics. 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -980,6 +980,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 = {},
|
||||||
|
|||||||
@@ -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,86 @@
|
|||||||
|
-- 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
|
||||||
|
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user