Files
gen1recomp/tests/modkit/cases/pokemon_level_visible.lua
T
DESKTOP-8SRFDDM\cam95 49a5408c2d engine: pokemon.level_visible, a level a mode can take off the screen (RFC 0019)
A Pokémon's level is printed on four Gen 1 surfaces -- both battle
healthboxes, the party rows, and both status pages -- and every one of them
prints it unconditionally. There is no seam, so a mode that wants the number
gone has two options today and both are bad: paint over the engine's own
pixels from render.hud (four rectangles, a background shade to match, and
the palette flashes and healthbox slide to survive), or monkey-patch the
render modules from inside the sandbox, which works and is exactly what
CONTRIBUTING-mods.md tells mods not to do.

The motivating case is a battle royale that scales every party to a shared
rung rising with its fog: the number is the same for everyone, it changes on
a clock, and it reads as a threat it is not -- a Lv37 opponent looks
dangerous to a player who has not worked out that their own team is Lv37
too. A randomizer keeping an encounter unreadable, a challenge run that
forbids level-checking and a blind Nuzlocke want the same switch.

New hook `pokemon.level_visible`, taking the shape the presentation
predicates on the battle screen already use -- battle.status_hud_visible,
battle.bottom_ui_visible, battle.caught_marker_visible: consulted behind
Runtime.wantsHook, default visible, only an explicit false suppresses. It is
not named battle.* because a level is not a battle-only readout, and it
carries the surface that asked (battle.enemy / battle.player / party /
summary) so a mode can hide an opponent's level and keep its own.

src/ui/LevelDisplay.lua holds the one definition of "visible", so the four
call sites are a one-line guard each rather than four copies of the same
five lines that can drift apart.

No layout moves. Each site keeps its own hand-rolled PrintLevel rule
(home/pokemon.asm:335-345), it just asks first. Two details are deliberate:
a status condition still replaces the level on a healthbox exactly as in the
cart, so hiding a level never hides PSN or BRN (the guard is an elseif on
the existing status branch); and on status page 2 the <to> arrow is hidden
with the level it points at, because an arrow with nothing after it is half
a sentence.

Gen 1 only. The Gen 2 screens and the Gen 1 PC box list -- where the level
is part of a row label rather than a drawn field -- keep their own readouts
and do not consult the hook. Both are stated as follow-ups in the RFC and
beside the hook in docs/modding.md, so a mod author reads the limit before
depending on it.

Verification: tests/modkit/cases/pokemon_level_visible.lua covers the
contract through the public mod API; gate_hooks picks the hook up on its own
because it walks the live catalog; gate_meta_coverage is satisfied by the
change that introduces the seam, so it never enters the DEBT ledger.
tests/run_modkit.lua 33/33, tests/run_engine.lua 327/331 -- the same four
audio/hostshell suites fail unchanged on dev without this branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 10:53:09 -05:00

79 lines
2.9 KiB
Lua

-- A sandboxed mod can take a Pokémon's level off the screens that print it
-- (pokemon.level_visible): the readout goes, the layout does not, and a
-- build with no mod wrapping the hook prints exactly what it always did.
--
-- The predicate is tested rather than the pixels: every Gen 1 level readout
-- goes through LevelDisplay.visible, and the four call sites are the same
-- one-line guard. What matters here is the contract -- default true, false
-- suppresses, the surface is named, and no-mod costs nothing.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.modkit")
local LevelDisplay = require("src.ui.LevelDisplay")
local FIXTURE = {
["mods/level_probe/manifest.json"] = [[{
"id": "level_probe",
"name": "Level Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/level_probe/main.lua"] = [[
local mod = ...
mod.exports.answer = nil
mod.exports.seen = nil
mod.hooks:wrap("pokemon.level_visible", function(next, mon, ctx)
mod.exports.seen = { mon = mon, where = ctx and ctx.where,
game = ctx and ctx.game }
if mod.exports.answer == nil then return next(mon, ctx) end
return mod.exports.answer
end)
]],
}
local MON = { species = "RATTATA", level = 42, moves = {} }
local GAME = { save = {} }
-- ------- no mod: the level is always printed
local vanilla = T.sdk.loadNone({})
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), true,
"no mod: the enemy healthbox prints a level")
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
"no mod: the party rows print a level")
T.eq(LevelDisplay.visible(nil, "summary", nil), true,
"no mod: even a nil mon answers true rather than throwing")
vanilla.release()
-- ------- a mod hides it
local run = T.sdk.loadMods({ "mods/level_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "the level probe loads clean (" .. tostring(run.errors[1]) .. ")")
local probe = run.loader.exports.level_probe
probe.answer = false
T.eq(LevelDisplay.visible(MON, "battle.enemy", GAME), false,
"hidden: the enemy healthbox prints no level")
T.eq(probe.seen and probe.seen.where, "battle.enemy",
"the hook is told which surface asked")
T.check(probe.seen and probe.seen.mon == MON, "and which Pokémon")
T.check(probe.seen and probe.seen.game == GAME, "and the game")
-- the surface is what lets a mode hide an opponent's level and keep its own
probe.answer = nil
T.eq(LevelDisplay.visible(MON, "party", GAME), true,
"falling through prints, as today")
T.eq(probe.seen and probe.seen.where, "party", "and still names the surface")
-- only an explicit false suppresses: a mod returning nothing must not blank
-- a screen by accident
probe.answer = true
T.eq(LevelDisplay.visible(MON, "summary", GAME), true,
"an explicit true prints")
run.release()
T.finish("pokemon level visible")