mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
Merge pull request #1769 from MaxTomahawk/adaptive-trainers/mod-developer-mode
feat(mod-api): expose loader developer mode
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
|
||||
|
||||
+27
-4
@@ -761,15 +761,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.
|
||||
|
||||
@@ -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,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.
|
||||
+5
-1
@@ -270,7 +270,7 @@ function Loader.new(opts)
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {}, modEnv = {}, stepsQueues = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
dev = dev == true,
|
||||
safeMode = false,
|
||||
-- Which generation this boot is (1 or 2). Fixed at construction: the
|
||||
-- active version is set once in main.lua's bootGame before anything
|
||||
@@ -980,6 +980,10 @@ function Loader:_api(mod)
|
||||
id = modId,
|
||||
version = mod.manifest.version,
|
||||
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
|
||||
manifest = Merge.deepCopy(mod.manifest),
|
||||
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,102 @@
|
||||
-- Public load-time developer-mode signal for sandboxed mods.
|
||||
--
|
||||
-- The production break this catches is a loader that computes dev mode but
|
||||
-- does not expose the same fixed answer to the public mod object before the
|
||||
-- entry chunk runs. It also protects the data-only contract: the public
|
||||
-- value is a boolean snapshot, not a live loader or environment handle.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local FILES = {
|
||||
["mods/dev_probe/manifest.json"] = [[{
|
||||
"id": "dev_probe",
|
||||
"name": "Developer Mode Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"games": ["all"]
|
||||
}]],
|
||||
["mods/dev_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.seenAtLoad = mod.developer
|
||||
mod.exports.kind = type(mod.developer)
|
||||
if mod.developer then
|
||||
mod.commands:register("dev_probe:diagnostics", function() return true end)
|
||||
end
|
||||
]],
|
||||
}
|
||||
|
||||
local function load(dev, generation)
|
||||
return T.sdk.loadMods({ "mods/dev_probe" }, {
|
||||
fs = T.sdk.memfs(FILES),
|
||||
dev = dev,
|
||||
generation = generation,
|
||||
})
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true)
|
||||
T.eq(#run.errors, 0, "developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"sandboxed entry code sees developer mode at load time")
|
||||
T.eq(out.kind, "boolean", "developer mode is exposed as plain data")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"entry code can register diagnostics only in developer mode")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(false)
|
||||
T.eq(#run.errors, 0, "production-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"sandboxed entry code sees production mode at load time")
|
||||
T.eq(out.kind, "boolean", "production mode is exposed as plain data")
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"production load does not register developer diagnostics")
|
||||
run.release()
|
||||
end
|
||||
|
||||
for _, provided in ipairs({ "yes", 1 }) do
|
||||
local run = load(provided)
|
||||
T.eq(#run.errors, 0,
|
||||
"non-boolean developer-mode probe loads clean: " .. tostring(provided))
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, false,
|
||||
"non-boolean opts.dev is false in mod.developer: " .. tostring(provided))
|
||||
T.eq(out.kind, "boolean",
|
||||
"non-boolean opts.dev stays a strict public boolean: " .. tostring(provided))
|
||||
T.eq(run.loader.dev, false,
|
||||
"non-boolean opts.dev is false in loader.dev: " .. tostring(provided))
|
||||
T.eq(run.loader.content.commands:get("dev_probe:diagnostics"), nil,
|
||||
"non-boolean opts.dev cannot register developer diagnostics: " .. tostring(provided))
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local run = load(true, 2)
|
||||
T.eq(#run.errors, 0, "Gen 2 developer-mode public probe loads clean")
|
||||
local out = run.loader.exports.dev_probe
|
||||
T.eq(out.seenAtLoad, true,
|
||||
"Gen 2 entry code sees the same developer-mode answer")
|
||||
T.check(run.loader.content.commands:get("dev_probe:diagnostics") ~= nil,
|
||||
"Gen 2 entry code can gate diagnostics on the same signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
do
|
||||
local saved = _G.POKEPORT_DEV_MODE
|
||||
_G.POKEPORT_DEV_MODE = true
|
||||
local ok, run = pcall(load, nil)
|
||||
_G.POKEPORT_DEV_MODE = saved
|
||||
if not ok then error(run, 0) end
|
||||
T.eq(#run.errors, 0, "command-line developer-mode probe loads clean")
|
||||
T.eq(run.loader.exports.dev_probe.seenAtLoad, true,
|
||||
"the --developer boot decision reaches the public signal")
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish("mod developer mode public API")
|
||||
Reference in New Issue
Block a user