diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..54a850f2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -290,5 +290,18 @@ update and input ownership, so a mod can mirror a native menu on another display without reimplementing it. The default is `true`. Treat the wrapper as a pure predicate: the renderer may ask it more than once per frame. +`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call +(once per frame). Vanilla behavior resolves the per-category GAME SPEED +option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever +category `Game.speedCategoryInStack` says is active right now. A mod may +call `next(game)` and return its result to pass that resolution through, or +return a different number outright to override it for that frame (a bot mod +forcing 1X for one route segment, say, regardless of the category or saved +option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry +regardless of what a subscriber returns, so a bad value (0, negative, `nil`) +cannot destabilize the fixed-step accumulator. This hook runs *after* link +play's 1X lock and the `--speed`/equivalent run-argument override, both of +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. diff --git a/docs/rfcs/0007-per-category-game-speed.md b/docs/rfcs/0007-per-category-game-speed.md new file mode 100644 index 00000000..eb34b3a4 --- /dev/null +++ b/docs/rfcs/0007-per-category-game-speed.md @@ -0,0 +1,212 @@ +# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook + +## Status + +Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`, +`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests: +`tests/engine/game_speed_categories_test.lua`, +`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua` +(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order). + +## Motivation + +`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier +applied uniformly to the whole logic clock in `Game:logicSpeed()` / +`Game:update()` -- overworld walking, menu navigation and battle turns all +scale together. A player who wants 4X battles (grinding, a long gym fight) +but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past) +has no way to get both; the one GAME SPEED row is a single ladder that +applies everywhere at once. + +This needs to be an engine change, not a mod: there is no per-frame seam a +mod can use to swap the multiplier mid-step, and no public event granular +enough to say "which category is active" (`screen.pushed`/`screen.popped` +and `battle.started`/`battle.ended` are the closest and are not enough -- +see Decisions below). The engine's own speed resolution has to become +category-aware. + +A category-aware speed resolution is also the general seam a +platform-launcher integration or automation tool needs to read or override +the effective multiplier for a given frame without caring which category +produced it -- this RFC's `core.logic_speed` hook is written for that case +alongside the player-facing Options rows. + +## The decision it extends + +No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged) +with per-category resolution. + +## The exact API delta + +Backward-compatible except for one save-data field rename, which ships with +an automatic migration (see below) -- nothing in the public mod API (hooks, +events, registries, `mod.*`) is renamed or removed. + +### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu` + +`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list +of categories, and `GameSpeed.optionKey(category)` maps a category to its +`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.). +`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are +unchanged -- the ladder and its behavior are exactly what they were, just +applied three times instead of once. + +`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`, +`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a +loaded options table that still has `speed` and none of the three new +fields seeds all three from it, so an existing player's fast-forward +preference carries over instead of two of the three categories silently +resetting to 1X. `speed` is dropped on the way out (not carried forward), +so a re-save never re-triggers the migration. + +### `Game.speedCategoryInStack(stack)` + +New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the +whole state stack top-down -- the same idiom `Game.wideBattleInStack` and +`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new +marker, `BattleState.isBattle = true`, covering every battle: wild, +trainer, link, safari, the old-man demo) or `state.isOverworld` (existing +marker, `OverworldController`'s `OverworldState.isOverworld = true`). The +first match wins; a state with neither marker (a menu, a text box, a +naming screen, a cutscene) is transparent to the walk and falls through to +whatever is under it. Nothing in the stack matching either falls back to +`"menu"`. + +### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()` + +`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack` +against the live stack, maps the category to its `save.options` key via +`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or +`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new +hook wraps. + +`Game:logicSpeed()` keeps its existing early returns -- link play forces +`1`, a run-argument speed override wins over the saved option -- unchanged, +and in the same order, before ever calling the hook. Only once neither +applies does it call the `core.logic_speed` hook. + +### `Game:_cycleSpeed(dir)` + +The keyboard hotkey and the gamepad shoulders/triggers that used to cycle +the single `speed` option now cycle whichever category +`Game.speedCategoryInStack` says is active: pressing the hotkey during a +battle speeds up just the battle, on the overworld just the walk, in a menu +just the menu. This is the natural per-category answer for a control that +used to have one option to reach and now has three -- see Decisions below +for why this reading was chosen over, say, always cycling `overworld`. + +### `core.logic_speed` + +New hook, `(game) -> number` through the public wrapper signature +`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame). +Vanilla behavior (used when no mod claims the hook) is +`Game:_resolveLogicSpeed()` -- exactly the category resolution above, +nothing else. A subscriber may call `next(game)` and return its result to +pass the vanilla multiplier through, or return a different number outright +to override it for that frame (e.g. a bot mod forcing `1` during one route +segment regardless of what category or option is active). + +This intentionally sits *after* the link and speed-override checks in +`Game:logicSpeed()`, not around them: link play staying locked to 1X "no +matter what either player set this to" is exactly the invariant that would +break if a mod's hook could override it, and the run-argument override +exists so a bot/screenshot run's speed does not depend on a mod any more +than on the player's saved option. Both stay unconditional early returns a +mod never sees. + +Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a +bare `vanilla(...)` call when no mod has wrapped the name, and this hook +fires every frame regardless. + +## Decisions on the issue's open questions + +**1. Overlays on top of another category's state (a party menu, a choice +box, a naming screen opened mid-battle or mid-overworld).** Resolved by +making the category a property of stack *position*, not of the overlay's +own type: an overlay with no `isBattle`/`isOverworld` marker is transparent +to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A +party swap opened mid-battle reads as `"battle"`; a bag opened while +walking reads as `"overworld"`. This was chosen over giving every UI state +its own fixed category (which would make a fast-forwarded battle visibly +stutter back to 1X every time its party menu opens) because it matches +what the player is actually doing moment to moment, and it reuses a +pattern the codebase already leans on for exactly this "menus opened over +X should behave like X" class of problem (`Game.fillScaleInStack`, +`Game.wideBattleInStack`). + +**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs +through the owning state's own machinery -- the overworld's script runner +or a battle's message queue -- rather than pushing a state of its own, so +it is already covered by decision 1: it inherits whatever category the +state driving it resolves to. A cutscene state that genuinely has nothing +under it (a pre-game intro) falls to `"menu"`, the default for anything +that is not battle or overworld gameplay -- consistent with those being +pre-game presentation, not something a player is likely to want scaled +differently from menu navigation. + +**3. Category granularity (splitting "menu" further).** Deferred. Start +with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey` +are written so adding a fourth later (a Pokédex/Bag category, say) is one +entry plus one new `save.options` field, not a resolution-logic rewrite. +No current request motivates it. + +**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three +things.** `Game:_cycleSpeed` now cycles whichever category is currently +active (`Game.speedCategoryInStack`), rather than, say, always cycling +`overworld` or requiring a modifier key to pick a category. A single +physical control that means "speed up whatever I'm looking at right now" +is the reading that needs no new UI and matches what a player pressing it +mid-battle almost certainly wants. + +## Migration note for existing mods + +**Nothing**, for the mod API surface: `content.X:register/override/get`, +`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are +untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle` +keep their exact signatures and behavior. + +**One save-data field**, for anything that read `save.options.speed` +directly (not a formal registry/hook surface, but worth naming): it is +superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated +automatically on load (see above) so a save from before this RFC keeps its +player's chosen speed. A mod reading `save.options.speed` after this change +sees `nil` (the key is dropped on migration, not kept as a stale alias) and +should read the per-category fields, or hook `core.logic_speed` to observe +the resolved multiplier directly regardless of which category produced it. + +## Parity tests + +- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file -- + `tests/engine/gate_hooks.lua` walks the live hook catalog (which scans + `src` for `Runtime.call("...")` call sites), so the new + `Runtime.call("core.logic_speed", ...)` site is picked up and gated + automatically: vanilla runs exactly once with an empty hook chain, an + unsubscribed-but-live bus passes values and multiple returns through + unchanged, and `Runtime.wantsHook` reads `false`. +- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the + hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed", + ...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a + subscriber can read the vanilla category resolution via `next(game)` and + can override it outright -- plus direct coverage of + `Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay + inheriting each, an empty/unmatched stack falling to `"menu"`) and + `Game:logicSpeed()`'s precedence (link forces 1X over all three + categories and over a hook override; the run-argument override wins over + the category resolution). +- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows + (OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping + independently, in place of the old single GAME SPEED row. +- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index + activations (MODS, CONTROLS) are updated for the two extra rows. +- A link-play driver should set all three per-category speeds high before + asserting `game:logicSpeed()` reads `1` during a real link session, + proving the lock wins over every category at once, not just whichever + one happens to be active. + +## Deprecation etiquette + +Nothing deprecated in the mod-facing hook/event/registry catalog -- this +adds one hook, additive. The `save.options.speed` field is superseded with +an automatic migration rather than a deprecation notice, since it was never +a registered mod-API surface (no schema entry, no registry) -- the same +treatment any other `save.options` field would get if it needed reshaping. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 86b2ab18..02d54fc3 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -37,6 +37,12 @@ local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Category identity for per-category GAME SPEED (RFC 0007), the same +-- style OverworldController.isOverworld already uses. Every battle -- +-- wild, trainer, link, safari, the old-man demo -- is this metatable, so +-- Game.speedCategoryInStack needs no special-casing beyond this one flag. +BattleState.isBattle = true + function BattleState:romText(label, fallback, ...) return romText(self.data, label, fallback, ...) end diff --git a/src/core/Game.lua b/src/core/Game.lua index 5226dd16..9cb05fda 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -221,24 +221,44 @@ function Game:step(dt) -- it on its own real-time 60Hz accumulator instead. end +-- The per-category (RFC 0007) save.options multiplier for whichever of +-- "battle"/"overworld"/"menu" Game.speedCategoryInStack says is active +-- right now. This is the "vanilla" the core.logic_speed hook wraps below +-- -- Game:logicSpeed calls it AFTER the link and speedOverride checks, so +-- neither a mod nor the category resolution ever has a seam to defeat them. +function Game:_resolveLogicSpeed() + local GameSpeed = require("src.core.GameSpeed") + local category = Game.speedCategoryInStack(self.stack) + local key = GameSpeed.optionKey(category) + local opts = self.save and self.save.options + return GameSpeed.clamp(opts and opts[key] or GameSpeed.DEFAULT) +end + -- The logic multiplier for this frame. Read live rather than cached so the --- Options row takes effect immediately; speedOverride is the --speed / +-- Options rows take effect immediately; speedOverride is the --speed / -- POKEPORT_SPEED run argument, which wins over the saved option so a bot -- or screenshot run does not depend on whatever the player last chose. function Game:logicSpeed() local GameSpeed = require("src.core.GameSpeed") -- Link play is always 1X on both machines, and this wins over every other - -- source including POKEPORT_SPEED. Fast-forward multiplies the logic - -- clock, so a peer at 10X burned a tournament shot clock ten times faster - -- than the opponent it is racing, and drove its own animation/message - -- queue at a different rate than the peer it is locked to. Nothing about - -- a match should depend on what either player set this to. + -- source including POKEPORT_SPEED and every per-category option. + -- Fast-forward multiplies the logic clock, so a peer at 10X burned a + -- tournament shot clock ten times faster than the opponent it is racing, + -- and drove its own animation/message queue at a different rate than the + -- peer it is locked to. Nothing about a match should depend on what + -- either player set this to -- checked here, before the core.logic_speed + -- hook ever runs, so a mod cannot defeat it either. if self.linkSession or (self.linkNet and not self.linkNet.closed) then return 1 end if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end - local opts = self.save and self.save.options - return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT) + -- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's + -- core.logic_speed hook can return anything (0, negative, nil, NaN) and + -- Hooks:call only guards against a hook that throws, not one that + -- returns a bad value, so an unclamped result would flow straight into + -- the FixedStep accumulator math below and freeze or destabilize logic. + return GameSpeed.clamp(ModRuntime.call("core.logic_speed", + function(g) return g:_resolveLogicSpeed() end, self)) end function Game:update(dt) @@ -337,6 +357,28 @@ function Game.wideBattleInStack(stack) return nil end +-- Which of "battle"/"overworld"/"menu" per-category GAME SPEED (RFC 0007) +-- applies right now. Whole-stack, the same idiom as fillScaleInStack/ +-- wideBattleInStack above: an overlay with neither marker (PartyMenu, +-- ChoiceBox, a NamingScreen, a text box) is transparent to the walk and +-- inherits whatever is under it, making the category a property of the +-- STACK POSITION the overlay sits over, not of the overlay itself. A +-- scripted sequence (script.started/ended) never pushes a state of its +-- own either -- it runs through the owning overworld/battle state's own +-- script runner or message queue -- so it inherits the same way. Nothing +-- identifying as either (the title screen, credits, an intro cutscene +-- with nothing under it) falls to "menu", the bucket every non-gameplay +-- screen gets; see the RFC's Decisions section for the full reasoning. +function Game.speedCategoryInStack(stack) + local states = stack and stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and state.isBattle then return "battle" end + if state and state.isOverworld then return "overworld" end + end + return "menu" +end + -- Whether a state on the stack composes its own screen and so wants the -- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like -- everything else here: the text box and YES/NO a battle puts up are states @@ -556,8 +598,15 @@ function Game:_cycleSpeed(dir) or ow.engaging or ow.emote)) end if busy then return end + -- Cycles whichever category Game.speedCategoryInStack says is active + -- right now (RFC 0007) -- pressing the hotkey during a battle speeds up + -- just the battle, on the overworld just the walk, in a menu just the + -- menu. A single physical control that means "speed up whatever I'm + -- looking at right now" needs no new UI and matches what a player + -- pressing it mid-battle almost certainly wants. local GameSpeed = require("src.core.GameSpeed") - self.save.options.speed = GameSpeed.cycle(self.save.options.speed, dir) + local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) + self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) self:writeOptions() end diff --git a/src/core/GameSpeed.lua b/src/core/GameSpeed.lua index 0a5ddde3..96f08815 100644 --- a/src/core/GameSpeed.lua +++ b/src/core/GameSpeed.lua @@ -51,4 +51,19 @@ function GameSpeed.cycle(v, dir) return levels[nextIdx] end +-- Per-category speed (RFC 0007): overworld walking, battle turns and menu +-- navigation each cycle their own multiplier instead of one global "speed" +-- value. This list is the single source of truth for which categories +-- exist and the order the Options rows/save.options keys follow; +-- Game.lua's stack-walk (Game.speedCategoryInStack) decides WHICH category +-- is active on a given frame, this module only knows the category names. +GameSpeed.CATEGORIES = { "overworld", "battle", "menu" } + +-- the save.options field name a category's multiplier lives under, e.g. +-- "overworld" -> "speedOverworld". Centralized so Game.lua, OptionsMenu.lua, +-- LauncherSettings.lua and the SaveData migration never hand-spell the key. +function GameSpeed.optionKey(category) + return "speed" .. category:sub(1, 1):upper() .. category:sub(2) +end + return GameSpeed diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 8de64ea8..4b8511bf 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -261,8 +261,12 @@ function SaveData.defaultOptions() -- (the PIKACHU VOL row appears only on Yellow; see Sound.lua) pikaVol = 7, musicFilter = 0, - -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) - speed = 1, + -- Per-category logic fast-forward multiplier (RFC 0007); audio is + -- unaffected (GameSpeed.lua). Superseded from a single "speed" field -- + -- mergeOptions migrates an old save's value into all three below. + speedOverworld = 1, + speedBattle = 1, + speedMenu = 1, -- port display options (OptionsMenu / hotkeys 2/3/4/5) colors = "gbc", tilt = 0, @@ -339,6 +343,19 @@ function SaveData.mergeOptions(loaded) for k, v in pairs(loaded) do opts[k] = v end + -- RFC 0007 migration: a save from before per-category GAME SPEED still + -- has a single "speed" and none of the three new fields, so seed all + -- three from it -- an existing player's fast-forward preference + -- carries over instead of two of the three categories silently + -- resetting to 1X. "speed" is dropped on the way out (not kept as a + -- stale alias), so a re-save never re-triggers this migration. + if loaded.speed ~= nil and loaded.speedOverworld == nil + and loaded.speedBattle == nil and loaded.speedMenu == nil then + opts.speedOverworld = loaded.speed + opts.speedBattle = loaded.speed + opts.speedMenu = loaded.speed + end + opts.speed = nil end return opts end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index aba010b6..03f1b8cc 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -214,10 +214,24 @@ local function coreRows(opts, hooks) local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") if okSpd then - add(Strings("GAME SPEED"), - function() return GameSpeed.levelLabel(opts.speed) end, + -- Per-category (RFC 0007): overworld/battle/menu each cycle their own + -- multiplier, mirroring OptionsMenu.lua's three rows. + add(Strings("OVERWORLD SPEED"), + function() return GameSpeed.levelLabel(opts.speedOverworld) end, function(dir) - opts.speed = GameSpeed.cycle(opts.speed, dir) + opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir) + return true + end) + add(Strings("BATTLE SPEED"), + function() return GameSpeed.levelLabel(opts.speedBattle) end, + function(dir) + opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir) + return true + end) + add(Strings("MENU SPEED"), + function() return GameSpeed.levelLabel(opts.speedMenu) end, + function(dir) + opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir) return true end) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c44f5718..cca24cd9 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -392,14 +392,35 @@ local function buildRows(game) return true end }, -- fast-forward the logic clock only; music and sfx keep their tempo - -- (src/core/GameSpeed.lua), so this is safe to leave on - { id = "speed", label = Strings("GAME SPEED"), + -- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category + -- (RFC 0007): overworld walking, battle turns and menu navigation each + -- cycle their own multiplier -- GameSpeed.CATEGORIES is the single + -- source of truth for which three rows exist. + { id = "speedOverworld", label = Strings("OVERWORLD SPEED"), value = function(g) - return GameSpeed.levelLabel(g.save.options.speed) + return GameSpeed.levelLabel(g.save.options.speedOverworld) end, step = function(g, dir) local o = g.save.options - o.speed = GameSpeed.cycle(o.speed, dir) + o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir) + return true + end }, + { id = "speedBattle", label = Strings("BATTLE SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedBattle) + end, + step = function(g, dir) + local o = g.save.options + o.speedBattle = GameSpeed.cycle(o.speedBattle, dir) + return true + end }, + { id = "speedMenu", label = Strings("MENU SPEED"), + value = function(g) + return GameSpeed.levelLabel(g.save.options.speedMenu) + end, + step = function(g, dir) + local o = g.save.options + o.speedMenu = GameSpeed.cycle(o.speedMenu, dir) return true end }, -- the manager's discoverable home (18-mod-manager-ux); inert until diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua index 8e9be6de..0a6c9123 100644 --- a/tests/drivers/online_match_host.lua +++ b/tests/drivers/online_match_host.lua @@ -55,8 +55,12 @@ return function(game) U.wait(10) -- the GAME SPEED forcing under test: a link session pins the logic clock - -- to 1X no matter what the option or POKEPORT_SPEED says - game.save.options.speed = 10 + -- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set + -- all three per-category speeds high, since the link lock has to win over + -- every one of them, not just whichever category happens to be active. + game.save.options.speedOverworld = 10 + game.save.options.speedBattle = 10 + game.save.options.speedMenu = 10 U.wait(2) log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed()) diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua index 48f8545d..31d3a593 100644 --- a/tests/drivers/online_match_join.lua +++ b/tests/drivers/online_match_join.lua @@ -55,7 +55,11 @@ return function(game) local link = LinkState.new(game) game.stack:push(link) U.wait(10) - game.save.options.speed = 20 + -- RFC 0007: set all three per-category speeds high, since the link lock + -- has to win over every one of them, not just whichever is active. + game.save.options.speedOverworld = 20 + game.save.options.speedBattle = 20 + game.save.options.speedMenu = 20 U.wait(2) log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed()) diff --git a/tests/drivers/route1_seam_pacing_bug487_test.lua b/tests/drivers/route1_seam_pacing_bug487_test.lua index 981b0e15..48ea282a 100644 --- a/tests/drivers/route1_seam_pacing_bug487_test.lua +++ b/tests/drivers/route1_seam_pacing_bug487_test.lua @@ -46,7 +46,9 @@ return function(game) os.getenv("POKEPORT_DRIVER") == nil) check("no fast-forward multiplier is set", (tonumber(os.getenv("POKEPORT_SPEED")) or 1) == 1 - and (game.save.options.speed or 1) == 1) + and (game.save.options.speedOverworld or 1) == 1 + and (game.save.options.speedBattle or 1) == 1 + and (game.save.options.speedMenu or 1) == 1) check("a window is up to watch (this is a visual call)", love.window ~= nil and love.window.isOpen and love.window.isOpen()) U.log("MAX FPS reads", FrameCap.label(game.save.options.fpsCap), diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua new file mode 100644 index 00000000..57bcd86f --- /dev/null +++ b/tests/engine/game_speed_categories_test.lua @@ -0,0 +1,196 @@ +-- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack +-- walk, Game:logicSpeed()'s precedence (link lock / run-argument override / +-- the core.logic_speed hook), Game:_cycleSpeed's per-category cycling, and +-- the core.logic_speed hook itself exercised through the public mod API +-- (Hooks.new() + bus:wrap, the same idiom other hooks' tests use -- not a +-- private require). +-- luajit tests/engine/game_speed_categories_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Game = require("src.core.Game") +local GameSpeed = require("src.core.GameSpeed") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") + +-- ------- Game.speedCategoryInStack: the whole-stack walk + +local function stack(...) return { states = { ... } } end + +local battle = { isBattle = true } +local overworld = { isOverworld = true } +local overlay = {} -- a party menu/choice box/naming screen/text box: no marker + +eq(Game.speedCategoryInStack(nil), "menu", "a nil stack falls to menu") +eq(Game.speedCategoryInStack(stack()), "menu", "an empty stack falls to menu") +eq(Game.speedCategoryInStack(stack(overlay)), "menu", + "an unmarked state alone (title screen, credits, a standalone cutscene) is menu") + +eq(Game.speedCategoryInStack(stack(overworld)), "overworld", + "the overworld alone resolves to overworld") +eq(Game.speedCategoryInStack(stack(battle)), "battle", + "a battle alone resolves to battle") + +eq(Game.speedCategoryInStack(stack(overworld, overlay)), "overworld", + "a menu opened while walking inherits overworld") +eq(Game.speedCategoryInStack(stack(battle, overlay)), "battle", + "a menu opened mid-battle inherits battle, not menu") +eq(Game.speedCategoryInStack(stack(overworld, overlay, overlay)), "overworld", + "the inheritance walk sees through more than one stacked overlay") + +eq(Game.speedCategoryInStack(stack(overworld, battle)), "battle", + "a battle opened over the overworld reads as battle, not the overworld underneath it") +eq(Game.speedCategoryInStack(stack(overworld, battle, overlay)), "battle", + "and a menu on top of THAT still reads as battle") + +-- ------- Game:_resolveLogicSpeed: category -> save.options key -> clamp + +local unpack = table.unpack or unpack + +local function gameWith(states, options) + return setmetatable({ + save = { options = options }, + stack = stack(unpack(states or {})), + }, { __index = Game }) +end + +do + local g = gameWith({ overworld }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 4, "overworld reads speedOverworld") +end +do + local g = gameWith({ battle }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) + eq(g:_resolveLogicSpeed(), 2, "menu reads speedMenu") +end +do + local g = gameWith({ overworld }, { speedOverworld = 7 }) + eq(g:_resolveLogicSpeed(), GameSpeed.clamp(7), + "an odd value clamps to the nearest LEVELS entry, like the old single field") +end +do + local g = gameWith({ overworld }, nil) + eq(g:_resolveLogicSpeed(), GameSpeed.DEFAULT, + "no save.options at all defaults rather than erroring") +end + +-- ------- Game:logicSpeed(): link and speedOverride win over every category +-- and over a hook override; the hook only ever sees the ordinary case + +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + eq(g:logicSpeed(), 1, "an active link session forces 1X even at 50X battle") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = false } + eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkNet = { closed = true } + eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X") +end +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + g.speedOverride = 20 + eq(g:logicSpeed(), 20, + "speedOverride (--speed/POKEPORT_SPEED) wins over the category option") +end +do + local g = gameWith({ battle }, { speedBattle = 50 }) + g.linkSession = true + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + local hookRan = false + local unsub = bus:wrap("core.logic_speed", function(next, game) + hookRan = true + return 999 + end) + eq(g:logicSpeed(), 1, + "the link lock wins even over a mod's core.logic_speed override") + check(not hookRan, "...because the hook is never called during link play") + unsub() + Runtime.hooks = savedHooks +end + +-- ------- core.logic_speed: the mod-API seam, driven through Runtime.call/ +-- Hooks.new + bus:wrap like every other hook's public-API test + +local function callLogicSpeed(g) + return Runtime.call("core.logic_speed", + function(gg) return gg:_resolveLogicSpeed() end, g) +end + +do + local g = gameWith({ battle }, { speedBattle = 4 }) + eq(callLogicSpeed(g), 4, + "with no subscriber, the hook returns the vanilla category resolution") +end + +do + local g = gameWith({ overworld }, { speedOverworld = 4 }) + local bus = Hooks.new() + local savedHooks = Runtime.hooks + Runtime.hooks = bus + + local nextArg = nil + local unsub = bus:wrap("core.logic_speed", function(next, game) + nextArg = next(game) + return nextArg + end) + eq(callLogicSpeed(g), 4, + "a subscriber calling next(game) passes the vanilla value through") + eq(nextArg, 4, "...and next(game) itself returned the vanilla resolution") + unsub() + + -- a bot mod forcing 1X for one route segment regardless of the category + unsub = bus:wrap("core.logic_speed", function(next, game) return 1 end) + eq(callLogicSpeed(g), 1, + "a subscriber may override the resolved multiplier outright") + unsub() + + Runtime.hooks = savedHooks +end + +-- ------- Game:_cycleSpeed: cycles whichever category is active, and only it + +do + local writeOptions = { calls = 0 } + local g = gameWith({ battle }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() writeOptions.calls = writeOptions.calls + 1 end + g:_cycleSpeed(1) + eq(g.save.options.speedBattle, 2, "cycling during battle bumps speedBattle") + eq(g.save.options.speedOverworld, 1, "...and leaves speedOverworld alone") + eq(g.save.options.speedMenu, 1, "...and leaves speedMenu alone") + eq(writeOptions.calls, 1, "a successful cycle persists the option") +end +do + local g = gameWith({ overworld }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedOverworld, 2, "cycling on the overworld bumps speedOverworld") + eq(g.save.options.speedBattle, 1, "...and leaves speedBattle alone") +end +do + local g = gameWith({ overlay }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() end + g:_cycleSpeed(1) + eq(g.save.options.speedMenu, 2, "cycling in a menu bumps speedMenu") +end + +T.finish("game_speed_categories") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 1ef1c640..51bde011 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -287,7 +287,8 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "performance", "colors", "tilt", "gbcfx", "zoom", "voidFill", "videoMode", "faithfulRes", "fpsCap", - "speed", "mods", "controls" } + "speedOverworld", "speedBattle", "speedMenu", + "mods", "controls" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -383,7 +384,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6 -- the MODS row is the manager's discoverable home local mgGame = optGame() om = OptionsMenu.new(mgGame) -om.rows[22].activate(mgGame) +om.rows[24].activate(mgGame) check(getmetatable(mgGame.stack:top()) == ManagerState, "the MODS row opens the manager") check(mgGame.stack:top().screenId == "ManagerState", @@ -393,7 +394,7 @@ check(mgGame.stack:top().screenId == "ManagerState", local BindingsMenu = require("src.ui.BindingsMenu") local cbGame = optGame() om = OptionsMenu.new(cbGame) -om.rows[23].activate(cbGame) +om.rows[25].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 92b21cd2..2915cbbe 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2759,29 +2759,43 @@ do -- SPEED below: a full loop of #STEPS presses returns to the 60 default. for _ = 1, #FrameCap.STEPS - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") + -- RFC 0007: the single GAME SPEED row is now three independent rows, + -- one per GameSpeed.CATEGORIES entry. press("down") - eq(om.index, 21, "cursor reaches GAME SPEED") + eq(om.index, 21, "cursor reaches OVERWORLD SPEED") press("a") - eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") + eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") -- Driven by the level list rather than a literal press count: adding a -- speed (20X went in for the bot runs) otherwise fails this as a wrap -- bug when the cycling is fine and the row is simply one longer. for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end - eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") + eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") press("down") - eq(om.index, 22, "cursor reaches MODS") + eq(om.index, 22, "cursor reaches BATTLE SPEED") + press("a") + eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL") press("down") - eq(om.index, 23, "cursor reaches CONTROLS") + eq(om.index, 23, "cursor reaches MENU SPEED") + press("a") + eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") + for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end + eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL") press("down") - eq(om.index, 24, "CANCEL stays the fixed final row") - eq(om.scroll, 19, "CANCEL keeps the last option boxes on screen") + eq(om.index, 24, "cursor reaches MODS") + press("down") + eq(om.index, 25, "cursor reaches CONTROLS") + press("down") + eq(om.index, 26, "CANCEL stays the fixed final row") + eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 24, "up from the top wraps to CANCEL") - eq(om2.scroll, 19, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 26, "up from the top wraps to CANCEL") + eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options)