From a685f16e00595ab39f31b409ddf6e71507dfc950 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 25 Sep 2026 10:28:21 -0400 Subject: [PATCH] updates to online --- docs/modding.md | 33 +- docs/new-features.md | 2 + mod_option_schemas.json | 2 +- src/core/FixedStep.lua | 12 + src/core/Game.lua | 43 +- src/core/Game2.lua | 32 +- src/core/Game3.lua | 53 +- src/core/game3/dataset.lua | 1 + src/core/game3/field_view.lua | 123 +- src/core/game3/link/union_plaza_map.lua | 213 ++++ src/core/game3/link/union_room.lua | 1039 +++++------------ src/core/game3/objects.lua | 51 +- src/core/game3/save_schema_firered.lua | 19 + src/core/game3/scripting/adapters.lua | 6 + src/core/game3/virtual_objects.lua | 9 + src/import/LauncherSettings.lua | 8 - src/link/Wire.lua | 6 +- src/mods/Loader.lua | 5 +- src/online/Client.lua | 17 +- src/online/Protocol2.lua | 9 +- src/render/BattleTransition.lua | 1 + src/ui/OptionsMenu.lua | 16 +- src/ui/game3/bag_menu.lua | 2 +- src/ui/game3/option_rows.lua | 3 +- src/ui/game3/start_menu.lua | 2 +- src/ui/game3/union_room.lua | 8 +- src/ui/game3/wireless_icon.lua | 1 + src/ui/gen2/BattleState.lua | 1 + src/ui/gen2/BattleTransition.lua | 1 + .../drivers/battle_cry_speed_bug2087_test.lua | 24 +- .../battle_fanfare_speed_bug1952_test.lua | 36 +- tests/drivers/g3link_union_plaza.lua | 103 +- tests/drivers/g3link_union_plaza_live.lua | 228 ++++ tests/drivers/g3link_union_plaza_perf.lua | 196 ++++ tests/drivers/g3link_union_room.lua | 57 +- tests/drivers/game3_link_union_room.lua | 105 +- tests/drivers/game3_union_plaza_map.lua | 107 ++ .../gold_fanfare_speed_bug1952_test.lua | 12 +- tests/drivers/speed_lock_firered.lua | 149 +++ tests/drivers/speed_lock_gold.lua | 102 ++ tests/drivers/speed_lock_red.lua | 87 ++ tests/engine/game3_speed_hotkey_test.lua | 48 + .../game3_virtual_objects_drawhook_test.lua | 16 + tests/engine/game_speed_categories_test.lua | 26 +- tests/engine/gen2_logic_speed_bug1994.lua | 32 + tests/engine/options_submenu_marquee_test.lua | 6 +- tests/engine/speed_lock_test.lua | 188 +++ tests/g3link_fake_relay.lua | 2 +- tests/game3_link_continue_warp_test.lua | 20 + tests/game3_link_relay_union_test.lua | 321 +++-- tests/game3_link_union_test.lua | 188 +-- tests/game3_union_plaza_map_test.lua | 199 ++++ tests/mod_ui_tests.lua | 2 +- tests/online_client_gen3.lua | 13 +- tests/run_tests.lua | 8 +- tests/support/fake_relay.lua | 27 +- tools/run_driver.sh | 10 +- 57 files changed, 2694 insertions(+), 1336 deletions(-) create mode 100644 src/core/game3/link/union_plaza_map.lua create mode 100644 tests/drivers/g3link_union_plaza_live.lua create mode 100644 tests/drivers/g3link_union_plaza_perf.lua create mode 100644 tests/drivers/game3_union_plaza_map.lua create mode 100644 tests/drivers/speed_lock_firered.lua create mode 100644 tests/drivers/speed_lock_gold.lua create mode 100644 tests/drivers/speed_lock_red.lua create mode 100644 tests/engine/speed_lock_test.lua create mode 100644 tests/game3_union_plaza_map_test.lua diff --git a/docs/modding.md b/docs/modding.md index 88d107c1..a90aacaf 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1212,9 +1212,9 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, control the battle text/menu layer and the HP/status panels. Both receive `(next, state)` and default to `true`, so vanilla rendering is unchanged. Both hooks apply to Gen 1 and Gen 2 battles. -Text boxes and YES/NO prompts pushed above a battle inherit a `false` result -for that battle, so hiding the bottom layer cannot leave their white backing -behind under another overlay. Text boxes also pass through the hook as their +Text boxes and YES/NO prompts pushed above a battle of either generation +inherit a `false` result for that battle, so hiding the bottom layer cannot +leave their white backing behind under another overlay. Text boxes also pass through the hook as their own state, preserving selective control outside a battle; a wrapper that only owns battle presentation should return `false` only for its active battle or text-box state. @@ -1251,17 +1251,22 @@ level is part of a row label rather than a drawn field. Both are noted in RFC 0019 as follow-ups. `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. +(once per frame), but only on frames where the speed is not locked. Every +battle (wild, trainer, link, online, tournament, spectate), link play and a +fixed-speed minigame lock the logic clock to 1X, and `Game:speedLocked()` +is checked before anything else: the hook is never called on those frames, +so it cannot fast-forward a battle. On every other frame vanilla behavior +resolves the per-category GAME SPEED option (`speedOverworld` or +`speedMenu`) for whichever category `Game.speedCategoryInStack` says is +active. 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). 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. The `--speed`/`POKEPORT_SPEED` +run-argument override also wins over the hook, but not over the lock. +Gen 2 and Gen 3 have no hook; their `logicSpeed()` facades report the +shell's own value, lock included. Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/new-features.md b/docs/new-features.md index 4bdab07f..24e913fa 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -27,6 +27,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Filter Find mods by game**, a generation or single-game filter of its own, with every listing showing the games and tags it declares * **Update all** in one press from the MODS tab, installing every outdated mod and every installed cart the index lists ahead of it, with a summary of what failed * **Rebindable GAME SPEED shortcuts**, SPEED - / SPEED + rows in CONTROLS that move the shoulder hotkeys to any pad button or switch them off +* **Fast-forward locks to 1X in battles and link rooms**, every battle, link session and the FireRed/LeafGreen Union Room on every game +* **40-player Union Room** on FireRed and LeafGreen online, a larger square room where every trainer keeps a fixed spot and anyone can be talked to * **Key bar on the touch pad**, a corner toggle that slides out SAVE, LOAD, SPEED, COLOR, TILT and ZOOM for phones with no keyboard * **Save editor item verbs**, sorting the bag and PC by item number or name, filling one stack or every stack to x99, and a coin editor on every game * **Save editor bag / PC move**, a PC / BAG button on every item row that moves the stack across, clamped to the stack and slot caps diff --git a/mod_option_schemas.json b/mod_option_schemas.json index 592ffeba..4002f364 100644 --- a/mod_option_schemas.json +++ b/mod_option_schemas.json @@ -1 +1 @@ -{"mods":[],"schema_version":1} \ No newline at end of file +{"schema_version":1,"mods":[]} \ No newline at end of file diff --git a/src/core/FixedStep.lua b/src/core/FixedStep.lua index 9d8017c6..45c9ae24 100644 --- a/src/core/FixedStep.lua +++ b/src/core/FixedStep.lua @@ -67,6 +67,7 @@ function FixedStep:init(callback) self.suppressCatchup = false self.dtHistory, self.dtSum = nil, 0 self.phasedFor = nil + self.frameBreak = false end FixedStep.maxAccum = MAX_ACCUM @@ -102,11 +103,13 @@ function FixedStep:update(dt, speed) -- called (the hitch itself already ran inside the current step); absorb -- that one frame as a single step instead of the normal accumulator so -- the burst it would otherwise release doesn't play out as a slide. + self.frameBreak = false if self.suppressCatchup then self.suppressCatchup = false self.dtHistory, self.dtSum = nil, 0 self.accum = self.STEP * RESEED_PHASE self.callback(self.STEP) + self.frameBreak = false return end -- Snap/smooth against the wall-clock frame dt first. Game speed is a @@ -155,6 +158,11 @@ function FixedStep:update(dt, speed) while self.accum >= self.STEP - STEP_EPS do self.accum = self.accum - self.STEP self.callback(self.STEP) + if self.frameBreak then + self.frameBreak = false + self.accum = self.accum % self.STEP + break + end if deadline and self.accum >= self.STEP - STEP_EPS and clock() >= deadline then self.accum = self.accum % self.STEP break @@ -162,6 +170,10 @@ function FixedStep:update(dt, speed) end end +function FixedStep:endFrame() + self.frameBreak = true +end + -- Drop any pending catch-up steps and arm the one-frame clamp above. A -- hitch inside one logic step (map seam setMap / song start) makes the -- next real-time dt huge; without this the while-loop above would advance diff --git a/src/core/Game.lua b/src/core/Game.lua index e75225b2..a4230709 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -101,7 +101,10 @@ function Game:load(opts) -- apply the persisted audio + display options before anything plays self:applyOptions(self.save.options) - FixedStep:init(function(step) self:step(step) end) + FixedStep:init(function(step) + self:step(step) + self:_speedLockEdge() + end) self.fixedStep = FixedStep local OverworldState = require("src.world.OverworldController") @@ -369,12 +372,7 @@ function Game:_resolveLogicSpeed() 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 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") +function Game:speedLocked() -- Link play is always 1X on both machines, and this wins over every other -- source including POKEPORT_SPEED and every per-category option. -- Fast-forward multiplies the logic clock, so a peer at 10X burned a @@ -384,11 +382,30 @@ function Game:logicSpeed() -- 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 + return true, "link" end if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then - return 1 + return true, "minigame" end + if Game.speedCategoryInStack(self.stack) == "battle" then + return true, "battle" + end + return false +end + +function Game:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + +-- The logic multiplier for this frame. Read live rather than cached so the +-- 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() + if self:speedLocked() then return 1 end + local GameSpeed = require("src.core.GameSpeed") if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end -- 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 @@ -404,6 +421,7 @@ function Game:update(dt) -- Give the accumulator room for one full frame at the current speed, -- or the anti-spiral clamp quietly caps every level above ~15X. local speed = self:logicSpeed() + self._frameSpeed = speed FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) -- Audio runs off real time at a fixed 60Hz regardless of game speed or @@ -783,6 +801,7 @@ end function Game:_cycleSpeed(dir) if not (self.save and self.save.options) then return end + if self:speedLocked() then return end local busy local ow = self.overworld if ow then @@ -794,12 +813,6 @@ 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") local key = GameSpeed.optionKey(Game.speedCategoryInStack(self.stack)) self.save.options[key] = GameSpeed.cycle(self.save.options[key], dir) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 420540af..c084e56a 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1208,7 +1208,12 @@ function Game2:load(opts) self:showCopyright() end + local stepBody FixedStep:init(function(dt) + stepBody(dt) + self:_speedLockEdge() + end) + stepBody = function(dt) -- Tool mods (autoplay, accessibility drivers, input visualizers) act on the -- same fixed-step boundary a physical controller does. Raised HERE, ahead -- of both the AUTO_INPUT arm and Input:step, for the reason Gen 1 raises it @@ -1270,7 +1275,7 @@ function Game2:load(opts) self.world:interact() end self.world:step() - end) + end end function Game2:inFillBoot() @@ -1279,7 +1284,29 @@ function Game2:inFillBoot() return self.phase == "boot" and self.stack:top() ~= nil end +function Game2:speedLocked() + if self.linkSession or (self.linkNet and not self.linkNet.closed) then + return true, "link" + end + local states = self.stack and self.stack.states + for i = #(states or {}), 1, -1 do + local state = states[i] + if state and state.isBattle then return true, "battle" end + if state and (state.isFixedSpeed or state.isMinigame) then + return true, "minigame" + end + end + return false +end + +function Game2:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + function Game2:logicSpeed() + if self:speedLocked() then return 1 end return math.max(1, tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) or 1) @@ -1310,6 +1337,7 @@ function Game2:update(dt) -- driver/CLI hook and wins over the saved option. -- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld local speed = self:logicSpeed() + self._frameSpeed = speed if self.phase == "boot" then FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) @@ -2057,6 +2085,7 @@ function Game2:hotkey(key) if loaded then self:continueGame(loaded) end return true elseif hk == "1" then + if self:speedLocked() then return true end local GameSpeed = require("src.core.GameSpeed") options.speed = GameSpeed.cycle(options.speed, 1) persist() @@ -2391,6 +2420,7 @@ function Game2:applyOptions() end function Game2:_cycleSpeed(dir) + if self:speedLocked() then return end local GameSpeed = require("src.core.GameSpeed") self.options.speed = GameSpeed.cycle(self.options.speed, dir) if self.save then self.save.options = self.options end diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 3abc41d3..7fae71b0 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -185,6 +185,7 @@ function Game3:load(opts) if opts.arena then FixedStep:init(function(dt) self:fixedUpdate(dt) + self:_speedLockEdge() end) pcall(function() require("src.core.PresentSync").applyFixedStepPeriod() @@ -208,6 +209,7 @@ function Game3:load(opts) FixedStep:init(function(dt) self:fixedUpdate(dt) + self:_speedLockEdge() end) pcall(function() require("src.core.PresentSync").applyFixedStepPeriod() @@ -414,7 +416,7 @@ function Game3:_handleRegisteredItem() if not item then return end -- src/item_menu.c:2025 local Map = package.loaded["src.core.game3.map"] - if Map and Map.current == "FR_UNION_ROOM" then return end + if Map and require("src.core.game3.link.union_room").isUnionMap(Map.current) then return end -- src/overworld.c:2813 local Link = package.loaded["src.core.game3.link"] if type(Link) == "table" and Link.link ~= nil and Link.inLinkRoom() == true then return end @@ -585,8 +587,53 @@ function Game3:isFixedSpeed() return type(MG) == "table" and MG.isActive ~= nil and MG.isActive() == true end +local function unionRoomMap(id) + return require("src.core.game3.link.union_room").isUnionMap(id) +end + +function Game3:speedLocked() + if self:isFixedSpeed() then return true, "link" end + local Battle = package.loaded["src.core.game3.battle"] + if type(Battle) == "table" and Battle.isActive and Battle.isActive() then + return true, "battle" + end + local Transition = package.loaded["src.core.game3.battle_transition"] + if type(Transition) == "table" and Transition.isActive and Transition.isActive() then + return true, "battle" + end + local Union = package.loaded["src.core.game3.link.union_room"] + if type(Union) == "table" and Union.isActive and Union.isActive() then + return true, "link" + end + local LinkMenu = package.loaded["src.ui.game3.link_menu"] + if type(LinkMenu) == "table" then + if LinkMenu.isOpen and LinkMenu.isOpen() then return true, "link" end + local Direct = LinkMenu.Direct + if type(Direct) == "table" and Direct.isOpen and Direct.isOpen() then + return true, "link" + end + end + if self.phase ~= "field" then return false end + local Map = package.loaded["src.core.game3.map"] + if type(Map) == "table" and unionRoomMap(Map.current) then + return true, "link" + end + local Link = package.loaded["src.core.game3.link"] + if type(Link) == "table" and Link.inLinkRoom then + local ok, inRoom = pcall(Link.inLinkRoom) + if ok and inRoom == true then return true, "link" end + end + return false +end + +function Game3:_speedLockEdge() + if (self._frameSpeed or 1) > 1 and self:speedLocked() then + FixedStep:endFrame() + end +end + function Game3:logicSpeed() - if self:isFixedSpeed() then return 1 end + if self:speedLocked() then return 1 end local override = tonumber(self.speedOverride) if override then return math.max(1, override) end local b = self.phase == "boot" and self.boot @@ -603,6 +650,7 @@ end function Game3:_cycleSpeed(dir) if type(self.options) ~= "table" then return end + if self:speedLocked() then return end local GameSpeed = require("src.core.GameSpeed") local key = GameSpeed.optionKey(self:speedCategory()) self.options[key] = GameSpeed.cycle(self.options[key], dir) @@ -633,6 +681,7 @@ end function Game3:update(dt) local speed = self:logicSpeed() + self._frameSpeed = speed FixedStep.maxAccum = FixedStep.catchupLimit(speed, dt) FixedStep:update(dt, speed) self._audioAccum = (self._audioAccum or 0) + dt diff --git a/src/core/game3/dataset.lua b/src/core/game3/dataset.lua index cecc8f73..60db21d3 100644 --- a/src/core/game3/dataset.lua +++ b/src/core/game3/dataset.lua @@ -339,6 +339,7 @@ function Dataset.hydrate(game) Space.ensureBundle(nil) nEvents = Space.attachEventsToMaps(game.data.maps, Space.bundle) or 0 end + require("src.core.game3.link.union_plaza_map").ensure(game) local NativeTileset = require("src.core.game3.tileset_native") if NativeTileset.install then diff --git a/src/core/game3/field_view.lua b/src/core/game3/field_view.lua index babc307e..e4cd954e 100644 --- a/src/core/game3/field_view.lua +++ b/src/core/game3/field_view.lua @@ -433,10 +433,19 @@ local function actorPriority(a) end end +local function sortActors(a, b) + local ay = a.subpriority or a.sortY or a.y + local by = b.subpriority or b.sortY or b.y + if ay == by then return (a.i or 0) < (b.i or 0) end + return ay < by +end + -- event_object_movement.c:8379-8387, scrcmd.c:1130 -local function applyDrawOrder(actors) - local underActors = {} - local overActors = {} +local function applyDrawOrder(actors, underActors, overActors) + underActors = underActors or {} + overActors = overActors or {} + for i = #underActors, 1, -1 do underActors[i] = nil end + for i = #overActors, 1, -1 do overActors[i] = nil end for _, a in ipairs(actors) do local obj = a.eventObject if obj and obj.fixedPriority then @@ -454,18 +463,14 @@ local function applyDrawOrder(actors) underActors[#underActors + 1] = a end end - local function sortActors(a, b) - local ay = a.subpriority or a.sortY or a.y - local by = b.subpriority or b.sortY or b.y - if ay == by then return (a.i or 0) < (b.i or 0) end - return ay < by - end table.sort(underActors, sortActors) table.sort(overActors, sortActors) return underActors, overActors end FieldView.applyDrawOrder = applyDrawOrder +local owOpts = {} + local function drawSingleActor(game, mapDef, a, camX, camY) local daytime = daytimeFor(game, mapDef) local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") @@ -478,15 +483,14 @@ local function drawSingleActor(game, mapDef, a, camX, camY) drew = true end if not drew and useOw and a.graphicsId ~= nil then - local opts = { - bow = a.bow, - fieldMove = a.fieldMove, - fieldMoveFrame = a.fieldMoveFrame, - fishing = a.fishing, - fishFrame = a.fishFrame, - frame = a.frame, - running = a.running, - } + local opts = owOpts + opts.bow = a.bow + opts.fieldMove = a.fieldMove + opts.fieldMoveFrame = a.fieldMoveFrame + opts.fishing = a.fishing + opts.fishFrame = a.fishFrame + opts.frame = a.frame + opts.running = a.running drew = OwSprites.draw( a.graphicsId, a.x, a.y, camX, camY, a.facing, a.walkPhase, a.stepFlip, opts) end @@ -509,11 +513,16 @@ local function drawSingleActor(game, mapDef, a, camX, camY) if billboarded then love.graphics.pop() end end +local frameActors, frameUnder, frameOver = {}, {}, {} +local npcActors = setmetatable({}, { __mode = "k" }) +local playerActor = {} + local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walkPhase, stepFlip, playerYOff, playerXOff) local okO, Objects = pcall(require, "src.core.game3.objects") local okOw, OwSprites = pcall(require, "src.core.game3.ow_sprites") local useOw = okOw and OwSprites and OwSprites.ready and OwSprites.ready() - local actors = {} + local actors = frameActors + for i = #actors, 1, -1 do actors[i] = nil end local hasObjects = okO and Objects and Objects.hasMap and Objects.hasMap() if hasObjects then @@ -522,23 +531,27 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk if eo.moving and eo.targetY and eo.targetY > (eo.cellY or 0) then sortY = math.max(sortY, eo.targetY * CELL) end - actors[#actors + 1] = { - kind = "npc", - i = eo.localId, - obj = eo.def, - eventObject = eo, - elevation = eo.elevation or (eo.def and eo.def.elevation) or 0, - x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0), - y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0), - sortY = sortY, - facing = eo.facing or "down", - walkPhase = Objects.walkPhase(eo), - stepFlip = eo.stepFlip and true or false, - bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true, - frame = eo.customFrame, - sprite = eo.sprite or spriteNameForObj(eo.def or {}), - graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)), - } + local a = npcActors[eo] + if not a then + a = { kind = "npc", eventObject = eo } + npcActors[eo] = a + end + a.i = eo.localId + a.obj = eo.def + a.elevation = eo.elevation or (eo.def and eo.def.elevation) or 0 + a.x = (eo.px or (eo.cellX * CELL)) + (eo.raiseX or 0) + a.y = (eo.py or (eo.cellY * CELL)) + (eo.raiseY or 0) + a.sortY = sortY + a.facing = eo.facing or "down" + a.walkPhase = Objects.walkPhase(eo) + a.stepFlip = eo.stepFlip and true or false + a.bow = (eo.bowFrames and eo.bowFrames > 0) or eo.raiseHand == true + a.frame = eo.customFrame + a.sprite = eo.sprite or spriteNameForObj(eo.def or {}) + a.graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics)) + a.priority = nil + a.subpriority = nil + actors[#actors + 1] = a end collectNeighborActors(actors, 10000, currentMapId(game), resolveMapDef(game, currentMapId(game))) @@ -606,28 +619,30 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk if not fieldMove and FieldMod and FieldMod.fishingPose then fishFrame, fishX2, fishY2 = FieldMod.fishingPose() end - actors[#actors + 1] = { - kind = "player", - elevation = PlayerMod and PlayerMod.elevation or 3, - x = px + (playerXOff or 0) + (fishX2 or 0), - y = py + (playerYOff or 0) + (fishY2 or 0), - sortY = playerSortY, - facing = facing or "down", - walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0, - stepFlip = stepFlip and true or false, - fieldMove = fieldMove, - fieldMoveFrame = fieldMoveFrame, - fishing = fishFrame ~= nil, - fishFrame = fishFrame, - running = PlayerMod and PlayerMod.runPose and PlayerMod.runPose() or nil, - sprite = playerSpriteName(game), - graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil, - } + local a = playerActor + a.kind = "player" + a.elevation = PlayerMod and PlayerMod.elevation or 3 + a.x = px + (playerXOff or 0) + (fishX2 or 0) + a.y = py + (playerYOff or 0) + (fishY2 or 0) + a.sortY = playerSortY + a.facing = facing or "down" + a.walkPhase = (walkPhase == 1 or walkPhase == true) and 1 or 0 + a.stepFlip = stepFlip and true or false + a.fieldMove = fieldMove + a.fieldMoveFrame = fieldMoveFrame + a.fishing = fishFrame ~= nil + a.fishFrame = fishFrame + a.running = PlayerMod and PlayerMod.runPose and PlayerMod.runPose() or nil + a.sprite = playerSpriteName(game) + a.graphicsId = useOw and OwSprites.playerGraphicsId(game) or nil + a.priority = nil + a.subpriority = nil + actors[#actors + 1] = a end local follower = require("src.world.game3.Follower").actor() if follower then actors[#actors + 1] = follower end - return applyDrawOrder(actors) + return applyDrawOrder(actors, frameUnder, frameOver) end --- Collect visible tile draws grouped by palette slot for batched GbcPalette.with. diff --git a/src/core/game3/link/union_plaza_map.lua b/src/core/game3/link/union_plaza_map.lua new file mode 100644 index 00000000..90321305 --- /dev/null +++ b/src/core/game3/link/union_plaza_map.lua @@ -0,0 +1,213 @@ +local Plaza = {} + +Plaza.MAP_ID = "FR_UNION_ROOM_PLAZA" +Plaza.SOURCE_ID = "FR_UNION_ROOM" +Plaza.CAP = 40 +Plaza.WIDTH = 25 +Plaza.HEIGHT = 25 +Plaza.EXIT_X, Plaza.EXIT_Y = 12, 24 +Plaza.ATTENDANT_LOCAL_ID = 1 + +Plaza.ROLES = { + "ABCDTTTTTTTTTTTTTTTTTTTTE", + "abcdtttttttttttttttttttte", + "FGHHHHHHHHHHHHHHHHHHHHHIJ", + "fg.....................ij", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L..........123..........R", + "L..........456..........R", + "L..........789..........R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "L.......................R", + "Kk_________mno_________pq", + "###########xXz###########", +} + +Plaza.SOURCE = { + A = { 0, 0 }, B = { 1, 0 }, C = { 2, 0 }, D = { 3, 0 }, T = { 4, 0 }, E = { 14, 0 }, + a = { 0, 1 }, b = { 1, 1 }, c = { 2, 1 }, d = { 3, 1 }, t = { 4, 1 }, e = { 14, 1 }, + F = { 0, 2 }, G = { 1, 2 }, H = { 2, 2 }, I = { 13, 2 }, J = { 14, 2 }, + f = { 0, 3 }, g = { 1, 3 }, ["."] = { 2, 3 }, i = { 13, 3 }, j = { 14, 3 }, + L = { 0, 4 }, R = { 14, 4 }, + ["1"] = { 6, 5 }, ["2"] = { 7, 5 }, ["3"] = { 8, 5 }, + ["4"] = { 6, 6 }, ["5"] = { 7, 6 }, ["6"] = { 8, 6 }, + ["7"] = { 6, 7 }, ["8"] = { 7, 7 }, ["9"] = { 8, 7 }, + K = { 0, 10 }, k = { 1, 10 }, _ = { 2, 10 }, m = { 6, 10 }, n = { 7, 10 }, o = { 8, 10 }, + p = { 13, 10 }, q = { 14, 10 }, + ["#"] = { 0, 11 }, x = { 6, 11 }, X = { 7, 11 }, z = { 8, 11 }, +} + +Plaza.BLOCKED = { + A = true, B = true, C = true, D = true, T = true, E = true, + a = true, b = true, c = true, d = true, t = true, e = true, + ["#"] = true, +} + +local CELL_XS = { 3, 6, 9, 12, 15, 18, 21 } +local CELL_YS = { 5, 8, 11, 14, 17, 20 } +local DOOR_X, DOOR_Y = 12, 23 + +local cells, bySlot, byKey = {}, {}, {} +for _, y in ipairs(CELL_YS) do + for _, x in ipairs(CELL_XS) do + if not (x == DOOR_X and y == 20) then + cells[#cells + 1] = { x = x, y = y } + end + end +end +table.sort(cells, function(p, q) + local dp = math.abs(p.x - DOOR_X) + math.abs(p.y - DOOR_Y) + local dq = math.abs(q.x - DOOR_X) + math.abs(q.y - DOOR_Y) + if dp ~= dq then return dp < dq end + local ap, aq = math.abs(p.x - DOOR_X), math.abs(q.x - DOOR_X) + if ap ~= aq then return ap < aq end + if p.x ~= q.x then return p.x < q.x end + return p.y > q.y +end) +for i, c in ipairs(cells) do + if i <= Plaza.CAP then + bySlot[i] = c + byKey[c.y * 64 + c.x] = i + end +end +Plaza.CELLS = cells + +function Plaza.cellFor(slot) + local c = bySlot[tonumber(slot) or -1] + if not c then return nil end + return c.x, c.y, "down" +end + +function Plaza.slotAt(x, y) + x, y = tonumber(x), tonumber(y) + if not (x and y) then return nil end + return byKey[y * 64 + x] +end + +function Plaza.entry() + return Plaza.EXIT_X, Plaza.EXIT_Y, "up" +end + +function Plaza.roleAt(x, y) + local row = Plaza.ROLES[y + 1] + if not row or x < 0 or x >= #row then return nil end + return row:sub(x + 1, x + 1) +end + +local function copyRow(row) + local out = {} + for k, v in pairs(row) do out[k] = v end + return out +end + +function Plaza.buildDecoded(src) + local cellsOut = {} + for y = 0, Plaza.HEIGHT - 1 do + for x = 0, Plaza.WIDTH - 1 do + local s = Plaza.SOURCE[Plaza.roleAt(x, y)] + if s[1] >= (src.trueWidth or src.width or 0) or s[2] >= (src.trueHeight or src.height or 0) then + error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.SOURCE_ID, s[1], s[2])) + end + local c = src:cellAt(s[1], s[2]) + cellsOut[y * Plaza.WIDTH + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev } + end + end + local border = {} + for i, m in ipairs(src.borderMids or { 0 }) do border[i] = m end + return { + width = Plaza.WIDTH, + height = Plaza.HEIGHT, + trueWidth = Plaza.WIDTH, + trueHeight = Plaza.HEIGHT, + borderWidth = src.borderWidth or 1, + borderHeight = src.borderHeight or 1, + borderMids = border, + cells = cellsOut, + } +end + +local function buildEvents(ev) + if type(ev) ~= "table" then return nil end + local out = {} + for k, v in pairs(ev) do out[k] = v end + local objs = {} + for _, row in ipairs(ev.objects or ev.objectEvents or {}) do + if tonumber(row.localId) == Plaza.ATTENDANT_LOCAL_ID then + objs[#objs + 1] = copyRow(row) + end + end + out.objects = objs + out.objectEvents = nil + out.bgEvents = {} + out.coordEvents = {} + return out +end + +function Plaza.ensure(game) + local maps = game and game.data and game.data.maps + if type(maps) ~= "table" then error("union plaza: no map table to build " .. Plaza.MAP_ID .. " into", 2) end + local srcDef = maps[Plaza.SOURCE_ID] + if not srcDef then + error("union plaza: " .. Plaza.SOURCE_ID .. " is missing from the cache; re-import the ROM", 2) + end + if not srcDef.midLayout then + local okM, Map = pcall(require, "src.core.game3.map") + if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, Plaza.SOURCE_ID, srcDef) end + end + local src = srcDef.midLayout + if not src then + error("union plaza: " .. Plaza.SOURCE_ID .. " has no layout in the cache; re-import the ROM", 2) + end + + local def = maps[Plaza.MAP_ID] + if not (def and def._plazaSource == src) then + local LayoutNative = require("src.core.game3.layout_native") + local pair = src.pair or srcDef.pair + def = {} + for k, v in pairs(srcDef) do def[k] = v end + def.id = Plaza.MAP_ID + def.name = Plaza.MAP_ID + def.width = Plaza.WIDTH + def.height = Plaza.HEIGHT + def.connections = {} + def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src), Plaza.MAP_ID, pair) + def.pair = pair + local warp = copyRow((srcDef.warps and srcDef.warps[1]) or {}) + warp.x, warp.y = Plaza.EXIT_X, Plaza.EXIT_Y + def.warps = { warp } + def.objects = nil + def.objectEvents = nil + def.bgEvents = {} + def.coordEvents = {} + def._plazaSource = src + maps[Plaza.MAP_ID] = def + end + + local Space = package.loaded["src.core.game3.scripting.space"] + local bundle = Space and Space.bundle + local events = bundle and bundle.events + if type(events) == "table" and events[Plaza.SOURCE_ID] + and events[Plaza.MAP_ID] == nil then + events[Plaza.MAP_ID] = buildEvents(events[Plaza.SOURCE_ID]) + end + local ev = events and events[Plaza.MAP_ID] + if ev and def.objects == nil and Space.attachEventsToMaps then + Space.attachEventsToMaps({ [Plaza.MAP_ID] = def }, bundle) + end + return def +end + +return Plaza diff --git a/src/core/game3/link/union_room.lua b/src/core/game3/link/union_room.lua index aa210a49..8b4ce1ec 100644 --- a/src/core/game3/link/union_room.lua +++ b/src/core/game3/link/union_room.lua @@ -29,9 +29,6 @@ Union.ACTIVITY = { -- pokefirered/include/constants/union_room.h:49 Union.IN_UNION_ROOM = 0x40 --- pokefirered/include/constants/union_room.h:8 -Union.MAX_LEADERS = 8 - -- pokefirered/include/constants/union_room.h:51 Union.LINK_GROUP = { SINGLE_BATTLE = 0, @@ -79,8 +76,6 @@ Union.ACTIVITY_NAMES = RomText.lazy({ [12] = "sLinkGroupActivityNameTexts[12]", }) --- pokefirered/src/union_room_player_avatar.c:97 -Union.LOCAL_IDS = { 9, 8, 7, 2, 6, 5, 4, 3 } Union.AVATARS_FILE = "data/generated/gba/union_room/avatars.lua" -- pokefirered/include/constants/global.h:110 Union.DIR = { NONE = 0, SOUTH = 1, NORTH = 2, WEST = 3, EAST = 4 } @@ -106,26 +101,16 @@ function Union.avatarData() return Union._avatars end --- pokefirered/include/constants/flags.h:115 -Union.FLAG_HIDE_PLAYER_1 = 0x63 --- pokefirered/include/constants/vars.h:28 -Union.VAR_OBJ_GFX_ID_0 = 0x4010 -- pokefirered/include/constants/union_room.h:82 Union.INTERACT_ATTENDANT = 9 Union.INTERACT_START_MENU = 10 Union.MAP = "FR_UNION_ROOM" -Union.RESPONSE_SECONDS = 3 Union.MSG = { HELLO = "game3_union_hello", - BYE = "game3_union_bye", - REQUEST = "game3_union_request", - RESPONSE = "game3_union_response", } --- pokefirered/src/union_room_player_avatar.c:566 -Union.REFRESH_FRAMES = 300 Union.AWAIT_ROOM_SECONDS = 20 Union.CARD_WAIT_SECONDS = 3 @@ -148,7 +133,6 @@ Union.activity = nil Union.lastResult = nil Union._name = nil Union._pump = nil -Union._spawned = {} Union._trade = nil Union.relay = false Union.memberIds = {} @@ -156,10 +140,8 @@ Union.invite = nil Union.incoming = nil Union.direct = {} Union._refresh = 0 -Union._refreshTimer = 0 Union._answered = {} Union._await = nil -Union._objs = {} Union._vobjs = {} Union._vobjDirty = false Union.flow = nil @@ -194,10 +176,6 @@ local function objects() return package.loaded["src.core.game3.objects"] end -local function flags() - return require("src.core.game3.scripting.flags") -end - local function ctxOf(extra) return Union.textCtx(extra) end @@ -210,8 +188,23 @@ function Union.isActive() return Union.state ~= "off" end +local function plazaMap() + return require("src.core.game3.link.union_plaza_map") +end + +Union.plazaMap = plazaMap + +function Union.isUnionMap(id) + if type(id) ~= "string" then return false end + return id == Union.MAP or id == plazaMap().MAP_ID +end + function Union.onUnionRoomMap() - return link().currentMap() == Union.MAP + return Union.isUnionMap(link().currentMap()) +end + +function Union.capacity() + return plazaMap().CAP end -- pokefirered/src/union_room.c:114 URTRADE_STATE_* @@ -256,14 +249,6 @@ function Union.graphicsIdFor(gender, trainerId) return row[((tonumber(trainerId) or 0) % 8) + 1] end --- pokefirered/src/union_room_player_avatar.c:134 -function Union.memberCoords(slot, member) - local data = Union.avatarData() - local c = data.leader_coords[slot] - local o = data.group_offsets[(member or 0) + 1] - return c[1] + o[1], c[2] + o[2] -end - -- pokefirered/src/union_room_player_avatar.c:448 function Union.memberFacing(member, activity) if (member or 0) ~= 0 then return Union.avatarData().member_facing[member + 1] end @@ -272,62 +257,7 @@ function Union.memberFacing(member, activity) return Union.DIR.EAST end --- pokefirered/src/union_room_player_avatar.c:169 CreateUnionRoomPlayerObjectEvent -function Union.spawnLeader(slot, player) - if not (slot and slot >= 1 and slot <= Union.MAX_LEADERS) then return false end - local L = link() - local store = L.store() - local F = flags() - local gfx = Union.graphicsIdFor(player and player.gender, player and player.trainerId) - F.setVar(store, nil, Union.VAR_OBJ_GFX_ID_0 + (slot - 1), gfx) - -- pokefirered/src/union_room_player_avatar.c:159 ShowUnionRoomPlayer - F.setFlag(store, nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), false) - local Objects = objects() - if Objects then - if Objects.addObject then Objects.addObject(Union.LOCAL_IDS[slot]) end - if Objects.refreshGraphics then Objects.refreshGraphics() end - end - Union._spawned[slot] = true - return true -end - --- pokefirered/src/union_room_player_avatar.c:174 RemoveUnionRoomPlayerObjectEvent -function Union.despawnLeader(slot) - if not (slot and slot >= 1 and slot <= Union.MAX_LEADERS) then return false end - local L = link() - local F = flags() - -- pokefirered/src/union_room_player_avatar.c:154 HideUnionRoomPlayer - F.setFlag(L.store(), nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), true) - local Objects = objects() - if Objects and Objects.removeObject then Objects.removeObject(Union.LOCAL_IDS[slot]) end - Union._spawned[slot] = nil - return true -end - -local function avatarLive(slot) - local Objects = objects() - local eo = Objects and Objects.find and Objects.find(Union.LOCAL_IDS[slot]) - return eo ~= nil and eo.visible == true -end - --- pokefirered/src/union_room_player_avatar.c:367 Task_AnimateUnionRoomPlayers -function Union.refreshAvatars() - if not Union.onUnionRoomMap() then return 0 end - local n = 0 - for slot = 1, Union.MAX_LEADERS do - if Union.players[slot] and not avatarLive(slot) then - Union.spawnLeader(slot, Union.players[slot]) - n = n + 1 - end - end - return n -end - function Union.despawnAll() - for slot = 1, Union.MAX_LEADERS do - if Union._spawned[slot] then Union.despawnLeader(slot) end - end - Union._objs = {} if next(Union._vobjs or {}) then Union._vobjs = {} local VirtualObjects = package.loaded["src.core.game3.virtual_objects"] @@ -351,152 +281,106 @@ local function playerOn(x, y) return P.moving and tonumber(P.targetX) == x and tonumber(P.targetY) == y or false end -local function leaderObj(slot) - Union._objs = Union._objs or {} - local o = Union._objs[slot] - if not o then - o = { state = 0, anim = 0, sched = nil, y2 = 0 } - Union._objs[slot] = o - end - return o +local function plazaVobjId(slot) + return Union.VOBJ_BASE + slot end -Union.leaderObj = leaderObj +Union.vobjId = plazaVobjId -local function setRaise(slot, y2) - local Objects = objects() - local eo = Objects and Objects.find and Objects.find(Union.LOCAL_IDS[slot]) - if eo then eo.raiseY = y2 end +function Union.vobj(slot) + return Union._vobjs and Union._vobjs[plazaVobjId(slot)] or nil end --- pokefirered/src/union_room_player_avatar.c:265 -local function animateSpawn(slot, o) - if o.anim == 0 then - if not playerStandingStill() then return false end - local x, y = Union.memberCoords(slot, 0) - if playerOn(x, y) then return false end - Union.spawnLeader(slot, o.player) - o.y2 = -Union.FLY_HEIGHT - setRaise(slot, o.y2) - o.anim = 1 - return false - end - o.y2 = math.min(0, o.y2 + Union.FLY_STEP) - setRaise(slot, o.y2) - if o.y2 >= 0 then - o.anim = 0 - return true - end - return false -end - --- pokefirered/src/union_room_player_avatar.c:236 -local function animateDespawn(slot, o) - if o.anim == 0 then - o.y2 = 0 - o.anim = 1 - flags().setFlag(link().store(), nil, Union.FLAG_HIDE_PLAYER_1 + (slot - 1), true) - end - o.y2 = o.y2 - Union.FLY_STEP - setRaise(slot, o.y2) - if o.y2 <= -Union.FLY_HEIGHT then - Union.despawnLeader(slot) - o.anim = 0 - return true - end - return false -end - --- pokefirered/src/union_room_player_avatar.c:325 -function Union.animatePlayer(slot) - local o = leaderObj(slot) - if o.state == 0 then - if o.sched ~= "in" then - o.sched = nil - return - end - o.state, o.anim = 2, 0 - end - if o.state == 2 then - if o.sched == "out" then - o.state, o.anim = 0, 0 - if Union._spawned[slot] then Union.despawnLeader(slot) end - elseif animateSpawn(slot, o) then - o.state = 1 - end - elseif o.state == 1 then - if o.sched == "out" then - o.state, o.anim = 3, 0 - if animateDespawn(slot, o) then o.state = 0 end - end - elseif o.state == 3 then - if animateDespawn(slot, o) then o.state = 0 end - end - o.sched = nil -end - --- pokefirered/src/union_room_player_avatar.c:300 -function Union.scheduleLeaderIn(slot, player) - local o = leaderObj(slot) - o.sched = "in" - o.player = player -end - --- pokefirered/src/union_room_player_avatar.c:313 -function Union.scheduleLeaderOut(slot) - leaderObj(slot).sched = "out" -end - -local function vobjId(slot, member) - return Union.VOBJ_BASE + (slot - 1) * Union.GROUP_SIZE + member -end - -Union.vobjId = vobjId - -function Union.vobj(slot, member) - Union._vobjs = Union._vobjs or {} - return Union._vobjs[vobjId(slot, member)] -end - -function Union.vobjVisible(slot, member) - local v = Union.vobj(slot, member) +function Union.vobjVisible(slot) + local v = Union.vobj(slot) return v ~= nil and v.visible == true end +local function facingDir(facing) + if type(facing) == "number" then return facing end + return Union.FACE_DIR[facing] or Union.DIR.SOUTH +end + +function Union.cellFor(slot) + local x, y, facing = plazaMap().cellFor(slot) + return x, y, facingDir(facing) +end + +local function idleActivity(activity) + local raw = math.floor(tonumber(activity) or 0) % Union.IN_UNION_ROOM + return raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH +end + +Union.idleActivity = idleActivity + +function Union.cellFacing(slot, p) + if p and not idleActivity(p.activity) then return Union.memberFacing(0, p.activity) end + local _, _, dir = Union.cellFor(slot) + return dir +end + -- pokefirered/src/union_room_player_avatar.c:463 -function Union.spawnMember(slot, member, gfx, activity) - Union._vobjs = Union._vobjs or {} - local id = vobjId(slot, member) - local v = Union._vobjs[id] +function Union.showAvatar(slot, p) + local vobjs = Union._vobjs + local id = plazaVobjId(slot) + local v = vobjs[id] if not v then - v = { slot = slot, member = member, visible = false, y2 = 0 } - Union._vobjs[id] = v + v = { slot = slot, member = 0, visible = false, y2 = 0 } + vobjs[id] = v end + local x, y = Union.cellFor(slot) if not v.visible or v.anim == "out" then + if playerOn(x, y) then + v.waiting = true + Union._avatarWaiting = true + return false + end v.visible = true v.anim = "in" v.y2 = -Union.FLY_HEIGHT end - v.gfx = gfx - v.dir = Union.memberFacing(member, activity) - v.x, v.y = Union.memberCoords(slot, member) + v.waiting = nil + v.gfx = Union.graphicsIdFor(p.gender, p.trainerId) + v.x, v.y = x, y + if Union._talkSlot ~= slot then v.dir = Union.cellFacing(slot, p) end Union._vobjDirty = true + return true end -- pokefirered/src/union_room_player_avatar.c:478 -function Union.despawnMember(slot, member) - local v = Union.vobj(slot, member) - if v and v.visible and v.anim ~= "out" then +function Union.hideAvatar(slot) + local v = Union.vobj(slot) + if not v then return end + v.waiting = nil + if v.visible and v.anim ~= "out" then v.anim = "out" v.y2 = v.y2 or 0 Union._vobjDirty = true end end +function Union.retryWaitingAvatars() + if not playerStandingStill() then return end + local waiting = false + for slot = 1, Union.capacity() do + local v = Union.vobj(slot) + if v and v.waiting then + local p = Union.players[slot] + if p and not p.gone then + if not Union.showAvatar(slot, p) then waiting = true end + else + v.waiting = nil + end + end + end + Union._avatarWaiting = waiting +end + -- pokefirered/src/event_object_movement.c:9354 function Union.animateVobjs() local dirty = Union._vobjDirty - for _, v in pairs(Union._vobjs or {}) do + local vobjs = Union._vobjs + for _, v in pairs(vobjs) do if v.anim == "in" then v.y2 = math.min(0, v.y2 + Union.FLY_STEP) if v.y2 >= 0 then v.anim = nil end @@ -507,128 +391,83 @@ function Union.animateVobjs() v.visible = false v.anim = nil v.y2 = 0 + Union._vobjRebuild = true end dirty = true end end if not dirty then return end Union._vobjDirty = false - local okV, VirtualObjects = pcall(require, "src.core.game3.virtual_objects") - if not okV then return end - VirtualObjects.clear() - local ids = {} - for id, v in pairs(Union._vobjs or {}) do - if v.visible then ids[#ids + 1] = id end + local VirtualObjects = package.loaded["src.core.game3.virtual_objects"] + if not VirtualObjects then + local okV, mod = pcall(require, "src.core.game3.virtual_objects") + if not okV then return end + VirtualObjects = mod end - table.sort(ids) - for _, id in ipairs(ids) do - local v = Union._vobjs[id] - local rec = VirtualObjects.spawn(id, v.gfx, v.x, v.y, 3, v.dir) - if rec then - rec.y2 = v.y2 - rec.solid = v.anim ~= "out" - end + if Union._vobjRebuild then + Union._vobjRebuild = false + VirtualObjects.clear() end -end - --- pokefirered/src/union_room_player_avatar.c:486 -function Union.assembleGroup(slot, p) - if not Union.vobjVisible(slot, 0) then - local x, y = Union.memberCoords(slot, 0) - if playerOn(x, y) then return end - Union.spawnMember(slot, 0, Union.graphicsIdFor(p.gender, p.trainerId), p.activity) - end - for i = 1, Union.GROUP_SIZE - 1 do - local partner = p.partners and p.partners[i] - if not partner then - Union.despawnMember(slot, i) - else - local x, y = Union.memberCoords(slot, i) - if not playerOn(x, y) then - Union.spawnMember(slot, i, Union.graphicsIdFor(partner.gender, partner.trainerId), p.activity) + for id, v in pairs(vobjs) do + if v.visible then + local rec = VirtualObjects.get(id) or VirtualObjects.spawn(id, v.gfx, v.x, v.y, 3, v.dir) + if rec then + rec.graphicsId = v.gfx + rec.x, rec.y = v.x, v.y + rec.direction = v.dir + rec.y2 = v.y2 + rec.solid = v.anim ~= "out" end end end end --- pokefirered/src/union_room_player_avatar.c:510 -function Union.spawnGroup(slot, p) - local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM - if raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH then - Union.scheduleLeaderIn(slot, p) - for i = 0, Union.GROUP_SIZE - 1 do Union.despawnMember(slot, i) end - else - Union.scheduleLeaderOut(slot) - Union.assembleGroup(slot, p) - end -end - --- pokefirered/src/union_room_player_avatar.c:544 -function Union.despawnGroup(slot) - Union.scheduleLeaderOut(slot) - for i = 0, Union.GROUP_SIZE - 1 do Union.despawnMember(slot, i) end -end - --- pokefirered/src/union_room_player_avatar.c:552 -function Union.updatePlayerSprites() - Union._refreshTimer = 0 - for slot = 1, Union.MAX_LEADERS do +function Union.refreshPlaza() + for slot = 1, Union.capacity() do local p = Union.players[slot] if p and p.gone then - Union.despawnGroup(slot) + Union.hideAvatar(slot) Union.players[slot] = nil Union.memberIds[slot] = nil if Union.partnerId == slot then Union.partnerId = nil end elseif p then - Union.spawnGroup(slot, p) + Union.showAvatar(slot, p) end end end --- pokefirered/src/union_room_player_avatar.c:566 -function Union.scheduleRefresh() - Union._refreshTimer = Union.REFRESH_FRAMES -end - --- pokefirered/src/union_room_player_avatar.c:571 -function Union.handleRefresh() - Union._refreshTimer = (Union._refreshTimer or 0) + 1 - if Union._refreshTimer > Union.REFRESH_FRAMES then Union.updatePlayerSprites() end -end - function Union.animateAll() - for slot = 1, Union.MAX_LEADERS do Union.animatePlayer(slot) end + if Union._avatarWaiting then Union.retryWaitingAvatars() end Union.animateVobjs() end +local FACING_DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } + -- pokefirered/src/union_room_player_avatar.c:577 function Union.tryInteractWithMember() if not playerStandingStill() then return nil end local P = playerMod() - local d = P and ({ down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } })[P.facing or "down"] + local d = P and FACING_DELTA[P.facing or "down"] if not d then return nil end local fx, fy = (tonumber(P.cellX) or 0) + d[1], (tonumber(P.cellY) or 0) + d[2] - for slot = 1, Union.MAX_LEADERS do - local p = Union.players[slot] - for member = 0, Union.GROUP_SIZE - 1 do - local v = Union.vobj(slot, member) - if v and v.visible and v.anim == nil and p and not p.gone and v.x == fx and v.y == fy then - local opp = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] - v.dir = opp - Union._vobjDirty = true - return slot, member - end - end - end - return nil + local slot = plazaMap().slotAt(fx, fy) + if not slot then return nil end + local p = Union.players[slot] + local v = Union.vobj(slot) + if not (v and v.visible and v.anim == nil and p and not p.gone) then return nil end + v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1] + Union._talkSlot = slot + Union._vobjDirty = true + return slot, 0 end -- pokefirered/src/union_room_player_avatar.c:621 -function Union.updateMemberFacing(slot, member) - local v = slot and Union.vobj(slot, member or 0) +function Union.updateMemberFacing(slot) + if slot == nil or Union._talkSlot == slot then Union._talkSlot = nil end + local v = slot and Union.vobj(slot) local p = slot and Union.players[slot] if not (v and p) then return end - v.dir = Union.memberFacing(member or 0, p.activity) + v.dir = Union.cellFacing(slot, p) Union._vobjDirty = true end @@ -638,7 +477,7 @@ end function Union.playerCount() local n = 0 - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do if Union.players[slot] then n = n + 1 end end return n @@ -646,7 +485,7 @@ end function Union.list() local out = {} - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do local p = Union.players[slot] if p then out[#out + 1] = { slot = slot, name = p.name, activity = p.activity } end end @@ -699,39 +538,6 @@ function Union.bufferPlayerName(ctx, adapters) return false, 1 end -local function addPlayer(hello) - for slot = 1, Union.MAX_LEADERS do - local p = Union.players[slot] - if p and p.trainerId == hello.trainerId and p.name == hello.name then - p.activity = hello.activity or p.activity - return slot, false - end - end - for slot = 1, Union.MAX_LEADERS do - if not Union.players[slot] then - Union.players[slot] = { - name = hello.name, - gender = tonumber(hello.gender) or 0, - trainerId = tonumber(hello.trainerId) or 0, - activity = tonumber(hello.activity) or Union.ACTIVITY.SEARCH, - } - return slot, true - end - end - return nil, false -end - -Union.addPlayer = addPlayer - -function Union.removePlayer(slot) - if not Union.players[slot] then return false end - Union.players[slot] = nil - Union.memberIds[slot] = nil - Union.despawnLeader(slot) - if Union.partnerId == slot then Union.partnerId = nil end - return true -end - function Union.announce() local L = link() local lk = L.link @@ -746,37 +552,28 @@ end -- pokefirered/src/union_room.c:3515 InitUnionRoom function Union.init(ctx) Union._name = nil - if Union.state ~= "off" then return false end - Union.players = {} - Union._spawned = {} - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - -- pokefirered/src/union_room.c:3536 Task_InitUnionRoom - Union.state = "search" - Union.startPump() - end return false end -- pokefirered/src/union_room.c:2579 RunUnionRoom function Union.run(ctx, adapters) link().setResult(ctx, 0) - if Union.state ~= "off" and Union.state ~= "search" and Union.onUnionRoomMap() then - if Union.relay then - Union._objs = {} - Union._spawned = {} - Union._vobjDirty = true - Union.scheduleRefresh() - end + if Union.state ~= "off" and Union.onUnionRoomMap() then + Union._vobjDirty = true + Union._vobjRebuild = true Union.startPump() return false end Union.players = {} - Union._spawned = {} - Union._objs = {} Union._vobjs = {} + Union._vobjDirty = false + Union._vobjRebuild = false + Union._avatarWaiting = false + Union._plazaSynced = false + Union._plazaInstance, Union._plazaRev = nil, nil + Union._offlineSince, Union._offlineWiped = nil, nil + Union._upgradeShown = nil Union.flow = nil - Union._refreshTimer = 0 Union.memberIds = {} Union.partnerId = nil Union.activity = nil @@ -787,13 +584,11 @@ function Union.run(ctx, adapters) Union._await = nil Union.state = "init" local L = link() - Union.relay = L.adapterConnected() - if Union.relay then - L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar()) - L.setStatus("idle") - Union._refresh = 0 - Union._synced = false - end + Union.relay = true + L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar(), plazaMap().CAP) + L.setStatus("idle") + Union._refresh = 0 + Union._synced = false L.setResult(ctx, 0) Union.startPump() return false @@ -802,20 +597,13 @@ end function Union.stop(reason) if Union.state == "off" then return false end local L = link() - if Union.relay then - if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end - L.clientCall("leavePlaza", "union") - L.setStatus("busy") - Union.relay = false - Union.invite = nil - Union.incoming = nil - Union._await = nil - else - local lk = L.link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.BYE, reason = tostring(reason or "left") }) - end - end + if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end + L.clientCall("leavePlaza", "union") + L.setStatus("busy") + Union.relay = false + Union.invite = nil + Union.incoming = nil + Union._await = nil local s = screen() if s and s.isOpen and s.isOpen() then s.close() end Union.flow = nil @@ -832,21 +620,6 @@ function Union.stop(reason) return true end -local function chooserOpen() - local s = screen() - return s and s.isOpen and s.isOpen() and s.mode == "activity" -end - -local function sendRequest(activity) - if Union.relay then return Union.sendInvite(activity) end - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.REQUEST, activity = activity, name = localPlayer().name }) - return true - end - return false -end - -- pokefirered/src/union_room.c:2896 UR_STATE_HANDLE_DO_SOMETHING_PROMPT_INPUT function Union.chooseActivity(index) local item = Union.INVITE_ITEMS[tonumber(index) or 0] @@ -854,7 +627,7 @@ function Union.chooseActivity(index) local M = message() if item.activity == Union.ACTIVITY.NONE then Union.activity = nil - if Union.relay and M and M.show then + if M and M.show then -- pokefirered/src/union_room.c:2916 local p = Union.partnerRow() or {} local text = RomText.ascii(RomText.key("gTexts_UR_IfYouWantToDoSomething", tonumber(p.gender) == 1 and 1 or 0)) @@ -868,9 +641,9 @@ function Union.chooseActivity(index) local activity = item.union and (item.activity + Union.IN_UNION_ROOM) or item.activity Union.activity = activity Union._role = "child" - local sent = sendRequest(activity) + local sent = Union.sendInvite(activity) Union.state = sent and "send_activity_request" or "print_and_exit" - if sent and Union.relay and M and M.show then + if sent and M and M.show then -- pokefirered/src/union_room.c:2941 local p = Union.partnerRow() or {} local g = tonumber(p.gender) == 1 and 1 or 0 @@ -887,31 +660,6 @@ function Union.cancelActivity() return true end -local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } } - -function Union.slotForLocalId(localId) - for slot = 1, Union.MAX_LEADERS do - if Union.LOCAL_IDS[slot] == localId then return slot end - end - return nil -end - --- pokefirered/src/union_room.c:2757 TryInteractWithUnionRoomMember -function Union.facingMemberSlot() - local Player = package.loaded["src.core.game3.player"] - local Objects = objects() - if not (Player and Objects and Objects.at) then return nil end - local d = DELTA[Player.facing or "down"] - if not d then return nil end - local eo = Objects.at((tonumber(Player.cellX) or 0) + d[1], (tonumber(Player.cellY) or 0) + d[2]) - if not eo then return nil end - -- pokefirered/include/constants/event_objects.h:72 - if tonumber(eo.graphicsId) == 66 then return Union.INTERACT_ATTENDANT end - local slot = Union.slotForLocalId(tonumber(eo.localId)) - if slot and Union._spawned[slot] then return slot end - return nil -end - local function pressedA() local game = link().game() local input = game and game.input @@ -919,204 +667,14 @@ local function pressedA() return input:wasPressed("a") and true or false end --- pokefirered/src/union_room.c:2734 UR_STATE_MAIN -local function pollInteraction(ctx) - local L = link() - local result = L.getVar(ctx, L.VAR_RESULT) - if result == 0 and pressedA() then - local slot = Union.facingMemberSlot() - if slot then - local okA, Audio = pcall(require, "src.core.game3.audio") - local okS, SE = pcall(require, "src.core.game3.se_ids") - if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end - result = slot - end - end - if result == 0 then return false end - L.setVar(ctx, L.VAR_RESULT, 0) - if result == Union.INTERACT_ATTENDANT then - Union.state = "interact_with_attendant" - return true - end - if result == Union.INTERACT_START_MENU then return false end - local slot = result - if not Union.players[slot] then - Union.state = "print_and_exit" - return true - end - Union.partnerId = slot - Union.state = "do_something_prompt" - return true -end - -- pokefirered/src/union_room.c:2647 Task_RunUnionRoom function Union.update(dt) if Union.state == "off" then return false end - local L = link() - local ctx = select(1, L.vmCtx()) - local lk = L.link - - if Union.state ~= "search" and not Union.onUnionRoomMap() then + if not Union.onUnionRoomMap() then Union.stop("left_union_room") return false end - - if Union.relay then return Union.relayUpdate(dt, ctx) end - if lk and lk.isOpen and lk:isOpen() then - local hello = lk:take(Union.MSG.HELLO) - while hello do - local slot, isNew = addPlayer(hello) - if slot and isNew then - if Union.state ~= "search" then Union.spawnLeader(slot, Union.players[slot]) end - Union.noteUnionRoomPlayer(Union.players[slot].name) - end - hello = lk:take(Union.MSG.HELLO) - end - if lk:take(Union.MSG.BYE) then - for slot = Union.MAX_LEADERS, 1, -1 do - if Union.players[slot] then Union.removePlayer(slot) end - end - end - local request = lk:take(Union.MSG.REQUEST) - if request and Union.state == "main" then - Union.activity = tonumber(request.activity) - Union._requestName = request.name - Union.state = "player_contacted_you" - end - local response = lk:take(Union.MSG.RESPONSE) - if response then - Union._waited = nil - Union.lastResult = response.accept and "accepted" or "declined" - Union.state = response.accept and "start_activity" or "print_and_exit" - end - elseif Union.state ~= "init" and Union.state ~= "search" then - for slot = Union.MAX_LEADERS, 1, -1 do - if Union.players[slot] then Union.removePlayer(slot) end - end - end - - -- pokefirered/src/union_room.c:2795 HandleUnionRoomPlayerRefresh - if Union.state ~= "search" then Union.refreshAvatars() end - - if Union.state == "search" then - if not (lk and lk.isOpen and lk:isOpen()) then - Union.state = "off" - return false - end - elseif Union.state == "init" then - -- pokefirered/src/union_room.c:2674 UR_STATE_INIT_LINK - Union.announce() - Union.state = "main" - elseif Union.state == "main" then - pollInteraction(ctx) - elseif Union.state == "player_contacted_you" then - Union.askActivityRequest() - elseif Union.state == "handle_activity_request" then - local M = message() - local okC, Choice = pcall(require, "src.ui.game3.choice") - Union.offerYesNo() - local waiting = (M and M.isOpen and M.isOpen()) - or (okC and Choice and Choice.isOpen and Choice.isOpen()) - if not waiting then Union.answerRequest(false) end - elseif Union.state == "do_something_prompt" then - if not chooserOpen() then - local s = screen() - if s then - s.showActivities(Union.INVITE_ITEMS, { - partner = Union.players[Union.partnerId or 0], - onChoose = function(index) Union.chooseActivity(index) end, - onCancel = function() Union.cancelActivity() end, - }) - Union.state = "handle_do_something_prompt_input" - else - Union.state = "main" - end - end - elseif Union.state == "handle_do_something_prompt_input" then - if not chooserOpen() then Union.state = "main" end - elseif Union.state == "interact_with_attendant" then - -- pokefirered/src/union_room.c:3373 UR_STATE_CHECK_TRADING_BOARD - local s = screen() - if s then - s.showPlayers(Union.list(), { - mode = "board", - onPoll = function() return Union.list() end, - -- pokefirered/src/union_room.c:3485 UR_STATE_TRADE_SELECT_MON - onConfirm = function(slot) - if not Union.chooseMonForTradingBoard(slot) then Union.state = "main" end - end, - onCancel = function() Union.state = "main" end, - }) - Union.state = "check_trading_board" - else - Union.state = "main" - end - elseif Union.state == "check_trading_board" then - local s = screen() - if not (s and s.isOpen and s.isOpen()) then Union.state = "main" end - elseif Union.state == "send_activity_request" and Union.relay then - Union.pollInvite() - elseif Union.state == "await_room" then - Union.pollAwaitRoom(dt) - elseif Union.state == "await_link" then - Union.pollAwaitLink(dt) - elseif Union.state == "send_activity_request" then - -- pokefirered/src/union_room.c:2937 UR_STATE_TRAINER_APPEARS_BUSY - Union._waited = (Union._waited or 0) + (tonumber(dt) or 0) - if Union._waited >= Union.RESPONSE_SECONDS then - Union._waited = nil - Union.lastResult = "busy" - local M = message() - if M and M.show then - M.show(RomText.ascii("gText_UR_TrainerAppearsBusy")) - end - Union.state = "print_and_exit" - end - elseif Union.state == "start_activity" then - Union.startActivity() - elseif Union.state == "in_activity" then - local raw = math.floor(tonumber(Union.activity) or 0) - if raw % Union.IN_UNION_ROOM == Union.ACTIVITY.CHAT then - -- pokefirered/src/union_room.c:1949 EnterUnionRoomChat - if not chat().isActive() then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - elseif raw % Union.IN_UNION_ROOM == Union.ACTIVITY.CARD then - -- pokefirered/src/union_room.c:1954 CB2_ShowCard - local okC, TrainerCard = pcall(require, "src.ui.game3.trainer_card") - local open = okC and TrainerCard and TrainerCard.isOpen and TrainerCard.isOpen() - if not open then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - elseif raw % Union.IN_UNION_ROOM == Union.ACTIVITY.TRADE then - -- pokefirered/src/union_room.c:1713 Task_StartUnionRoomTrade - local LT = linkTrade() - if not LT.isActive() then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - else - local LB = battle() - if LB.state == "setup" then - LB.pumpUnionSetup() - elseif LB.state ~= "battle" then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - end - elseif Union.state == "print_and_exit" then - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end - - return true + return Union.relayUpdate(dt, select(1, link().vmCtx())) end -- pokefirered/src/union_room.c:1832 WarpForCableClubActivity @@ -1168,7 +726,7 @@ function Union.startActivity() local act = raw % Union.IN_UNION_ROOM local inRoom = raw >= Union.IN_UNION_ROOM local L = link() - local warpOpts = Union.relay and { onDone = function() end } or nil + local warpOpts = { onDone = function() end } if act == Union.ACTIVITY.BATTLE_SINGLE and inRoom then -- pokefirered/src/union_room.c:1811 StartUnionRoomBattle if battle().startUnionRoomBattle() then @@ -1189,7 +747,7 @@ function Union.startActivity() return true elseif act == Union.ACTIVITY.TRADE and inRoom then -- pokefirered/src/union_room.c:1936 Task_StartUnionRoomTrade - if Union.relay then Union.prepareBoardTrade() end + Union.prepareBoardTrade() if linkTrade().startUnionRoomTrade(function() Union.state = "main" end) then Union.state = "in_activity" return true @@ -1199,20 +757,10 @@ function Union.startActivity() Union.stop() Union.warpForCableClubActivity(Union.TRADE_CENTER, L.USING.TRADE_CENTER, warpOpts) return true - elseif act == Union.ACTIVITY.CHAT and Union.relay then + elseif act == Union.ACTIVITY.CHAT then -- pokefirered/src/union_room.c:1949 local rs = Union._chatSession or L.clientCall("roomSession") if rs then return Union.enterChat(rs) end - elseif act == Union.ACTIVITY.CHAT then - -- pokefirered/src/union_room.c:1949 EnterUnionRoomChat - if chat().start({ onDone = function() - Union.activity = nil - Union.partnerId = nil - Union.state = "main" - end }) then - Union.state = "in_activity" - return true - end elseif act == Union.ACTIVITY.CARD then -- pokefirered/src/union_room.c:1954 CB2_ShowCard if Union.showPartnerCard() then @@ -1447,7 +995,7 @@ end -- pokefirered/src/union_room.c:4400 function Union.boardOffers() local out = {} - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do local p = Union.players[slot] local b = p and not p.gone and type(p.board) == "table" and p.board or nil if b and (tonumber(b.species) or 0) ~= 0 then @@ -1509,7 +1057,7 @@ function Union.openTradingBoard() own = own, offers = offers, onChoose = function(_, item) - if not item or item.id == 8 then return Union.toMain() end + if not item or item.id == s.BOARD_EXIT then return Union.toMain() end local entry = item.entry if not entry then return Union.toMain() end Union.boardPick(entry) @@ -1672,37 +1220,28 @@ end -- pokefirered/src/union_room.c:3148 function Union.answerRequest(accept) - if Union.relay then - local inv = Union.incoming - Union.incoming = nil - local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM - local M = message() - local shown = inv and M and M.show and type(love) == "table" and love.graphics - if inv then link().clientCall("replyInvite", inv.id, accept and true or false) end - Union.lastResult = accept and "accepted" or "declined" - if accept and inv then - Union._role = "parent" - Union.flow = nil - Union.beginAwaitRoom(nil) - else - Union.activity = nil - if shown then - -- pokefirered/src/union_room.c:887 - local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD) - and "gText_UR_OfferDeclined2" or "gText_UR_OfferDeclined1" - return Union.printAndExit(RomText.ascii(key)) - end - Union.flow = nil - Union.state = "main" - end - return true - end - local lk = link().link - if lk and lk.isOpen and lk:isOpen() then - lk:send({ type = Union.MSG.RESPONSE, accept = accept and true or false }) - end - Union.state = accept and "start_activity" or "main" + local inv = Union.incoming + Union.incoming = nil + local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM + local M = message() + local shown = inv and M and M.show and type(love) == "table" and love.graphics + if inv then link().clientCall("replyInvite", inv.id, accept and true or false) end Union.lastResult = accept and "accepted" or "declined" + if accept and inv then + Union._role = "parent" + Union.flow = nil + Union.beginAwaitRoom(nil) + else + Union.activity = nil + if shown then + -- pokefirered/src/union_room.c:887 + local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD) + and "gText_UR_OfferDeclined2" or "gText_UR_OfferDeclined1" + return Union.printAndExit(RomText.ascii(key)) + end + Union.flow = nil + Union.state = "main" + end return true end @@ -1873,7 +1412,7 @@ end function Union.slotForId(id) if id == nil then return nil end - for slot = 1, Union.MAX_LEADERS do + for slot = 1, Union.capacity() do if Union.memberIds[slot] == id then return slot end end return nil @@ -1917,11 +1456,7 @@ local function avatarRow(m) } end -local function partnerSig(list) - local out = {} - for i, p in ipairs(list or {}) do out[i] = tostring(p.id) end - return table.concat(out, ",") -end +local NO_MEMBERS = {} -- pokefirered/src/union_room.c:2781 Union.GONE_GRACE_SECONDS = 1.5 @@ -1929,101 +1464,83 @@ Union.GONE_GRACE_SECONDS = 1.5 function Union.syncPlaza() local plaza = link().clientCall("plaza") if type(plaza) ~= "table" then return false end + if Union._plazaSynced and plaza.instance == Union._plazaInstance and plaza.rev == Union._plazaRev then + return false + end + Union._plazaSynced = true + Union._plazaInstance, Union._plazaRev = plaza.instance, plaza.rev + Union.plazaBuilds = (Union.plazaBuilds or 0) + 1 local me = myId() local mySlot = tonumber(plaza.you) - local members, present = {}, {} - for _, m in ipairs(type(plaza.members) == "table" and plaza.members or {}) do - if type(m) == "table" and m.id ~= nil and m.id ~= me - and not (mySlot and tonumber(m.slot) == mySlot) then - members[#members + 1] = m - present[m.id] = m - end - end - table.sort(members, function(a, b) return (tonumber(a.slot) or 99) < (tonumber(b.slot) or 99) end) - local partnerOf = {} - for _, m in ipairs(members) do - local g = type(m.group) == "table" and m.group or nil - if g and g.leader ~= nil and g.leader ~= m.id and present[g.leader] then partnerOf[m.id] = g.leader end - end - local leaders, partners = {}, {} - for _, m in ipairs(members) do - if not partnerOf[m.id] then - leaders[m.id] = m - local list = {} - local g = type(m.group) == "table" and m.group or nil - for _, id in ipairs(g and type(g.members) == "table" and g.members or {}) do - if partnerOf[id] == m.id and present[id] and #list < Union.GROUP_SIZE - 1 then - list[#list + 1] = avatarRow(present[id]) - end + local cap = Union.capacity() + local gen = (Union._plazaGen or 0) + 1 + Union._plazaGen = gen + local fresh = false + local members = type(plaza.members) == "table" and plaza.members or NO_MEMBERS + for i = 1, #members do + local m = members[i] + local slot = type(m) == "table" and tonumber(m.slot) or nil + if slot and slot >= 1 and slot <= cap and m.id ~= nil and m.id ~= me and slot ~= mySlot then + local p = Union.players[slot] + if p and p.id ~= m.id then + Union.hideAvatar(slot) + p = nil end - partners[m.id] = list + if not p then + p = avatarRow(m) + Union.players[slot] = p + Union.memberIds[slot] = m.id + Union.noteUnionRoomPlayer(p.name) + fresh = true + end + p.gen = gen + p.gone = nil + p.online = m.online + p.status = m.status + p.group = m.group + p.board = m.board + p.activity = Union.memberActivity(m) end end - local changed, fresh = false, false - local now = Union.clock() - for slot = 1, Union.MAX_LEADERS do + for slot = 1, cap do local p = Union.players[slot] - if p and leaders[p.id] then - p.missingSince = nil - elseif p and not p.gone then - if present[p.id] then - p.gone = true - else - p.missingSince = p.missingSince or now - if now - p.missingSince >= Union.GONE_GRACE_SECONDS then p.gone = true end - end - end - end - for _, m in ipairs(members) do - if leaders[m.id] then - local slot = Union.slotForId(m.id) - local p = slot and Union.players[slot] or nil - local activity = Union.memberActivity(m) - if p then - if p.gone or p.activity ~= activity or partnerSig(p.partners) ~= partnerSig(partners[m.id]) then - changed = true - end - p.gone = nil - p.board = m.board - p.recruiting = m.recruiting - p.status = m.status - p.group = m.group - p.activity = activity - p.partners = partners[m.id] - elseif not slot then - for s = 1, Union.MAX_LEADERS do - if not Union.players[s] then - slot = s - break - end - end - if slot then - local row = avatarRow(m) - row.activity = activity - row.board = m.board - row.recruiting = m.recruiting - row.status = m.status - row.group = m.group - row.partners = partners[m.id] - Union.memberIds[slot] = m.id - Union.players[slot] = row - Union.noteUnionRoomPlayer(row.name) - fresh, changed = true, true - end - end - end + if p and p.gen ~= gen then p.gone = true end end -- pokefirered/src/union_room.c:2783 if fresh and Union._synced then playSe("SE_NOTE_C") end - if changed then Union.scheduleRefresh() end Union._synced = true + Union.refreshPlaza() return true end +function Union.plazaOffline() + local now = Union.clock() + Union._plazaSynced = false + Union._offlineSince = Union._offlineSince or now + if Union._offlineWiped or now - Union._offlineSince < Union.GONE_GRACE_SECONDS then return end + Union._offlineWiped = true + for slot = 1, Union.capacity() do + local p = Union.players[slot] + if p then p.gone = true end + end + Union.refreshPlaza() +end + +function Union.checkUpgrade() + if Union._upgradeShown or Union.state ~= "main" or Union.flow then return end + local up = link().clientCall("upgradeRequired") + if not up then return end + Union._upgradeShown = true + local M = message() + if M and M.show then + Union.printAndExit(require("src.online.Protocol2").upgradeText(up)) + end +end + -- pokefirered/src/union_room.c:3106 function Union.pollIncoming() local L = link() - if Union.state == "init" or Union.state == "search" then return end + if Union.state == "init" then return end for _, inv in ipairs(L.clientCall("invites") or {}) do local id = type(inv) == "table" and inv.id or nil if id ~= nil and not Union._answered[id] then @@ -2048,16 +1565,13 @@ end function Union.relayTick(_dt) local L = link() if not L.online() then - for slot = Union.MAX_LEADERS, 1, -1 do - local p = Union.players[slot] - if p and not p.gone then p.gone = true end - end - Union.handleRefresh() + Union.plazaOffline() Union.animateAll() + Union.checkUpgrade() return end + Union._offlineSince, Union._offlineWiped = nil, nil Union.syncPlaza() - Union.handleRefresh() Union.animateAll() Union.pollIncoming() if Union.state == "main" and L.link then @@ -2175,15 +1689,16 @@ function Union.toMain() Union.flow = nil Union.activity = nil Union.state = "main" + if Union._talkSlot then Union.updateMemberFacing(Union._talkSlot) end Union.releaseScript() end -- pokefirered/src/union_room.c:3455 -function Union.printAndExit(text, slot, member) +function Union.printAndExit(text, slot) return Union.runFlow(stepsOf( Union.sayStep(text), Union.doStep(function() - Union.updateMemberFacing(slot, member) + Union.updateMemberFacing(slot) Union.toMain() end) ), "print_and_exit") @@ -2308,32 +1823,35 @@ function Union.doSomethingPrompt(again) ), "do_something_prompt") end +function Union.chatHasRoom(p) + local g = type(p.group) == "table" and p.group or nil + local seats = g and type(g.members) == "table" and #g.members or 0 + return seats < Union.GROUP_SIZE +end + -- pokefirered/src/union_room.c:2805 -function Union.talkTo(slot, member) - member = member or 0 +function Union.talkTo(slot) local p = Union.players[slot] if not p or p.gone then - return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot, member) + return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot) end Union.partnerId = slot Union._partner = p local raw = math.floor(tonumber(p.activity) or 0) % Union.IN_UNION_ROOM - local idle = raw == Union.ACTIVITY.NONE or raw == Union.ACTIVITY.PLYRTALK or raw == Union.ACTIVITY.SEARCH - if member == 0 and idle then return Union.doSomethingPrompt(false) end - local who = member == 0 and p or (p.partners and p.partners[member]) or p - local g = genderIndex(who) - local ctx = textCtx({ stringVars = { who.name or "" } }) - if member == 0 and raw == Union.ACTIVITY.CHAT and #(p.partners or {}) < Union.GROUP_SIZE - 1 then + if idleActivity(p.activity) then return Union.doSomethingPrompt(false) end + local g = genderIndex(p) + local ctx = textCtx({ stringVars = { p.name or "" } }) + if raw == Union.ACTIVITY.CHAT and Union.chatHasRoom(p) then local met = Union.metBefore(p) and 1 or 0 return Union.runFlow(stepsOf( Union.stayStep(RomText.ascii(Union.JOIN_CHAT_TEXTS[met][g], ctx)), Union.yesNoStep(function(yes) if yes then return Union.joinChat(slot) end - Union.printAndExit(RomText.ascii(RomText.key("gTexts_UR_DeclineChat", g), ctx), slot, member) + Union.printAndExit(RomText.ascii(RomText.key("gTexts_UR_DeclineChat", g), ctx), slot) end) ), "recv_join_chat_request") end - return Union.printAndExit(Union.reactionText(p.activity, g, who.name), slot, member) + return Union.printAndExit(Union.reactionText(p.activity, g, p.name), slot) end -- pokefirered/src/union_room.c:3027 @@ -2388,7 +1906,6 @@ function Union.pollMain(ctx) if result ~= 0 then L.setVar(ctx, L.VAR_RESULT, 0) if result == Union.INTERACT_ATTENDANT then return Union.attendant() end - if result >= 1 and result <= Union.MAX_LEADERS then return Union.talkTo(result, 0) end Union.releaseScript() return end @@ -2396,10 +1913,10 @@ function Union.pollMain(ctx) local Hud = package.loaded["src.ui.game3.hud"] if Hud and Hud.busy and Hud.busy() then return end if not pressedA() then return end - local slot, member = Union.tryInteractWithMember() + local slot = Union.tryInteractWithMember() if slot then playSe("SE_SELECT") - return Union.talkTo(slot, member) + return Union.talkTo(slot) end if Union.facingTradingBoard() then return Union.checkTradingBoard() end end @@ -2407,10 +1924,11 @@ end function Union.facingTradingBoard() local P = playerMod() if not P or P.moving then return false end - local d = ({ down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } })[P.facing or "down"] + local d = FACING_DELTA[P.facing or "down"] if not d then return false end - return (tonumber(P.cellX) or 0) + d[1] == Union.TRADING_BOARD.x - and (tonumber(P.cellY) or 0) + d[2] == Union.TRADING_BOARD.y + local board = plazaMap().TRADING_BOARD or Union.TRADING_BOARD + return (tonumber(P.cellX) or 0) + d[1] == board.x + and (tonumber(P.cellY) or 0) + d[2] == board.y end function Union.namedRequest(name) @@ -2496,7 +2014,7 @@ function Union.relayUpdate(dt, ctx) if Union.flow then return true end end local st = Union.state - if st == "init" or st == "search" then + if st == "init" then Union.state = "main" elseif st == "main" then Union.pollMain(ctx) @@ -2601,8 +2119,7 @@ function Union.pollInvite() text = RomText.ascii("gText_UR_TrainerAppearsBusy") end if M and M.isOpen and M.isOpen() then M.close() end - if Union.relay and M and M.show then return Union.printAndExit(text) end - if M and M.show then M.show(text) end + if M and M.show then return Union.printAndExit(text) end Union.state = "print_and_exit" end @@ -2669,7 +2186,7 @@ function Union.pollAwaitLink(dt) if raw == Union.ACTIVITY.CARD and not L.peerCard and a.t < Union.CARD_WAIT_SECONDS then return end Union._await = nil local M = message() - if Union.relay and M and M.show and type(love) == "table" and love.graphics then + if M and M.show and type(love) == "table" and love.graphics then if raw == Union.ACTIVITY.CARD then return Union.cardFlow() end return Union.printStartActivity() end @@ -3459,7 +2976,6 @@ function Union.reset() Union.stop("reset") Union.state = "off" Union.players = {} - Union._spawned = {} Union.memberIds = {} Union.partnerId = nil Union.activity = nil @@ -3467,7 +2983,6 @@ function Union.reset() Union._name = nil Union._pump = nil Union._trade = nil - Union._waited = nil Union._requestName = nil Union.relay = false Union.invite = nil @@ -3479,10 +2994,14 @@ function Union.reset() Union._synced = false Union._groupMembers = nil Union._choose = nil - Union._objs = {} Union._vobjs = {} Union._vobjDirty = false - Union._refreshTimer = 0 + Union._vobjRebuild = false + Union._avatarWaiting = false + Union._plazaSynced = false + Union._plazaInstance, Union._plazaRev = nil, nil + Union._offlineSince, Union._offlineWiped = nil, nil + Union._upgradeShown = nil Union.flow = nil Union._partner = nil Union._role = nil diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index bfe75e68..f07b2197 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -570,35 +570,45 @@ end local VIRT_DIR_FACE = { [1] = "down", [2] = "up", [3] = "left", [4] = "right" } +local drawList = {} +local vrecs = {} + function Objects.forDraw() - local list = {} + local list = drawList + local n = 0 for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] -- src/event_object_movement.c:8014 if eo and eo.visible and not eo.hidden and not eo.invisible and not offMap(Objects._bounds, eo) then - list[#list + 1] = eo + n = n + 1 + list[n] = eo end end -- src/event_object_movement.c:1719 - if VirtualObjects.count() > 0 then - for _, vo in ipairs(VirtualObjects.list()) do + for i = 1, VirtualObjects.slots() do + local vo = VirtualObjects.nth(i) + if vo then + local vrec = vrecs[vo.id] + if not vrec then + vrec = { virtualId = vo.id, visible = true, hidden = false } + vrecs[vo.id] = vrec + end local gid = tonumber(vo.graphicsId) or 0 - local vrec = { - virtualId = vo.id, - cellX = tonumber(vo.x) or 0, - cellY = tonumber(vo.y) or 0, - elevation = tonumber(vo.elevation) or 3, - facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down", - sprite = GfxIds.spriteFor(gid), - graphicsId = gid, - raiseY = tonumber(vo.y2) or 0, - visible = true, - hidden = false, - } - if not offMap(Objects._bounds, vrec) then list[#list + 1] = vrec end + vrec.cellX = tonumber(vo.x) or 0 + vrec.cellY = tonumber(vo.y) or 0 + vrec.elevation = tonumber(vo.elevation) or 3 + vrec.facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down" + vrec.sprite = GfxIds.spriteFor(gid) + vrec.graphicsId = gid + vrec.raiseY = tonumber(vo.y2) or 0 + if not offMap(Objects._bounds, vrec) then + n = n + 1 + list[n] = vrec + end end end + for i = #list, n + 1, -1 do list[i] = nil end return list end @@ -642,10 +652,9 @@ function Objects.blocks(tx, ty, exceptLocalId, elevation) end end -- pokefirered/src/union_room_player_avatar.c:475 - if VirtualObjects.count() > 0 then - for _, vo in ipairs(VirtualObjects.list()) do - if vo.solid == true and tonumber(vo.x) == tx and tonumber(vo.y) == ty then return true end - end + for i = 1, VirtualObjects.slots() do + local vo = VirtualObjects.nth(i) + if vo and vo.solid == true and tonumber(vo.x) == tx and tonumber(vo.y) == ty then return true end end return false end diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 356b3b78..27c88327 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -76,6 +76,19 @@ local CONTINUE_GAME_WARP = 0x01 -- pokefirered/data/maps/PokemonLeague_HallOfFame/scripts.inc:40 local HALL_OF_FAME_MAP = "FR_POKEMON_LEAGUE_HALL_OF_FAME" +local UNION_ROOMS = { FR_UNION_ROOM = true, FR_UNION_ROOM_PLAZA = true } +-- pokefirered/data/maps/ViridianCity_PokemonCenter_2F/map.json:84 +local UNION_DOOR_X, UNION_DOOR_Y = 5, 1 +local FIRST_CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" + +local function union_room_door_map(session) + local heal = type(session.healMap) == "string" and session.healMap or "" + local city = heal:match("^(.+_POKEMON_CENTER)_1F$") + if city then return city .. "_2F" end + if heal:match("_POKECENTER$") then return heal .. "_2F" end + return FIRST_CENTER_2F +end + -- pokefirered/src/overworld.c:1706 CB2_ContinueSavedGame local function use_continue_game_warp(session, mounted) local Bit = require("bit") @@ -87,6 +100,11 @@ local function use_continue_game_warp(session, mounted) return end session._continueWarpDeferred = nil + if UNION_ROOMS[session.map] then + session.map, session.x, session.y, session.facing = + union_room_door_map(session), UNION_DOOR_X, UNION_DOOR_Y, "down" + return + end if session.map == HALL_OF_FAME_MAP then local Field = require("src.core.game3.field") if not mounted and not Field.flyDestinationsMounted() then @@ -111,6 +129,7 @@ local LINK_ROOMS = { FR_RECORD_CORNER = true, FR_BATTLE_COLOSSEUM_4P = true, FR_UNION_ROOM = true, + FR_UNION_ROOM_PLAZA = true, } -- pokefirered/src/load_save.c:149 SetContinueGameWarpStatusToDynamicWarp diff --git a/src/core/game3/scripting/adapters.lua b/src/core/game3/scripting/adapters.lua index 4f9dfd99..af38c110 100644 --- a/src/core/game3/scripting/adapters.lua +++ b/src/core/game3/scripting/adapters.lua @@ -1228,6 +1228,12 @@ function Adapters.host(mod, game, world) return v end local cx, cy = as_coord(x), as_coord(y) + if mapId == "FR_UNION_ROOM" then + local Plaza = require("src.core.game3.link.union_plaza_map") + Plaza.ensure(resolveGame()) + mapId = Plaza.MAP_ID + cx, cy = Plaza.entry() + end local wid = tonumber(warpId) if (not cx or not cy) and wid and wid ~= 0xFF and wid >= 0 then local def = w and w.data and w.data.maps and w.data.maps[mapId] diff --git a/src/core/game3/virtual_objects.lua b/src/core/game3/virtual_objects.lua index ac2e379a..735fdde9 100644 --- a/src/core/game3/virtual_objects.lua +++ b/src/core/game3/virtual_objects.lua @@ -64,6 +64,15 @@ function VirtualObjects.list() return out end +function VirtualObjects.slots() + return #order +end + +function VirtualObjects.nth(i) + local id = order[i] + return id and byId[id] or nil +end + function VirtualObjects.count() local n = 0 for _ in pairs(byId) do n = n + 1 end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 8c0fec24..ec38eef7 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -344,20 +344,12 @@ local function coreRows(opts, hooks) local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed") if okSpd then - -- 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.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) diff --git a/src/link/Wire.lua b/src/link/Wire.lua index 0c9d9427..a6d59493 100644 --- a/src/link/Wire.lua +++ b/src/link/Wire.lua @@ -327,7 +327,7 @@ local MAX_TEAM = 6 local MAX_DEADLINES = 8 local MAX_U32 = 4294967295 local MAX_SEATS = 5 -local MAX_PLAZA = 9 +local MAX_PLAZA = 40 local MAX_DIRECT = 64 local MAX_GROUPS = 64 local MAX_AVATAR_NAME = 16 @@ -979,6 +979,7 @@ end SCHEMAS.plaza_join = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), + cap = Wire.num(m.cap, nil, 1, MAX_PLAZA), profile = profile(m.profile), avatar = Wire.avatar(m.avatar), } @@ -992,6 +993,8 @@ SCHEMAS.plaza_state = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), instance = Wire.num(m.instance, nil, 0, MAX_INT), + cap = Wire.num(m.cap, nil, 1, MAX_PLAZA), + rev = Wire.num(m.rev, nil, 0, MAX_INT), you = Wire.num(m.you, nil, 1, MAX_PLAZA), members = type(m.members) == "table" and Wire.list(m.members, MAX_PLAZA, Wire.member) or nil, @@ -1002,6 +1005,7 @@ SCHEMAS.plaza_delta = function(m) return { kind = Wire.str(m.kind, nil, MAX_NAME), instance = Wire.num(m.instance, nil, 0, MAX_INT), + rev = Wire.num(m.rev, nil, 0, MAX_INT), joined = Wire.list(m.joined, MAX_PLAZA, Wire.member), left = idList(m.left, MAX_PLAZA), changed = Wire.list(m.changed, MAX_PLAZA, Wire.member), diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index b1c3a5a3..c473b2e5 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -258,11 +258,14 @@ function Loader:_installDevShim() -- doing it is the hole this closes, and any future path that runs mod -- code without a sandbox env still lands here. local owner = Runtime.currentMod or Runtime.modRequire - if owner or callerIsMod(3) then + if owner then local id = type(owner) == "string" and owner or nil local denial = Sandbox.moduleDenial(name, devShim.permissions[id]) or (id and crossGenerationDenial(name, devShim.generation)) if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end + else + local denial = Sandbox.moduleDenial(name, nil) + if denial and callerIsMod(3) then error(("[mod] %s"):format(denial), 0) end end if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end -- The Gen 1 name a mod asked for, answered by this generation's compat arm. diff --git a/src/online/Client.lua b/src/online/Client.lua index 35bcdd11..5f7ed943 100644 --- a/src/online/Client.lua +++ b/src/online/Client.lua @@ -143,6 +143,11 @@ function Client.upgradeRequired() return S.upgrade end function Client.invites() return S.invites end function Client.outgoing() return S.outgoing end function Client.plaza() return S.plaza end +function Client.plazaRev() + local p = S.plaza + if not p then return nil end + return p.instance, p.rev +end function Client.plazaCounts() return S.plazaCounts end function Client.group() return S.group end function Client.direct() return S.direct end @@ -747,6 +752,7 @@ local function applyPlazaDelta(msg) and msg.instance ~= plaza.instance then return false end + if type(msg.rev) == "number" then plaza.rev = msg.rev end local gone = {} for _, id in ipairs(msg.left or {}) do gone[id] = true end local members = {} @@ -831,7 +837,7 @@ local function resendState() if S.presence then sendRaw(Protocol2.presence(S.presence)) end for _, kind in ipairs({ "union", "wireless" }) do local j = S.plazaJoins[kind] - if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar)) end + if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap)) end end local board = S.presence and S.presence.board if S.plazaJoins.union and type(board) == "table" then @@ -1051,7 +1057,7 @@ local function handle(msg) emit("invite_closed", msg) elseif kind == "plaza_state" then S.plaza = { kind = msg.kind, instance = msg.instance, you = msg.you, - members = sortMembers(msg.members) } + cap = msg.cap, rev = msg.rev, members = sortMembers(msg.members) } emit("plaza", S.plaza) elseif kind == "plaza_delta" then if applyPlazaDelta(msg) then emit("plaza", S.plaza) end @@ -1398,10 +1404,11 @@ function Client.replyInvite(id, accept) return sendRaw(Protocol2.inviteReply(id, accept == true)) end -function Client.joinPlaza(kind, profile, avatar) +function Client.joinPlaza(kind, profile, avatar, cap) profile = profile or defaultProfile(3) - S.plazaJoins[kind] = { profile = profile, avatar = avatar } - return sendRaw(Protocol2.plazaJoin(kind, profile, avatar)) + if cap == nil and kind == "union" then cap = Protocol2.PLAZA_CAP end + S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap } + return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap)) end function Client.leavePlaza(kind) diff --git a/src/online/Protocol2.lua b/src/online/Protocol2.lua index 8f455801..47af9e49 100644 --- a/src/online/Protocol2.lua +++ b/src/online/Protocol2.lua @@ -98,9 +98,11 @@ function Protocol2.inviteReply(id, accept) return build({ type = "invite_reply", id = id, accept = accept == true }) end -function Protocol2.plazaJoin(kind, profile, avatar) +Protocol2.PLAZA_CAP = 40 + +function Protocol2.plazaJoin(kind, profile, avatar, cap) return build({ type = "plaza_join", kind = kind, profile = profile, - avatar = avatar }) + avatar = avatar, cap = cap }) end function Protocol2.plazaLeave(kind) @@ -399,11 +401,14 @@ end VALIDATORS.plaza_state = function(m) if type(m.members) ~= "table" then return nil, "plaza_state without members" end if type(m.kind) ~= "string" then return nil, "plaza_state without a kind" end + if m.rev ~= nil and type(m.rev) ~= "number" then return nil, "plaza_state rev is not a number" end + if m.cap ~= nil and type(m.cap) ~= "number" then return nil, "plaza_state cap is not a number" end return m end VALIDATORS.plaza_delta = function(m) if type(m.kind) ~= "string" then return nil, "plaza_delta without a kind" end + if m.rev ~= nil and type(m.rev) ~= "number" then return nil, "plaza_delta rev is not a number" end return m end diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index aa214344..012a146d 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -14,6 +14,7 @@ local Runtime = require("src.mods.Runtime") local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false -- draws over the frozen overworld +BattleTransition.isFixedSpeed = true -- BattleTransition_FlashScreenPalettes: fade to black and back, then to -- white and back; each palette held 2 frames, whole sequence played 3 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 517b2d2f..cdc9f444 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -529,10 +529,7 @@ 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. 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. + -- (src/core/GameSpeed.lua), so this is safe to leave on. { id = "speedOverworld", label = Strings("OVERWORLD SPEED"), value = function(g) return gameSpeedLabel(g.save.options.speedOverworld) @@ -542,15 +539,6 @@ local function buildRows(game) o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir) return true end }, - { id = "speedBattle", label = Strings("BATTLE SPEED"), - value = function(g) - return gameSpeedLabel(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 gameSpeedLabel(g.save.options.speedMenu) @@ -726,7 +714,7 @@ local GROUPS = { members = { "uiLayout", "videoMode", "orientation", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock" } }, { id = "group.speed", label = "SPEED", - members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + members = { "textSpeed", "speedOverworld", "speedMenu" } }, { id = "group.graphics", label = "GRAPHICS", members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } }, -- A mod's Pipelines row splices in after TILT and is in no group, so it diff --git a/src/ui/game3/bag_menu.lua b/src/ui/game3/bag_menu.lua index 967f3e28..5d2eb95b 100644 --- a/src/ui/game3/bag_menu.lua +++ b/src/ui/game3/bag_menu.lua @@ -61,7 +61,7 @@ end -- src/union_room.c:4558 InUnionRoom local function link_menus_active() local Map = package.loaded["src.core.game3.map"] - if Map and Map.current == "FR_UNION_ROOM" then return true end + if Map and require("src.core.game3.link.union_room").isUnionMap(Map.current) then return true end local Link = package.loaded["src.core.game3.link"] return type(Link) == "table" and Link.link ~= nil and Link.inLinkRoom() == true end diff --git a/src/ui/game3/option_rows.lua b/src/ui/game3/option_rows.lua index baac784c..cd2d0e56 100644 --- a/src/ui/game3/option_rows.lua +++ b/src/ui/game3/option_rows.lua @@ -70,7 +70,6 @@ function Rows.build(ctx) step = function(c, dir) return cartCycle(c, "textSpeed", 3, dir) end, }) add(speedRow("speedOverworld", "OVERWORLD SPEED", "speedOverworld")) - add(speedRow("speedBattle", "BATTLE SPEED", "speedBattle")) add(speedRow("speedMenu", "MENU SPEED", "speedMenu")) add({ @@ -397,7 +396,7 @@ end Rows.GROUPS = { { id = "group.speed", label = "SPEED", - members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + members = { "textSpeed", "speedOverworld", "speedMenu" } }, { id = "group.video", label = "VIDEO", members = { "uiLayout", "videoMode", "orientation", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock" } }, diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index fae3fe36..456d101f 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -40,7 +40,7 @@ end local function in_union_room(session) local Map = package.loaded["src.core.game3.map"] local cur = (Map and type(Map.current) == "string" and Map.current) or (session and session.map) - return cur == "FR_UNION_ROOM" + return require("src.core.game3.link.union_room").isUnionMap(cur) end local function safari_active(session) diff --git a/src/ui/game3/union_room.lua b/src/ui/game3/union_room.lua index 9d978bdb..6c3c40a0 100644 --- a/src/ui/game3/union_room.lua +++ b/src/ui/game3/union_room.lua @@ -21,6 +21,9 @@ UnionRoomScreen.TYPES_TEMPLATE = Window.template(20, 2, 9, 11) UnionRoomScreen.BOARD_HEADER_TEMPLATE = Window.template(1, 1, 28, 2) -- pokefirered/src/data/union_room.h:302 UnionRoomScreen.BOARD_TEMPLATE = Window.template(1, 5, 28, 10) +-- pokefirered/src/union_room.c:4400 +UnionRoomScreen.BOARD_ROWS = 8 +UnionRoomScreen.BOARD_EXIT = "exit" -- pokefirered/src/data/union_room.h:197 UnionRoomScreen.ROW_HEIGHT = 14 @@ -202,14 +205,15 @@ function UnionRoomScreen.showBoard(opts) if item.entry then UnionRoomScreen.printBoardRow(item.entry, px, py, UnionRoomScreen.BOARD_SELF) end end } local offers = opts.offers or {} - for i = 0, 7 do + for i = 0, math.max(UnionRoomScreen.BOARD_ROWS, #offers) - 1 do local entry = offers[i + 1] rows[#rows + 1] = { label = "", id = i, disabled = entry == nil, entry = entry, print = function(item, px, py) if item.entry then UnionRoomScreen.printBoardRow(item.entry, px, py, UnionRoomScreen.BOARD_OTHER) end end } end - rows[#rows + 1] = { label = RomText.plain("gText_UR_Exit2"), id = 8, colors = UnionRoomScreen.BOARD_OTHER } + rows[#rows + 1] = { label = RomText.plain("gText_UR_Exit2"), id = UnionRoomScreen.BOARD_EXIT, + colors = UnionRoomScreen.BOARD_OTHER } local shown = UnionRoomScreen.showList("board", { template = UnionRoomScreen.BOARD_TEMPLATE, items = rows, diff --git a/src/ui/game3/wireless_icon.lua b/src/ui/game3/wireless_icon.lua index e954a6fe..ed051592 100644 --- a/src/ui/game3/wireless_icon.lua +++ b/src/ui/game3/wireless_icon.lua @@ -150,6 +150,7 @@ end function WirelessIcon.onLinkMap(mapId) if type(mapId) ~= "string" then return false end if WirelessIcon.LINK_MAPS[mapId] then return true end + if require("src.core.game3.link.union_room").isUnionMap(mapId) then return true end return mapId:match("_POKEMON_CENTER_2F$") ~= nil end diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 38556edb..47aecf5a 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -54,6 +54,7 @@ local Unown = require("src.core.gen2.Unown") local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +BattleState.isBattle = true function BattleState:moveGridNavigation() if not Runtime.wantsHook("battle.move_grid_navigation") then return false end diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index 1a778fb3..9f9411f3 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -35,6 +35,7 @@ local Tilt = require("src.render.Tilt") local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false +BattleTransition.isFixedSpeed = true local COLS, ROWS = 20, 18 -- SCREEN_WIDTH x SCREEN_HEIGHT, in tiles diff --git a/tests/drivers/battle_cry_speed_bug2087_test.lua b/tests/drivers/battle_cry_speed_bug2087_test.lua index 598e6492..8cfbb8f2 100644 --- a/tests/drivers/battle_cry_speed_bug2087_test.lua +++ b/tests/drivers/battle_cry_speed_bug2087_test.lua @@ -25,6 +25,19 @@ return function(game) ow:pushBattle(battle) U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + local function assertLocked(where) + if inBattle() and game:logicSpeed() ~= 1 then + error(("speed lock: %s ran at %sX with speedOverride 4") + :format(where, tostring(game:logicSpeed()))) + end + end + assertLocked("battle start") if Sound.rate() ~= 1 then error(("bug2087: Game:update pitched SFX off battle speed (rate %s at 4X)") :format(tostring(Sound.rate()))) @@ -58,8 +71,11 @@ return function(game) end end + local lockedFrames = 0 local shot = false for _ = 1, 2400 do + assertLocked("entrance") + if inBattle() then lockedFrames = lockedFrames + 1 end poll("entrance cry") if battle.phase == "menu" then break end if battle.waitingSound and not shot then @@ -87,6 +103,7 @@ return function(game) for _ = 1, 2400 do poll("post-move sfx") if game.stack:top() ~= battle then break end + assertLocked("post-move") if #gates > before and cur and not shotLevel then shotLevel = U.shot(game, DIR .. "/bug2087_levelup.png") end @@ -105,11 +122,12 @@ return function(game) fail = ("bug2087: %s was pitched with battle speed (%s)"):format( g.label, tostring(g.pitch)) elseif g.dur and g.held < g.dur * 0.9 then - fail = ("bug2087: %s cut short at 4X (%.3fs of %.3fs)"):format( + fail = ("bug2087: %s cut short (%.3fs of %.3fs)"):format( g.label, g.held, g.dur) end end if fail then error(fail) end - U.log("PASS every battle sfx gate held for its real length at 4X") - U.log("input is yours now") + if lockedFrames == 0 then error("speed lock: the battle never reached the stack") end + U.log("PASS every battle sfx gate held for its real length, battle locked to 1X under a 4X override") + love.event.quit(0) end diff --git a/tests/drivers/battle_fanfare_speed_bug1952_test.lua b/tests/drivers/battle_fanfare_speed_bug1952_test.lua index 578abc83..e62b0ed3 100644 --- a/tests/drivers/battle_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/battle_fanfare_speed_bug1952_test.lua @@ -1,5 +1,4 @@ --- Wait gates hold for the sound's real length at high GAME SPEED --- (#1952/#2087) without pitching one-shot SFX (#1990/#1991/#1997). +-- home/text.asm:506 (#1952/#2087, #1990/#1991/#1997) return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" @@ -26,12 +25,26 @@ return function(game) ow:pushBattle(battle) U.log("logic speed", game:logicSpeed(), "sfx rate", Sound.rate()) + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + local function assertLocked(where) + if inBattle() and game:logicSpeed() ~= 1 then + error(("speed lock: %s ran at %sX with speedOverride 4") + :format(where, tostring(game:logicSpeed()))) + end + end + assertLocked("battle start") if Sound.rate() ~= 1 then error(("bug1952: Game:update pitched SFX off GAME SPEED (rate %s at 4X)") :format(tostring(Sound.rate()))) end for _ = 1, 240 do + assertLocked("battle intro") if battle.phase == "menu" then break end U.tap(game, "a") U.wait(3) @@ -50,6 +63,7 @@ return function(game) local shot = false for _ = 1, 1200 do U.wait(1) + assertLocked("level-up") if battle.waitingSound and not t0 then local src = battle.waitingSound t0 = love.timer.getTime() @@ -80,13 +94,25 @@ return function(game) if not dur then error("bug1952: no duration for the level-up fanfare") end if held < dur * 0.9 then - error(("bug2087: the 4X battle cut the fanfare short (%.3fs of %.3fs)") + error(("bug2087: the battle cut the fanfare short (%.3fs of %.3fs)") :format(held, dur)) end if held > dur + 1 then error(("bug1952: the fanfare dragged past its length (%.3fs of %.3fs)") :format(held, dur)) end - U.log("PASS the fanfare kept natural pitch and played to completion at 4X") - love.event.quit() + for _ = 1, 2400 do + if not inBattle() then break end + assertLocked("battle end") + U.tap(game, "a") + U.wait(1) + end + if inBattle() then error("speed lock: the battle never ended") end + U.wait(2) + if game:logicSpeed() ~= 4 then + error(("speed lock: after the battle logic speed is %s, want 4") + :format(tostring(game:logicSpeed()))) + end + U.log("PASS the fanfare kept natural pitch at 1X in a 4X-override battle, 4X back after") + love.event.quit(0) end diff --git a/tests/drivers/g3link_union_plaza.lua b/tests/drivers/g3link_union_plaza.lua index 278bf073..499aeaff 100644 --- a/tests/drivers/g3link_union_plaza.lua +++ b/tests/drivers/g3link_union_plaza.lua @@ -2,7 +2,9 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID +local OTHERS = Plaza.CAP - 1 -- pokefirered/include/constants/flags.h:1375 local FLAG_SYS_POKEDEX_GET = 0x829 -- pokefirered/include/constants/metatile_behaviors.h:104 @@ -135,13 +137,82 @@ return function(game) me.presence.where = "union" local blue = peer("b0000002", "BLUE", 0x2222, 0, "leafgreen") - local leaf = peer("c0000003", "LEAF", 0x3131, 1, "firered") - relay:handle(blue, { type = "plaza_join", kind = "union", profile = live, avatar = blue.avatar }) - relay:handle(leaf, { type = "plaza_join", kind = "union", profile = live, avatar = leaf.avatar }) - result(waitFor(function() return Union.playerCount() == 2 end, 10, Union.REFRESH_FRAMES + 30), - "both plaza members appear as union room avatars") + relay:handle(blue, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = blue.avatar }) + local crowd = { blue } + for i = 2, OTHERS do + local s = peer(string.format("c%07x", i), "T" .. i, 0x100 + i * 13, i % 2, i % 3 == 0 and "leafgreen" or "firered") + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + crowd[#crowd + 1] = s + end + result(waitFor(function() return Union.playerCount() == OTHERS end, 10, 120), + "all 39 other plaza members appear as avatars") + result(waitFor(function() + local VirtualObjects = require("src.core.game3.virtual_objects") + return VirtualObjects.count() == OTHERS + end, 5, 60), "39 virtual objects, no cart map objects") + local mySlot = Client.plaza() and Client.plaza().you + result(mySlot ~= nil and Union.players[mySlot] == nil, "our own slot is not drawn") + local cellsOk = true + for slot = 1, Plaza.CAP do + if slot ~= mySlot then + local v = Union.vobj(slot) + local x, y = Plaza.cellFor(slot) + if not (v and v.visible and v.x == x and v.y == y) then cellsOk = false end + end + end + result(cellsOk, "every avatar stands on its slot's cell") wait(60) - U.shot(game, DIR .. "/g3link_union_plaza_avatars.png") + U.still(game, DIR .. "/g3link_union_plaza_entrance.png") + place(12, 12, "down") + wait(20) + U.still(game, DIR .. "/g3link_union_plaza_full_room.png") + place(22, 3, "left") + wait(20) + U.still(game, DIR .. "/g3link_union_plaza_far_corner.png") + + local far = crowd[#crowd] + local farSlot = Union.slotForId(far.id) + if farSlot ~= Plaza.CAP then + for _, s in ipairs(crowd) do + if Union.slotForId(s.id) == Plaza.CAP then far, farSlot = s, Plaza.CAP end + end + end + local fx, fy = Plaza.cellFor(Plaza.CAP) + result(farSlot == Plaza.CAP, "someone holds slot 40 at " .. tostring(fx) .. "," .. tostring(fy)) + place(12, 22, "up") + wait(10) + local function walk(dir, axis, target) + for _ = 1, 600 do + local v = axis == "x" and Player.cellX or Player.cellY + if v == target then + waitFor(function() return not Player.moving end, 2, 60) + return (axis == "x" and Player.cellX or Player.cellY) == target + end + if Player.moving then wait(1) else U.hold(game, dir, 1) end + end + return false + end + local walked = walk("up", "y", 18) and walk("left", "x", fx + 1) and walk("up", "y", fy) + if walked and Player.facing ~= "left" then + U.tap(game, "left") + wait(6) + end + print("[driver] walked to " .. tostring(Player.cellX) .. "," .. tostring(Player.cellY) .. " " .. tostring(Player.facing)) + result(walked and Player.cellX == fx + 1 and Player.cellY == fy, "walked through the crowd to slot 40's cell") + U.tap(game, "a") + local prompt = waitFor(function() + if Message.isOpen() and Message.isWaiting() and (Message._page or 1) < #(Message._pages or {}) then + U.tap(game, "a") + end + local Screen = require("src.ui.game3.union_room") + return Screen.isOpen() and Screen.mode == "activity" + end, 8, 600) + result(prompt, "talking to slot 40 opens the do-something prompt") + result(Union.vobj(Plaza.CAP).dir == Union.DIR.EAST, "slot 40 turned to face the player") + wait(10) + U.still(game, DIR .. "/g3link_union_plaza_slot40_prompt.png") + U.tap(game, "b") + drive(function() return Union.state == "main" and not Message.isOpen() end, 8) relay:handle(blue, { type = "invite", to = "a0000001", activity = "chat", detail = {}, profile = live }) result(waitFor(function() return Union.state == "player_contacted_you" or Union.state == "handle_activity_request" end, @@ -170,8 +241,16 @@ return function(game) "leaving the chat ends it") result(Client.room() == nil, "and leaves the private room") - Map.load(nil, game, CENTER_2F, { x = 7, y = 5, facing = "down" }) - wait(90) + place(12, 22, "down") + wait(10) + for _ = 1, 20 do + if Map.current == CENTER_2F then break end + U.hold(game, "down", 8) + relay:pump() + end + result(waitFor(function() return Map.current == CENTER_2F end, 8, 300), "the exit pad warps back to the PC 2F") + wait(60) + U.still(game, DIR .. "/g3link_union_plaza_exit_2f.png") result(Union.state == "off", "leaving the Union Room stops it") result(Client.plaza() == nil, "and leaves the plaza") @@ -194,9 +273,9 @@ return function(game) local rows = LinkMenu.rows print("[driver] rows trade=" .. tostring(rows[1] and rows[1].count) .. " battle=" .. tostring(rows[2] and rows[2].count) .. " union=" .. tostring(rows[3] and rows[3].count) .. " total=" .. tostring(rows[4] and rows[4].count)) - result(rows[Status.GROUPTYPE.UNION] and rows[Status.GROUPTYPE.UNION].count == 2, - "UNION ROOM counts the two plaza members") - result(rows[Status.GROUPTYPE.TOTAL] and rows[Status.GROUPTYPE.TOTAL].count == 2, "TOTAL = trade + battle + union") + result(rows[Status.GROUPTYPE.UNION] and rows[Status.GROUPTYPE.UNION].count == OTHERS, + "UNION ROOM counts the 39 plaza members") + result(rows[Status.GROUPTYPE.TOTAL] and rows[Status.GROUPTYPE.TOTAL].count == OTHERS, "TOTAL = trade + battle + union") result(relay.wireless["a0000001"] == true, "the monitor subscribed to wireless counts") U.shot(game, DIR .. "/g3link_wireless_monitor_counts.png") U.tap(game, "a") diff --git a/tests/drivers/g3link_union_plaza_live.lua b/tests/drivers/g3link_union_plaza_live.lua new file mode 100644 index 00000000..2ae400a1 --- /dev/null +++ b/tests/drivers/g3link_union_plaza_live.lua @@ -0,0 +1,228 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +local Plaza = require("src.core.game3.link.union_plaza_map") +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 +local ROLE = os.getenv("B4_ROLE") or "A" +local NAMES = { A = "ALICE", B = "BOB", C = "CAROL" } +local NAME = NAMES[ROLE] +-- pokefirered/include/constants/species.h:10 +local CHARIZARD, CATERPIE, BULBASAUR = 6, 10, 1 + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. "[" .. ROLE .. "] " .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + local Link = package.loaded["src.core.game3.link"] + if Link then pcall(Link.reset) end + local Client = package.loaded["src.online.Client"] + if Client then pcall(Client.disconnect) end + print((failures == 0 and "PASS" or "FAIL") .. " g3link_union_plaza_live role=" .. ROLE .. " failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) + coroutine.yield() +end + +local function now() return love.timer.getTime() end + +return function(game) + print("PASS driver_started") + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = NAME }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local Party = require("src.core.game3.party") + local Link = require("src.core.game3.link") + local Union = require("src.core.game3.link.union_room") + local LB = require("src.core.game3.link.battle") + local Chat = require("src.core.game3.link.chat") + local Screen = require("src.ui.game3.union_room") + local Client = require("src.online.Client") + local Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + + local session = Runtime.getSession() + session.name = NAME + if ROLE == "A" then Party.giveMon(session, CHARIZARD, 70) + elseif ROLE == "B" then Party.giveMon(session, CATERPIE, 3) + else Party.giveMon(session, BULBASAUR, 5) end + + local function ctx() return Space.vm and Space.vm.ctx end + local function place(x, y, facing) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + game.session.x, game.session.y, game.session.facing = x, y, facing + end + local function waitFor(cond, seconds, onFrame) + local t0 = now() + while not cond() do + if onFrame then onFrame() end + U.wait(1) + if now() - t0 > (seconds or 5) then return false end + end + return true + end + local function pageThrough() + if Message.isOpen() and Message.isWaiting() and (Message._page or 1) < #(Message._pages or {}) then + U.tap(game, "a") + end + end + local function findByName(name) + for slot = 1, Plaza.CAP do + local p = Union.players[slot] + if p and not p.gone and p.name == name then return slot, p end + end + return nil + end + local function talkTo(slot) + local x, y = Plaza.cellFor(slot) + place(x, y + 1, "up") + U.wait(4) + U.tap(game, "a") + end + local function mashBattle(seconds) + local sawBattle = waitFor(function() return LB.state == "battle" end, 30, pageThrough) + if not sawBattle then return false, false end + local t0, k = now(), 0 + while LB.state == "battle" or LB.state == "setup" do + k = k + 1 + if k % 8 == 0 then U.tap(game, "a") else U.wait(1) end + if now() - t0 > seconds then return true, false end + end + return true, waitFor(function() return Union.state == "main" end, 30, function() + if Message.isOpen() then U.tap(game, "a") end + end) + end + + local live = Link.liveProfile() + if not result(live ~= nil, "live g3 profile (vanilla)") then return finish() end + Link.connect() + if not result(waitFor(function() return Client.state() == "online" end, 20), "online on the local relay") then + print("[driver] client state=" .. tostring(Client.state()) .. " err=" .. tostring(Client.error())) + return finish() + end + + Map.load(nil, game, CENTER_2F, { x = 5, y = 1, facing = "up" }) + U.wait(30) + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + place(5, 1, "up") + U.wait(5) + -- pokefirered/data/specials.inc:12 + Natives.special(ctx(), 0x01, Space.vm.adapters) + local slotKey = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slotKey:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") + if not result(waitFor(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 30), + "entered the plaza over the real relay") then + return finish() + end + result(waitFor(function() return Client.plaza() ~= nil end, 10), "plaza_state from the relay") + local mySlot = Client.plaza() and Client.plaza().you + print("[driver] " .. ROLE .. " slot=" .. tostring(mySlot) .. " instance=" .. tostring(Client.plaza() and Client.plaza().instance) + .. " cap=" .. tostring(Client.plaza() and Client.plaza().cap) .. " rev=" .. tostring(Client.plaza() and Client.plaza().rev)) + result(Client.plaza() and Client.plaza().cap == 40, "the relay reports a 40-cap plaza") + + if ROLE == "A" then + local slotB + result(waitFor(function() slotB = findByName("BOB") return slotB ~= nil end, 60), "BOB appears in the plaza") + if not slotB then return finish() end + U.wait(60) + local bx, by = Plaza.cellFor(slotB) + print("[driver] A sees BOB on slot " .. slotB .. " cell " .. bx .. "," .. by) + U.still(game, DIR .. "/live_a_sees_bob.png") + talkTo(slotB) + result(waitFor(function() return Screen.isOpen() and Screen.mode == "activity" end, 10, pageThrough), + "talking to BOB opens the do-something prompt") + Screen.cursor = 2 + U.tap(game, "a") + local started, back = mashBattle(180) + result(started, "the battle with BOB started") + result(back, "the battle ended and ALICE is back in the Union Room") + result(waitFor(function() return Space.mapId == Plaza.MAP_ID end, 10), "still on the plaza map") + U.wait(90) + local slotB2 = findByName("BOB") + result(Client.plaza() and Client.plaza().you == mySlot, "ALICE kept slot " .. tostring(mySlot)) + result(slotB2 == slotB, "BOB is back on slot " .. tostring(slotB)) + local v = Union.vobj(slotB) + result(v and v.visible and v.x == bx and v.y == by, "BOB's avatar stands on the same cell") + U.still(game, DIR .. "/live_a_after_battle.png") + U.wait(120) + talkTo(slotB) + result(waitFor(function() return Screen.isOpen() and Screen.mode == "activity" end, 10, pageThrough), + "BOB's menu again") + Screen.cursor = 3 + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "ALICE and BOB are chatting") + local three = waitFor(function() + local room = Client.room() + return room and room.players and #room.players >= 3 + end, 120) + result(three, "CAROL joined the chat") + U.wait(120) + U.still(game, DIR .. "/live_a_chat_three.png") + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + elseif ROLE == "B" then + result(waitFor(function() return Choice.active end, 120, pageThrough), "ALICE's battle request rings BOB") + U.wait(10) + U.tap(game, "a") + local started, back = mashBattle(180) + result(started, "the battle with ALICE started") + result(back, "BOB is back in the Union Room") + U.wait(90) + result(Client.plaza() and Client.plaza().you == mySlot, "BOB kept slot " .. tostring(mySlot)) + local slotA = findByName("ALICE") + local ax, ay = Plaza.cellFor(slotA or 0) + local v = slotA and Union.vobj(slotA) + result(v and v.visible and v.x == ax and v.y == ay, "ALICE's avatar stands on her cell") + U.still(game, DIR .. "/live_b_after_battle.png") + result(waitFor(function() return Choice.active end, 120, pageThrough), "ALICE's chat request rings BOB") + U.wait(10) + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "BOB is chatting") + waitFor(function() + local room = Client.room() + return room and room.players and #room.players >= 3 + end, 120) + waitFor(function() return not Chat.isActive() end, 60) + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + else + local slotB + result(waitFor(function() + slotB = findByName("BOB") + local p = slotB and Union.players[slotB] + return p and (p.activity % Union.IN_UNION_ROOM) == Union.ACTIVITY.CHAT + end, 300), "CAROL sees BOB chatting") + if not slotB then return finish() end + U.wait(30) + talkTo(slotB) + result(waitFor(function() return Choice.active end, 15, pageThrough), "the join-chat prompt") + U.still(game, DIR .. "/live_c_join_prompt.png") + U.tap(game, "a") + result(waitFor(function() return Chat.isActive() end, 40, pageThrough), "CAROL joined the chat") + U.wait(120) + U.still(game, DIR .. "/live_c_chat.png") + Chat.stop("left") + waitFor(function() return Union.state == "main" end, 10) + end + finish() +end diff --git a/tests/drivers/g3link_union_plaza_perf.lua b/tests/drivers/g3link_union_plaza_perf.lua new file mode 100644 index 00000000..c4ea43e9 --- /dev/null +++ b/tests/drivers/g3link_union_plaza_perf.lua @@ -0,0 +1,196 @@ +local U = require("tests.drivers.util") + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +local Plaza = require("src.core.game3.link.union_plaza_map") +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 +local SECONDS = tonumber(os.getenv("PERF_SECONDS")) or 20 +local LABEL = os.getenv("POKEPORT_TOUCH") == "1" and "touch" or "desktop" + +local failures = 0 +local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end + +local function finish() + local Link = package.loaded["src.core.game3.link"] + if Link then pcall(Link.reset) end + local Client = package.loaded["src.online.Client"] + if Client then pcall(Client.disconnect) end + if failures == 0 then + print("PASS g3link_union_plaza_perf") + love.event.quit(0) + else + print("FAIL g3link_union_plaza_perf failures=" .. failures) + love.event.quit(1) + end +end + +local function now() return love.timer.getTime() end + +return function(game) + print("PASS driver_started") + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Message = require("src.ui.game3.message") + local Choice = require("src.ui.game3.choice") + local Link = require("src.core.game3.link") + local Union = require("src.core.game3.link.union_room") + local Client = require("src.online.Client") + local Relay = require("tests.support.fake_relay") + local Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + + local relay = Relay.new({ clock = function() return now() end }) + local me = relay:seat("a0000001", "RED") + Client.configure({ relayAddress = "fake:1", connect = function() return me.transport end }) + + local function ctx() return Space.vm and Space.vm.ctx end + local function wait(n) + for _ = 1, n do + relay:pump() + U.wait(1) + end + end + local function waitFor(cond, seconds) + local t0 = now() + while not cond() do + relay:pump() + U.wait(1) + if now() - t0 > (seconds or 5) then return false end + end + return true + end + local function drive(cond, seconds) + local t0 = now() + while not cond() and now() - t0 < (seconds or 10) do + if Choice.active or Message.isOpen() then U.tap(game, "a") end + wait(6) + end + return cond() + end + + local live = Link.liveProfile() + if not result(live ~= nil, "the live g3 profile computes (vanilla game)") then return finish() end + Link.connect() + result(waitFor(function() return Client.state() == "online" end, 5), "online on the relay") + Map.load(nil, game, CENTER_2F, { x = 5, y = 1, facing = "up" }) + wait(30) + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 5, 1, 5, 1 + Player.px, Player.py, Player.facing = 5 * 16, 1 * 16, "up" + wait(5) + -- pokefirered/data/specials.inc:12 + Natives.special(ctx(), 0x01, Space.vm.adapters) + local slot = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slot:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") + if not result(drive(function() return Space.mapId == Plaza.MAP_ID and Union.state == "main" end, 60), + "entered the plaza") then + return finish() + end + + local peers = {} + local function addPeers(n) + while #peers < n do + local i = #peers + 1 + local id = string.format("d%07x", i) + local s = relay:seat(id, "P" .. i) + relay:handle(s, { type = "lobby_hello", protocol = 3, name = "P" .. i, profiles = { live }, + presence = { where = "launcher", status = "idle", version = "firered" } }) + s.avatar = { name = "P" .. i, trainerId = i * 37, gender = i % 2, version = "firered" } + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) + peers[i] = s + end + end + + local ownUpdate, ownDraw = rawget(game, "update"), rawget(game, "draw") + local rawUpdate, rawDraw = game.update, game.draw + local upd, drw = { sum = 0, max = 0, n = 0 }, { sum = 0, max = 0, n = 0 } + local function timed(fn, acc) + return function(self, ...) + local t = love.timer.getTime() + local a, b = fn(self, ...) + local d = (love.timer.getTime() - t) * 1000 + acc.sum, acc.n = acc.sum + d, acc.n + 1 + if d > acc.max then acc.max = d end + return a, b + end + end + game.update = timed(rawUpdate, upd) + game.draw = timed(rawDraw, drw) + + local STATUSES = { "chatting", "battling", "idle" } + local function measure(n) + addPeers(n) + result(waitFor(function() return Union.playerCount() == n end, 10), n .. " avatars in the room") + Player.cellX, Player.cellY, Player.targetX, Player.targetY = 12, 12, 12, 12 + Player.px, Player.py = 12 * 16, 12 * 16 + wait(90) + upd.sum, upd.max, upd.n = 0, 0, 0 + drw.sum, drw.max, drw.n = 0, 0, 0 + collectgarbage("collect") + local kb0 = collectgarbage("count") + local last = kb0 + local allocated = 0 + local frame = { sum = 0, max = 0, n = 0 } + local builds0 = Union.plazaBuilds or 0 + local t0, tPrev, nextChange, k = now(), now(), now() + 1, 0 + while now() - t0 < SECONDS do + relay:pump() + for i = 1, #peers do + local box = peers[i].transport.inbox + for j = #box, 1, -1 do box[j] = nil end + end + if now() >= nextChange then + k = k + 1 + local s = peers[(k - 1) % n + 1] + s.status = STATUSES[(k - 1) % #STATUSES + 1] + relay:plazaChanged(s) + nextChange = nextChange + 1 + end + U.wait(1) + local t = now() + local d = (t - tPrev) * 1000 + tPrev = t + frame.sum, frame.n = frame.sum + d, frame.n + 1 + if d > frame.max then frame.max = d end + local kb = collectgarbage("count") + if kb > last then allocated = allocated + (kb - last) end + last = kb + end + local elapsed = now() - t0 + collectgarbage("collect") + local growth = collectgarbage("count") - kb0 + local line = string.format( + "PERF %s members=%d seconds=%.1f frames=%d frame_ms mean=%.2f max=%.2f update_ms mean=%.3f max=%.3f" + .. " draw_ms mean=%.3f max=%.3f alloc_kb_per_s=%.1f gc_growth_kb=%.1f status_changes=%d rebuilds=%d", + LABEL, n, elapsed, frame.n, frame.sum / math.max(1, frame.n), frame.max, + upd.sum / math.max(1, upd.n), upd.max, drw.sum / math.max(1, drw.n), drw.max, + allocated / elapsed, growth, k, (Union.plazaBuilds or 0) - builds0) + print(line) + local f = io.open(os.getenv("PERF_OUT") or "/tmp/g3link_union_plaza_perf.log", "a") + if f then + f:write(line, "\n") + f:close() + end + result((Union.plazaBuilds or 0) - builds0 <= k + 1, "one rebuild per status change at " .. n) + end + + measure(8) + measure(Plaza.CAP - 1) + game.update, game.draw = ownUpdate, ownDraw + finish() +end diff --git a/tests/drivers/g3link_union_room.lua b/tests/drivers/g3link_union_room.lua index 08aabfb0..86901a31 100644 --- a/tests/drivers/g3link_union_room.lua +++ b/tests/drivers/g3link_union_room.lua @@ -2,7 +2,8 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/g3link" local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID -- pokefirered/include/constants/flags.h:1375 local FLAG_SYS_POKEDEX_GET = 0x829 -- pokefirered/include/constants/species.h:5 @@ -69,16 +70,6 @@ return function(game) Party.giveMon(session, PIDGEY, 9) local relay = Relay.new({ clock = function() return now() end }) - local plazaState = relay.plazaStateMsg - relay.plazaStateMsg = function(self, s) - local msg = plazaState(self, s) - for _, m in ipairs(msg.members or {}) do - local ss = self.sessions[m.id] - if ss and ss.group then m.group = ss.group end - if ss and ss.status then m.status = ss.status end - end - return msg - end local me = relay:seat("a0000001", "RED") Client.configure({ relayAddress = "fake:1", connect = function() return me.transport end }) @@ -125,9 +116,6 @@ return function(game) return cond() end end - local function pushPlaza() - relay:to(me, relay:plazaStateMsg(me)) - end local live = Link.liveProfile() if not result(live ~= nil, "the live g3 profile computes (vanilla game)") then return finish() end @@ -163,32 +151,34 @@ return function(game) local yellow = peer("d0000004", "YELLOW", 6, 0, "firered") for _, s in ipairs({ green, pink, yellow }) do s.presence.where = "union" - relay:handle(s, { type = "plaza_join", kind = "union", profile = live, avatar = s.avatar }) + relay:handle(s, { type = "plaza_join", kind = "union", cap = 40, profile = live, avatar = s.avatar }) end local group = { leader = pink.id, members = { pink.id, yellow.id }, activity = "chat" } pink.group, pink.status = group, "chatting" yellow.group, yellow.status = group, "chatting" - pushPlaza() - result(waitFor(function() return Union.playerCount() == 2 end, 10, 60), - "GREEN takes a leader slot and PINK's chat group another") + relay:plazaChanged(pink) + relay:plazaChanged(yellow) + result(waitFor(function() return Union.playerCount() == 3 end, 10, 60), + "GREEN, PINK and YELLOW each take their own plaza slot") local slotG = Union.slotForId(green.id) local slotP = Union.slotForId(pink.id) - result(slotG ~= nil and slotP ~= nil, "both have slots") - result(waitFor(function() return slotG and Union.leaderObj(slotG).state == 1 end, 5, 60), - "GREEN flew down onto her spot") - result(slotP and Union.vobjVisible(slotP, 0) and Union.vobjVisible(slotP, 1), - "PINK and YELLOW stand at the group offsets") - result(slotP and Union.vobj(slotP, 0).dir == Union.DIR.SOUTH, "the chat leader faces south") - place(7, 9, "up") + local slotY = Union.slotForId(yellow.id) + result(slotG ~= nil and slotP ~= nil and slotY ~= nil, "all three have slots") + result(waitFor(function() return slotG and Union.vobjVisible(slotG) and Union.vobj(slotG).anim == nil end, 5, 60), + "GREEN flew down onto her cell") + local gx, gy = Plaza.cellFor(slotG) + result(Union.vobj(slotG).x == gx and Union.vobj(slotG).y == gy, "on her slot's cell") + result(slotP and Union.vobjVisible(slotP) and Union.vobjVisible(slotY), "PINK and YELLOW stand on their own cells") + result(slotP and Union.vobj(slotP).dir == Union.DIR.SOUTH, "a chatter faces south") + place(12, 22, "up") wait(40) U.still(game, DIR .. "/g3link_union_room_avatars.png") print("[driver] 2. talking to a trainer opens the invite menu") - local Objects = require("src.core.game3.objects") local opened = false for _ = 1, 20 do - local eo = Objects.find(Union.LOCAL_IDS[slotG]) - if eo and not eo.moving then + local eo = { cellX = gx, cellY = gy } + do place(eo.cellX, eo.cellY + 1, "up") wait(2) U.tap(game, "a") @@ -217,7 +207,7 @@ return function(game) drive(function() return Union.state == "main" and not Message.isOpen() end, 8) print("[driver] 3. an incoming invite rings") - place(7, 9, "up") + place(12, 22, "up") wait(6) relay:handle(green, { type = "invite", to = me.id, activity = "chat", detail = {}, profile = live }) result(waitFor(function() return Union.state == "player_contacted_you" or Union.state == "handle_activity_request" end, @@ -319,8 +309,8 @@ return function(game) end local asked = false for _ = 1, 20 do - local eo = Objects.find(Union.LOCAL_IDS[slotG]) - if eo and not eo.moving then + local eo = { cellX = gx, cellY = gy } + do place(eo.cellX, eo.cellY + 1, "up") wait(2) U.tap(game, "a") @@ -351,7 +341,7 @@ return function(game) U.still(game, DIR .. "/g3link_union_request_declined.png") drive(function() return Union.state == "main" and not Message.isOpen() end, 8) - place(7, 9, "up") + place(12, 22, "up") wait(6) relay:handle(green, { type = "invite", to = me.id, activity = "chat", detail = {}, profile = live }) result(waitFor(pageThrough(function() return Choice.active end), 8, 600), "GREEN asks to chat again") @@ -368,7 +358,6 @@ return function(game) print("[driver] 5. the trading board") relay:handle(green, { type = "presence", board = { species = BULBASAUR, level = 14, wantType = 10 } }) relay:handle(yellow, { type = "presence", board = { species = PIDGEY, level = 5, wantType = 12 } }) - pushPlaza() result(waitFor(function() return #Union.boardOffers() >= 1 end, 5, 120), "GREEN's offer is on the board") Union.trade().playerSpecies, Union.trade().playerLevel, Union.trade().type = CHARMANDER, 10, 11 place(2, 2, "up") @@ -395,7 +384,7 @@ return function(game) drive(function() return Union.state == "main" and not Message.isOpen() end, 8) print("[driver] 6. a minigame group recruiting") - place(7, 9, "up") + place(12, 22, "up") wait(6) Flags.setVar(Space.store, ctx(), Link.VAR_0x8004, Union.LINK_GROUP.BERRY_CRUSH) local c = ctx() diff --git a/tests/drivers/game3_link_union_room.lua b/tests/drivers/game3_link_union_room.lua index df6ef713..ca524a2d 100644 --- a/tests/drivers/game3_link_union_room.lua +++ b/tests/drivers/game3_link_union_room.lua @@ -1,7 +1,8 @@ local U = require("tests.drivers.util") local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_link_union_room" -local UNION_ROOM = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_ROOM = Plaza.MAP_ID local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" -- pokefirered/data/specials.inc:5 SetCableClubWarp local SET_CABLE_CLUB_WARP = 0x01 @@ -81,8 +82,10 @@ return function(game) result(session.dynamicWarp ~= nil and session.dynamicWarp.map == CENTER_2F, "SetCableClubWarp recorded the way back to the cable club counter") setVar(VAR_CABLE_CLUB_STATE, 6) - Map.load(nil, game, UNION_ROOM, { x = 7, y = 11, facing = "up" }) - place(7, 11, "up") + local slotKey = require("src.import.gba.map_catalog").slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slotKey:match("(%d+)%D+(%d+)") + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(tonumber(group), tonumber(num), 0xFF, 7, 11, function() end, "warpspinenter") U.wait(120) result(Space.mapId == UNION_ROOM, "the player is in the Union Room") @@ -90,92 +93,9 @@ return function(game) print("[driver] union state after entry: " .. tostring(Union.state)) U.shot(game, DIR .. "/union_room_01_empty.png") - local host, peer = Game3Link.loopback({ game = game }) - host:update(0) - peer:update(0) - Link.attach(host) - result(host:isReady(), "the loopback session handshook") - - peer:send({ - type = Union.MSG.HELLO, - name = "BLUE", - trainerId = 0x2222, - gender = 0, - activity = Union.ACTIVITY.SEARCH + Union.IN_UNION_ROOM, - }) + result(Union.relay and Union.capacity() == Plaza.CAP, "offline, the Union Room is still the 40-player plaza") + result(Union.playerCount() == 0, "with nobody in it") local waited = 0 - while waited < 300 and Union.playerCount() == 0 do - U.wait(10) - waited = waited + 10 - end - if not result(Union.playerCount() == 1, "the peer appeared in the Union Room player list") then - U.shot(game, DIR .. "/union_room_99_no_peer.png") - return finish() - end - local avatar = Objects.find(Union.LOCAL_IDS[1]) - result(avatar ~= nil and avatar.visible == true, - "the slot 1 union room avatar is on the map") - U.wait(30) - U.shot(game, DIR .. "/union_room_02_player_spawned.png") - - -- pokefirered/src/union_room.c:3264 UR_STATE_INTERACT_WITH_ATTENDANT - place(3, 3, "up") - U.wait(15) - waited = 0 - while waited < 200 and not Screen.isOpen() do - U.tap(game, "a") - U.wait(20) - waited = waited + 20 - end - if result(Screen.isOpen(), "the Union Room attendant opened the player list") then - result(Screen.mode == "board", "the list is the trading board view") - result(#Screen.players == 1, "it lists the one other player in the room") - U.wait(20) - U.shot(game, DIR .. "/union_room_03_player_list.png") - U.tap(game, "b") - U.wait(30) - end - - -- pokefirered/data/maps/UnionRoom/scripts.inc:28 UnionRoom_EventScript_Player1 - waited = 0 - while waited < 240 and not Screen.isOpen() do - local eo = Objects.find(Union.LOCAL_IDS[1]) - if eo and not eo.moving then - place(tonumber(eo.cellX), tonumber(eo.cellY) + 1, "up") - U.wait(10) - U.tap(game, "a") - end - U.wait(20) - waited = waited + 30 - end - if not result(Screen.isOpen(), "talking to the player opened the activity chooser") then - print("[driver] VAR_RESULT=" .. tostring(getVar(VAR_RESULT)) .. - " state=" .. tostring(Union.state)) - U.shot(game, DIR .. "/union_room_98_no_chooser.png") - return finish() - end - result(Screen.mode == "activity", "the chooser is the invite-to-activity list") - result(#Screen.items == 4, "GREETINGS / BATTLE / CHAT / EXIT") - U.wait(20) - U.shot(game, DIR .. "/union_room_04_activity_chooser.png") - - U.tap(game, "a") - U.wait(30) - print("[driver] after greeting: activity=" .. tostring(Union.activity) - .. " state=" .. tostring(Union.state)) - result(not Screen.isOpen(), "GREETINGS closed the chooser") - result(Union.activity == Union.ACTIVITY.CARD, "and sent the trainer card activity") - U.shot(game, DIR .. "/union_room_05_after_greeting.png") - - -- pokefirered/src/union_room.c:2937 UR_STATE_TRAINER_APPEARS_BUSY - waited = 0 - while waited < 600 and Union.state ~= "main" do - U.tap(game, "a") - U.wait(20) - waited = waited + 20 - end - result(Union.state == "main", "an unanswered request frees the room again") - -- pokefirered/src/field_fadetransition.c:578 DoUnionRoomWarp waited = 0 while waited < 200 and Screen.isOpen() do @@ -184,7 +104,7 @@ return function(game) waited = waited + 20 end U.wait(30) - place(7, 10, "down") + place(12, 22, "down") U.wait(30) local Message = require("src.ui.game3.message") waited = 0 @@ -194,7 +114,12 @@ return function(game) waited = waited + 20 end U.wait(20) - U.hold(game, "down", 30) + for _ = 1, 20 do + if Space.mapId ~= UNION_ROOM then break end + U.hold(game, "down", 8) + end + print("[driver] exit walk: p=" .. tostring(Player.cellX) .. "," .. tostring(Player.cellY) + .. " msg=" .. tostring(Message.isOpen()) .. " union=" .. tostring(Union.state)) waited = 0 while waited < 600 and Space.mapId == UNION_ROOM do U.wait(15) diff --git a/tests/drivers/game3_union_plaza_map.lua b/tests/drivers/game3_union_plaza_map.lua new file mode 100644 index 00000000..539cf480 --- /dev/null +++ b/tests/drivers/game3_union_plaza_map.lua @@ -0,0 +1,107 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or ".bazinga/union40/shots/map" + +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +-- pokefirered/data/specials.inc:12 +local SET_CABLE_CLUB_WARP = 0x01 +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 + +return function(game) + local function fail(msg) + print("FAIL game3_union_plaza_map: " .. msg) + love.event.quit(1) + coroutine.yield() + end + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + if game.phase ~= "field" then return fail("never reached the field") end + + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Natives = require("src.core.game3.scripting.natives") + local MapCatalog = require("src.import.gba.map_catalog") + local Plaza = require("src.core.game3.link.union_plaza_map") + + local function ctx() return Space.vm and Space.vm.ctx end + local function place(x, y, facing) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing + end + end + + local def = game.data and game.data.maps and game.data.maps[Plaza.MAP_ID] + if not def then return fail("hydrate did not register " .. Plaza.MAP_ID) end + + -- pokefirered/data/scripts/cable_club.inc:743 + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Map.load(nil, game, CENTER_2F, { x = 5, y = 2, facing = "up" }) + place(5, 2, "up") + U.wait(30) + place(5, 1, "up") + U.wait(10) + Natives.special(ctx(), SET_CABLE_CLUB_WARP, Space.vm and Space.vm.adapters) + local dw = game.session and game.session.dynamicWarp + if not (dw and dw.map == CENTER_2F) then return fail("SetCableClubWarp did not record the way back") end + + local slot = MapCatalog.slotKeyFor("FR_UNION_ROOM") or "" + local group, num = slot:match("(%d+)%D+(%d+)") + group, num = tonumber(group), tonumber(num) + if not (group and num) then return fail("no group/num for FR_UNION_ROOM (" .. slot .. ")") end + + local arrived = false + -- pokefirered/data/scripts/cable_club.inc:797 + Space.vm.adapters.warp(group, num, 0xFF, 7, 11, function() arrived = true end, "warpspinenter") + for _ = 1, 600 do + if arrived and Map.current == Plaza.MAP_ID then break end + U.wait(1) + end + if Map.current ~= Plaza.MAP_ID then + return fail("warp to the Union Room landed on " .. tostring(Map.current)) + end + local ex, ey = Plaza.entry() + if Player.cellX ~= ex or Player.cellY ~= ey then + return fail(("spawned at %s,%s want %d,%d"):format(tostring(Player.cellX), tostring(Player.cellY), ex, ey)) + end + U.log("entered", Map.current, Player.cellX, Player.cellY) + U.wait(120) + U.still(game, DIR .. "/plaza_entrance.png") + + place(12, 18, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_center.png") + + place(22, 3, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_far_corner.png") + + place(3, 3, "up") + U.wait(30) + U.still(game, DIR .. "/plaza_counter.png") + + place(12, 22, "down") + U.wait(30) + U.hold(game, "down", 40) + for _ = 1, 300 do + if Map.current == CENTER_2F then break end + U.wait(1) + end + if Map.current ~= CENTER_2F then + return fail("exit landed on " .. tostring(Map.current) .. " want " .. CENTER_2F) + end + U.wait(60) + U.still(game, DIR .. "/plaza_exit_center_2f.png") + U.log("exit landed", Map.current, Player.cellX, Player.cellY) + print("PASS game3_union_plaza_map") + love.event.quit(0) +end diff --git a/tests/drivers/gold_fanfare_speed_bug1952_test.lua b/tests/drivers/gold_fanfare_speed_bug1952_test.lua index 962ae358..610580af 100644 --- a/tests/drivers/gold_fanfare_speed_bug1952_test.lua +++ b/tests/drivers/gold_fanfare_speed_bug1952_test.lua @@ -22,6 +22,10 @@ return function(game) game.speedOverride = 4 U.wait(2) + if game:logicSpeed() ~= 4 then + error(("bug1952: the overworld did not take the 4X override (%s)") + :format(tostring(game:logicSpeed()))) + end U.log("gen2 speed override", 4, "sfx rate", Sound.rate()) if Sound.rate() ~= 1 then error(("bug1952: Game2:update pitched SFX off GAME SPEED (rate %s at 4X)") @@ -71,6 +75,12 @@ return function(game) :format(held, dur)) end + game.linkNet = { closed = false } + if game:logicSpeed() ~= 1 then + error(("speed lock: an open gen 2 link ran at %sX under a 4X override") + :format(tostring(game:logicSpeed()))) + end + game.linkNet = nil game.speedOverride = nil U.wait(2) if Sound.rate() ~= 1 then @@ -80,5 +90,5 @@ return function(game) U.shot(game, DIR .. "/bug1952_gold_after.png") U.log("PASS the gen 2 jingle kept natural pitch under GAME SPEED") - love.event.quit() + love.event.quit(0) end diff --git a/tests/drivers/speed_lock_firered.lua b/tests/drivers/speed_lock_firered.lua new file mode 100644 index 00000000..b7f408ce --- /dev/null +++ b/tests/drivers/speed_lock_firered.lua @@ -0,0 +1,149 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + +local UNION_ROOM = "FR_UNION_ROOM" +local CENTER_2F = "FR_VIRIDIAN_CITY_POKEMON_CENTER_2F" +-- pokefirered/data/specials.inc:12 +local SET_CABLE_CLUB_WARP = 0x01 +-- pokefirered/include/constants/vars.h:163 +local VAR_CABLE_CLUB_STATE = 0x406F +-- pokefirered/include/constants/flags.h:1375 +local FLAG_SYS_POKEDEX_GET = 0x829 + +local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) +end + +return function(game) + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "RED" }) + U.wait(240) + if game.phase ~= "field" then fail("never reached the field") end + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Map = require("src.core.game3.map") + local Space = require("src.core.game3.scripting.space") + local Flags = require("src.core.game3.scripting.flags") + local Player = require("src.core.game3.player") + local Union = require("src.core.game3.link.union_room") + local o = game.options + + game.speedOverride = nil + o.speedOverworld, o.speedBattle, o.speedMenu = 10, 200, 1 + U.wait(2) + if game:logicSpeed() ~= 10 then + fail(("field at OVERWORLD SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + + local session = Runtime.getSession() + session.party = {} + Party.giveMon(session, 6, 50) + local ok, err = BattleBridge.startWild(Runtime._mod, game, { species = 16, level = 2 }, { fade = false }) + if not ok then fail("wild battle did not start " .. tostring(err)) end + + local checked, shot = 0, false + local Ui = require("src.core.game3.battle.ui") + for f = 1, 20000 do + if not Battle.isActive() then break end + if game:logicSpeed() ~= 1 then + fail(("battle ran at %sX with speedBattle 200"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 30 then + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + if o.speedOverworld ~= 10 or o.speedBattle ~= 200 or o.speedMenu ~= 1 then + fail(("speed presses in battle changed options (%s/%s/%s)"):format( + tostring(o.speedOverworld), tostring(o.speedBattle), tostring(o.speedMenu))) + end + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then fail("speedOverride 200 defeated the battle lock") end + game.speedOverride = nil + if game.touchSkinHotkey then game:touchSkinHotkey("fast_forward_hold", true) end + if game:logicSpeed() ~= 1 then fail("skin FF hold defeated the battle lock") end + if game.touchSkinHotkey then game:touchSkinHotkey("fast_forward_hold", false) end + end + if checked == 200 and not shot then + shot = U.shot(game, DIR .. "/firered_battle_locked.png") + end + local st = Battle.getState and Battle.getState() + if st and Battle._phase == "command" and Ui._mode == "menu" and not Ui._pendingCommand then + local mon = st.player.mon + mon.moves[1] = 33 + mon.pp = mon.pp or {} + mon.pp[1] = 10 + st.enemy.mon.hp = 1 + Ui._pendingCommand = { kind = "move", move = 33, slot = 1, user = "player" } + Ui._mode = "none" + U.wait(1) + elseif f % 14 == 0 then + U.tap(game, "a") + else + U.wait(1) + end + end + if Battle.isActive() then + fail(("battle never ended (phase %s, ui %s)"):format(tostring(Battle._phase), tostring(Ui._mode))) + end + U.log("battle frames checked at 1X", checked) + U.wait(120) + if game:logicSpeed() ~= 10 then + fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + end + + local function ctx() return Space.vm and Space.vm.ctx end + local function place(x, y, facing) + Player.cellX, Player.cellY = x, y + Player.px, Player.py = x * 16, y * 16 + Player.targetX, Player.targetY = x, y + Player.facing = facing + if game.session then + game.session.x, game.session.y, game.session.facing = x, y, facing + end + end + -- pokefirered/data/scripts/cable_club.inc:743 + Flags.setFlag(Space.store, ctx(), FLAG_SYS_POKEDEX_GET, true) + Map.load(nil, game, CENTER_2F, { x = 5, y = 2, facing = "up" }) + place(5, 2, "up") + U.wait(60) + if game:logicSpeed() ~= 10 then + fail(("the cable club counter room reads %s, want 10"):format(tostring(game:logicSpeed()))) + end + place(5, 1, "up") + U.wait(10) + local Natives = require("src.core.game3.scripting.natives") + Natives.special(ctx(), SET_CABLE_CLUB_WARP, Space.vm and Space.vm.adapters) + Flags.setVar(Space.store, ctx(), VAR_CABLE_CLUB_STATE, 6) + Map.load(nil, game, UNION_ROOM, { x = 7, y = 11, facing = "up" }) + place(7, 11, "up") + local seen = 0 + for _ = 1, 120 do + U.wait(1) + if game:logicSpeed() ~= 1 then + fail(("the Union Room ran at %sX with OVERWORLD SPEED 10"):format(tostring(game:logicSpeed()))) + end + seen = seen + 1 + end + if not Union.isActive() then U.log("note: union session not active, locked by map") end + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + if o.speedOverworld ~= 10 then + fail(("speed presses in the Union Room moved OVERWORLD SPEED to %s"):format(tostring(o.speedOverworld))) + end + U.shot(game, DIR .. "/firered_union_locked.png") + U.log("union room frames checked at 1X", seen, "union state", tostring(Union.state)) + U.log("PASS firered battle and Union Room locked to 1X, OVERWORLD SPEED 10 back after the battle") + love.event.quit(0) +end diff --git a/tests/drivers/speed_lock_gold.lua b/tests/drivers/speed_lock_gold.lua new file mode 100644 index 00000000..3636e3a7 --- /dev/null +++ b/tests/drivers/speed_lock_gold.lua @@ -0,0 +1,102 @@ +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + U.wait(45) + local world = game.world + if not (world and world.map) then return fail("gold world did not boot") end + + game.speedOverride = nil + game.options.speed = 10 + U.wait(2) + if game:logicSpeed() ~= 10 then + return fail(("overworld at GAME SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + U.log("overworld logic speed", game:logicSpeed()) + + local player = Mon.new(game.data, "CYNDAQUIL", 10) + game.save.party = { player } + local wild = Mon.new(game.data, "PIDGEY", 2) + if not world:startBattle({ wild = wild }) then return fail("startBattle failed") end + + local battle + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + if not battle then return fail("battle screen never came up") end + + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + + local checked, shot = 0, false + for _ = 1, 2000 do + if not inBattle() then break end + if game:logicSpeed() ~= 1 then + return fail(("battle ran at %sX with GAME SPEED 10"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 30 then + game:hotkey("1") + game:_cycleSpeed(1) + game:_cycleSpeed(-1) + if game.options.speed ~= 10 then + return fail(("speed presses in battle moved GAME SPEED to %s"):format(tostring(game.options.speed))) + end + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then + return fail("speedOverride 200 defeated the battle lock") + end + game.speedOverride = nil + end + if battle.phase == "menu" and not shot then + shot = U.shot(game, DIR .. "/gold_battle_locked.png") + end + if battle.battle and battle.battle.over then + tap("a", 3) + elseif battle.phase == "menu" then + tap("a", 4) + tap("a", 4) + else + tap("a", 3) + end + end + if inBattle() then + local top = game.stack:top() + return fail(("battle never ended (phase %s, over %s, top %s)"):format( + tostring(battle.phase), tostring(battle.battle and battle.battle.over), + tostring(top and (top.screenId or top.name) or top))) + end + U.log("battle frames checked at 1X", checked) + U.wait(30) + if game:logicSpeed() ~= 10 then + return fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + end + game.linkNet = { closed = false } + if game:logicSpeed() ~= 1 then return fail("an open linkNet did not lock 1X") end + game.linkNet = nil + game.linkSession = true + if game:logicSpeed() ~= 1 then return fail("a linkSession did not lock 1X") end + game.linkSession = nil + U.shot(game, DIR .. "/gold_after_battle.png") + U.log("PASS gold battle and link locked to 1X, GAME SPEED 10 back after") + love.event.quit(0) +end diff --git a/tests/drivers/speed_lock_red.lua b/tests/drivers/speed_lock_red.lua new file mode 100644 index 00000000..29fe7e28 --- /dev/null +++ b/tests/drivers/speed_lock_red.lua @@ -0,0 +1,87 @@ +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local function fail(msg) + error("FAIL speed lock: " .. msg, 0) + end + local function key(k) + love.keypressed(k, k, false) + love.keyreleased(k, k) + end + + local squirtle = Pokemon.new(game.data, "SQUIRTLE", 30, function(_, b) return b end) + game.save.party = { squirtle } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local o = game.save.options + game.speedOverride = nil + o.speedOverworld, o.speedBattle, o.speedMenu = 10, 200, 1 + U.wait(2) + if game:logicSpeed() ~= 10 then + fail(("overworld at OVERWORLD SPEED 10 reads %s"):format(tostring(game:logicSpeed()))) + end + U.log("overworld logic speed", game:logicSpeed()) + + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.onFinish = function() end + battle.enemy.mon.hp = 1 + ow:pushBattle(battle) + + local function inBattle() + for _, st in ipairs(game.stack.states) do + if st == battle then return true end + end + return false + end + + for _ = 1, 600 do + if inBattle() then break end + U.wait(1) + end + if not inBattle() then fail("the battle never reached the stack") end + + local checked, shot = 0, false + for _ = 1, 2400 do + if not inBattle() then break end + if game:logicSpeed() ~= 1 then + fail(("battle ran at %sX with speedBattle 200"):format(tostring(game:logicSpeed()))) + end + checked = checked + 1 + if checked == 20 then + key("1") + game:gamepadaxis(nil, "triggerright", 1) + game:gamepadaxis(nil, "triggerright", 0) + game:touchSkinHotkey("fast_forward_toggle", true) + if o.speedOverworld ~= 10 or o.speedBattle ~= 200 or o.speedMenu ~= 1 then + fail(("speed presses in battle changed options (%s/%s/%s)"):format( + tostring(o.speedOverworld), tostring(o.speedBattle), tostring(o.speedMenu))) + end + game:touchSkinHotkey("fast_forward_hold", true) + if game:logicSpeed() ~= 1 then fail("skin FF hold defeated the battle lock") end + game:touchSkinHotkey("fast_forward_hold", false) + game.speedOverride = 200 + if game:logicSpeed() ~= 1 then fail("speedOverride 200 defeated the battle lock") end + game.speedOverride = nil + end + if battle.phase == "menu" and not shot then + shot = U.shot(game, DIR .. "/red_battle_locked.png") + end + U.tap(game, "a") + U.wait(2) + end + if inBattle() then fail("battle never ended") end + U.log("battle frames checked at 1X", checked) + U.wait(10) + if game:logicSpeed() ~= 10 then + fail(("after the battle logic speed is %s, want 10"):format(tostring(game:logicSpeed()))) + end + game.linkSession = true + if game:logicSpeed() ~= 1 then fail("a link session did not lock 1X") end + game.linkSession = nil + U.shot(game, DIR .. "/red_after_battle.png") + U.log("PASS red battle and link locked to 1X, OVERWORLD SPEED 10 back after") + love.event.quit(0) +end diff --git a/tests/engine/game3_speed_hotkey_test.lua b/tests/engine/game3_speed_hotkey_test.lua index da01a1eb..a8969f77 100644 --- a/tests/engine/game3_speed_hotkey_test.lua +++ b/tests/engine/game3_speed_hotkey_test.lua @@ -38,6 +38,14 @@ do eq(g:logicSpeed(), 2, "walk speed sticks after the menu closes") battleActive = true eq(g:speedCategory(), "battle", "battle stays battle") + g.options.speedBattle = 10 + eq(g:logicSpeed(), 1, "a gen3 battle runs 1X whatever BATTLE SPEED says") + g.speedOverride = 20 + eq(g:logicSpeed(), 1, "and whatever speedOverride says") + g.speedOverride = nil + g:keypressed("1") + eq(g.options.speedBattle, 10, "1 in battle is ignored") + eq(g.options.speedOverworld, 2, "and leaves OVERWORLD SPEED alone") battleActive = false eq(newGame3("boot"):speedCategory(), "menu", "boot phase stays menu") eq(newGame3("quest_log"):speedCategory(), "menu", "quest log stays menu") @@ -158,4 +166,44 @@ do package.loaded["src.core.game3.minigames.common"] = nil end +do + local g = newGame3("field") + g.options.speedOverworld = 10 + eq(g:logicSpeed(), 10, "plain field walk honors OVERWORLD SPEED") + local unionOn = true + package.loaded["src.core.game3.link.union_room"] = { + isActive = function() return unionOn end, + } + eq(g:logicSpeed(), 1, "an active Union Room is locked to 1X") + g:keypressed("1") + eq(g.options.speedOverworld, 10, "1 in the Union Room is ignored") + unionOn = false + eq(g:logicSpeed(), 10, "leaving the Union Room restores the walk speed") + package.loaded["src.core.game3.link.union_room"] = nil + package.loaded["src.core.game3.map"] = { current = "FR_UNION_ROOM" } + eq(g:logicSpeed(), 1, "standing on the Union Room map is locked to 1X") + package.loaded["src.core.game3.map"] = { current = "FR_PALLET_TOWN" } + eq(g:logicSpeed(), 10, "another map is not") + package.loaded["src.core.game3.map"] = nil + local room = 1 + package.loaded["src.core.game3.link"] = { + inLinkRoom = function() return room ~= 0 end, + } + eq(g:logicSpeed(), 1, "a cable club link room is locked to 1X") + room = 0 + eq(g:logicSpeed(), 10, "and outside it the walk speed is back") + package.loaded["src.core.game3.link"] = nil + local menuOpen, directOpen = true, false + package.loaded["src.ui.game3.link_menu"] = { + isOpen = function() return menuOpen end, + Direct = { isOpen = function() return directOpen end }, + } + eq(g:logicSpeed(), 1, "the wireless link menu is locked to 1X") + menuOpen, directOpen = false, true + eq(g:logicSpeed(), 1, "the Direct Corner chooser is locked to 1X") + directOpen = false + eq(g:logicSpeed(), 10, "closed link menus release the lock") + package.loaded["src.ui.game3.link_menu"] = nil +end + T.finish() diff --git a/tests/engine/game3_virtual_objects_drawhook_test.lua b/tests/engine/game3_virtual_objects_drawhook_test.lua index 8c99828b..47dfee56 100644 --- a/tests/engine/game3_virtual_objects_drawhook_test.lua +++ b/tests/engine/game3_virtual_objects_drawhook_test.lua @@ -31,6 +31,22 @@ eq(found and found.sprite, GfxIds.spriteFor(40), "sprite name reaches the non-OW eq(Objects._byId[1], nil, "the registry is not in the object store (no collision)") eq(Objects.at(5, 6), nil, "Objects.at cannot see it") +local again +for _, eo in ipairs(Objects.forDraw()) do + if eo.virtualId == 1 then again = eo end +end +check(again == found, "forDraw reuses the draw record for an unchanged virtual object") +if jit then jit.off() end +for _ = 1, 20 do Objects.forDraw() end +collectgarbage("collect") +collectgarbage("stop") +local kb0 = collectgarbage("count") +for _ = 1, 100 do Objects.forDraw() end +local grown = collectgarbage("count") - kb0 +collectgarbage("restart") +if jit then jit.on() end +check(grown < 1, "forDraw allocates nothing in steady state (" .. grown .. " KB)") + VirtualObjects.turn(1, 2) local turned for _, eo in ipairs(Objects.forDraw()) do diff --git a/tests/engine/game_speed_categories_test.lua b/tests/engine/game_speed_categories_test.lua index 6303fd42..200124c2 100644 --- a/tests/engine/game_speed_categories_test.lua +++ b/tests/engine/game_speed_categories_test.lua @@ -1,5 +1,5 @@ -- Per-category GAME SPEED (RFC 0007): Game.speedCategoryInStack's stack --- walk, Game:logicSpeed()'s precedence (link lock / run-argument override / +-- walk, Game:logicSpeed()'s precedence (battle/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 @@ -66,6 +66,7 @@ do local g = gameWith({ battle }, { speedOverworld = 4, speedBattle = 10, speedMenu = 2 }) eq(g:_resolveLogicSpeed(), 10, "battle reads speedBattle") + eq(g:logicSpeed(), 1, "but a battle always runs 1X whatever speedBattle says") end do local g = gameWith({ overlay }, @@ -97,10 +98,17 @@ do eq(g:logicSpeed(), 1, "an open linkNet forces 1X the same way") end do - local g = gameWith({ battle }, { speedBattle = 50 }) + local g = gameWith({ overworld }, { speedOverworld = 50 }) g.linkNet = { closed = true } eq(g:logicSpeed(), 50, "a CLOSED linkNet does not force 1X") end +do + local g = gameWith({ overworld, battle }, { speedOverworld = 50, speedBattle = 50 }) + g.speedOverride = 20 + eq(g:logicSpeed(), 1, "a battle forces 1X over speedOverride too") + local locked, why = g:speedLocked() + check(locked and why == "battle", "speedLocked reports the battle") +end do local g = gameWith({ overworld }, { speedOverworld = 4 }) g.speedOverride = 20 @@ -172,10 +180,20 @@ do { 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.speedBattle, 1, "cycling during battle is ignored") 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") + eq(writeOptions.calls, 0, "and persists nothing") +end +do + local calls = 0 + local g = gameWith({ overworld }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + function g:writeOptions() calls = calls + 1 end + g.linkSession = true + g:_cycleSpeed(1) + eq(g.save.options.speedOverworld, 1, "cycling during link play is ignored") + eq(calls, 0, "...and persists nothing") end do local g = gameWith({ overworld }, diff --git a/tests/engine/gen2_logic_speed_bug1994.lua b/tests/engine/gen2_logic_speed_bug1994.lua index d2f0305e..b434d876 100644 --- a/tests/engine/gen2_logic_speed_bug1994.lua +++ b/tests/engine/gen2_logic_speed_bug1994.lua @@ -25,6 +25,38 @@ eq(game({ options = { speed = -5 } }):logicSpeed(), 1, eq(game({ options = { speed = "fast" } }):logicSpeed(), 1, "an unparseable option falls back rather than erroring") +local function stack(...) return { states = { ... } } end +local battle = { isBattle = true } +local world = {} + +eq(game({ options = { speed = 10 }, stack = stack(world) }):logicSpeed(), 10, + "the overworld under a plain stack honors GAME SPEED") +eq(game({ options = { speed = 10 }, stack = stack(world, battle) }):logicSpeed(), 1, + "a battle on the stack is locked to 1X") +eq(game({ options = { speed = 10 }, speedOverride = 20, + stack = stack(world, battle, {}) }):logicSpeed(), 1, + "even with speedOverride and a menu over the battle") +eq(game({ options = { speed = 10 }, linkNet = { closed = false } }):logicSpeed(), 1, + "an open linkNet (online arena, LinkBattle2) is locked to 1X") +eq(game({ options = { speed = 10 }, linkNet = { closed = true } }):logicSpeed(), 10, + "a closed linkNet is not") +eq(game({ options = { speed = 10 }, linkSession = true }):logicSpeed(), 1, + "a link session (tournament, spectate) is locked to 1X") +eq(require("src.ui.gen2.BattleState").isBattle, true, + "the Gen 2 battle screen marks itself as a battle") + +do + local persisted = 0 + local g = game({ options = { speed = 1 }, stack = stack(world, battle), + persistOptions = function() persisted = persisted + 1 end }) + g:_cycleSpeed(1) + eq(g.options.speed, 1, "SPEED + in battle is ignored") + eq(persisted, 0, "and persists nothing") + g.stack = stack(world) + g:_cycleSpeed(1) + eq(g.options.speed, 2, "SPEED + on the overworld still cycles") +end + local Gen2Compat = require("src.mods.Gen2Compat") local live = game({ options = { speed = 4 } }) Gen2Compat.bind(function() return live end) diff --git a/tests/engine/options_submenu_marquee_test.lua b/tests/engine/options_submenu_marquee_test.lua index ce4b580e..4b119db2 100644 --- a/tests/engine/options_submenu_marquee_test.lua +++ b/tests/engine/options_submenu_marquee_test.lua @@ -45,7 +45,7 @@ local menu = OptionsMenu.new(game) T.check(find(menu.rows, "battleBg"), "self.rows keeps the flat list for mods") T.check(find(menu.rows, "musicVol"), "self.rows keeps the audio rows too") -for _, id in ipairs({ "battleBg", "speedBattle", "uiLayout", "tilt", "zoom", +for _, id in ipairs({ "battleBg", "speedMenu", "uiLayout", "tilt", "zoom", "voidFill", "colors", "shaderfx", "shaderfx2", "musicVol", "videoMode" }) do T.check(not find(menu.view, id), id .. " moved onto a page") @@ -53,7 +53,7 @@ end for _, spec in ipairs({ { "group.battle", 6 }, { "group.audio", 3 }, { "group.video", 5 }, - { "group.speed", 4 }, { "group.graphics", 4 }, { "group.extras", 3 }, + { "group.speed", 3 }, { "group.graphics", 4 }, { "group.extras", 3 }, }) do local row = find(menu.view, spec[1]) T.check(row, spec[1] .. " has an opener row") @@ -100,7 +100,7 @@ game.stack:pop() local speed = find(menu.view, "group.speed") speed.activate(game) T.eq(ids(game.stack:top().view), - "textSpeed,speedOverworld,speedBattle,speedMenu", + "textSpeed,speedOverworld,speedMenu", "TEXT SPEED heads the SPEED page") game.stack:pop() diff --git a/tests/engine/speed_lock_test.lua b/tests/engine/speed_lock_test.lua new file mode 100644 index 00000000..7903b8b2 --- /dev/null +++ b/tests/engine/speed_lock_test.lua @@ -0,0 +1,188 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local FixedStep = require("src.core.FixedStep") +local GameSpeed = require("src.core.GameSpeed") +local Hooks = require("src.mods.Hooks") +local Runtime = require("src.mods.Runtime") +local Game = require("src.core.Game") +local Game2 = require("src.core.Game2") +local Game3 = require("src.core.Game3") + +local function stack(...) return { states = { ... } } end +local unpack = table.unpack or unpack + +local battle = { isBattle = true } +local overworld = { isOverworld = true } + +local function gen1(states, opts) + return setmetatable({ + save = { options = opts or { speedOverworld = 200, speedBattle = 200, speedMenu = 200 } }, + stack = stack(unpack(states)), + writeOptions = function() end, + }, { __index = Game }) +end + +do + local g = gen1({ overworld }) + eq(g:logicSpeed(), 200, "gen1 overworld runs the saved 200X") + check(not g:speedLocked(), "gen1 overworld is not locked") + g = gen1({ overworld, battle }) + eq(g:logicSpeed(), 1, "gen1 battle is 1X at speedBattle 200") + g.speedOverride = 200 + eq(g:logicSpeed(), 1, "gen1 battle ignores speedOverride (--speed, skin FF hold)") + local bus = Hooks.new() + local saved = Runtime.hooks + Runtime.hooks = bus + local ran = false + local unsub = bus:wrap("core.logic_speed", function() ran = true; return 200 end) + g.speedOverride = nil + eq(g:logicSpeed(), 1, "gen1 battle ignores a core.logic_speed hook returning 200") + check(not ran, "the hook is never called while locked") + local ow = gen1({ overworld }) + eq(ow:logicSpeed(), 200, "outside battle the hook still runs") + check(ran, "...and was called") + unsub() + Runtime.hooks = saved +end + +do + GameSpeed.setAllowed({ 2, 4 }) + local g = gen1({ overworld, battle }) + eq(g:logicSpeed(), 1, "a cart ladder without 1 still locks to a literal 1") + eq(gen1({ overworld }, { speedOverworld = 4 }):logicSpeed(), 4, + "and outside battle the ladder applies") + GameSpeed.setAllowed(nil) +end + +do + local g = gen1({ overworld, battle }, + { speedOverworld = 1, speedBattle = 1, speedMenu = 1 }) + g:_cycleSpeed(1) + g:_cycleSpeed(-1) + g:_cycleSpeed(1) + eq(g.save.options.speedBattle, 1, "gen1 speed presses in battle leave speedBattle") + eq(g.save.options.speedOverworld, 1, "and speedOverworld") + g:touchSkinHotkey("fast_forward_toggle", true) + eq(g.save.options.speedBattle, 1, "gen1 skin FF toggle in battle is ignored") + g:touchSkinHotkey("fast_forward_hold", true) + eq(g:logicSpeed(), 1, "gen1 skin FF hold in battle stays 1X") + g:touchSkinHotkey("fast_forward_hold", false) + eq(g.speedOverride, nil, "and releasing restores the pre-hold override") +end + +do + local g = gen1({ overworld }) + g.linkSession = true + eq(g:logicSpeed(), 1, "gen1 link session is 1X") + g.linkSession = nil + g.linkNet = { closed = false } + eq(g:logicSpeed(), 1, "gen1 open linkNet (arena, tournament, spectate) is 1X") +end + +local function gen2(fields) + fields.persistOptions = fields.persistOptions or function() end + return setmetatable(fields, { __index = Game2 }) +end + +do + eq(gen2({ options = { speed = 200 }, stack = stack({}) }):logicSpeed(), 200, + "gen2 overworld runs GAME SPEED") + eq(gen2({ options = { speed = 200 }, stack = stack({}, battle) }):logicSpeed(), 1, + "gen2 battle is 1X") + eq(gen2({ options = { speed = 200 }, speedOverride = 200, + stack = stack({}, battle) }):logicSpeed(), 1, + "gen2 battle ignores speedOverride") + eq(gen2({ options = { speed = 200 }, linkNet = { closed = false } }):logicSpeed(), 1, + "gen2 online arena / LinkBattle2 linkNet is 1X") + eq(gen2({ options = { speed = 200 }, linkSession = true }):logicSpeed(), 1, + "gen2 tournament / spectate linkSession is 1X") + local g = gen2({ options = { speed = 1 }, stack = stack({}, battle) }) + g:_cycleSpeed(1) + eq(g.options.speed, 1, "gen2 pad speed press in battle is ignored") + g.stack = stack({}) + g.linkNet = { closed = false } + g:_cycleSpeed(1) + eq(g.options.speed, 1, "gen2 pad speed press over a link is ignored") +end + +local battleActive = false +package.loaded["src.core.game3.battle"] = { + isActive = function() return battleActive end, +} + +local function gen3() + return setmetatable({ + phase = "field", + options = { speedOverworld = 200, speedBattle = 200, speedMenu = 200 }, + writeOptions = function() end, + }, { __index = Game3 }) +end + +do + local g = gen3() + eq(g:logicSpeed(), 200, "gen3 field runs OVERWORLD SPEED") + battleActive = true + eq(g:logicSpeed(), 1, "gen3 battle is 1X") + g.speedOverride = 200 + eq(g:logicSpeed(), 1, "gen3 battle ignores speedOverride") + g.speedOverride = nil + g:_cycleSpeed(1) + eq(g.options.speedBattle, 200, "gen3 speed press in battle is ignored") + battleActive = false + package.loaded["src.core.game3.link.union_room"] = { isActive = function() return true end } + eq(g:logicSpeed(), 1, "gen3 Union Room is 1X") + package.loaded["src.core.game3.link.union_room"] = nil + package.loaded["src.core.game3.map"] = { current = "FR_UNION_ROOM" } + eq(g:logicSpeed(), 1, "gen3 Union Room map is 1X") + package.loaded["src.core.game3.map"] = nil + eq(g:logicSpeed(), 200, "and the field is back to 200X") +end + +local function runFrame(g, lockAt) + local steps = 0 + local savedInit = { accum = FixedStep.accum, callback = FixedStep.callback } + FixedStep.clock = function() return 0 end + FixedStep:init(function() + steps = steps + 1 + if steps == lockAt then battleActive = true; g.stack = stack(overworld, battle) end + g:_speedLockEdge() + end) + local speed = g:logicSpeed() + g._frameSpeed = speed + FixedStep.maxAccum = FixedStep.catchupLimit(speed, FixedStep.STEP) + FixedStep:update(FixedStep.STEP, speed) + FixedStep.clock = nil + FixedStep.accum, FixedStep.callback = savedInit.accum, savedInit.callback + return steps, speed +end + +do + battleActive = false + local g = gen3() + local steps, speed = runFrame(g, 3) + eq(speed, 200, "the frame started unlocked at 200X") + eq(steps, 3, "gen3: a battle starting mid-frame ends the frame on that step") + battleActive = false + + local g1 = gen1({ overworld }) + steps = runFrame(g1, 5) + eq(steps, 5, "gen1: a battle push mid-frame ends the frame on that step") + eq(g1:logicSpeed(), 1, "and the next frame samples 1X") + + local g2 = gen2({ options = { speed = 200 }, stack = stack({}) }) + steps = runFrame(g2, 2) + eq(steps, 2, "gen2: a battle push mid-frame ends the frame on that step") + battleActive = false + + local plain = gen1({ overworld }) + steps = runFrame(plain, -1) + check(steps > 100, "without a lock flip the 200X frame runs its steps") +end + +package.loaded["src.core.game3.battle"] = nil + +T.finish("speed lock") diff --git a/tests/g3link_fake_relay.lua b/tests/g3link_fake_relay.lua index 5be08448..dec6bd13 100644 --- a/tests/g3link_fake_relay.lua +++ b/tests/g3link_fake_relay.lua @@ -305,7 +305,7 @@ function FakeRelay.client(opts) rec("setStatus", status) C.status = status end - function C.joinPlaza(kind, profile, avatar) rec("joinPlaza", kind, profile, avatar) end + function C.joinPlaza(kind, profile, avatar, cap) rec("joinPlaza", kind, profile, avatar, cap) end function C.leavePlaza(kind) rec("leavePlaza", kind) end function C.plaza() return C._plaza end function C.plazaCounts() return C._counts end diff --git a/tests/game3_link_continue_warp_test.lua b/tests/game3_link_continue_warp_test.lua index fdfe93b6..7df3c56a 100644 --- a/tests/game3_link_continue_warp_test.lua +++ b/tests/game3_link_continue_warp_test.lua @@ -51,6 +51,26 @@ for _, room in ipairs({ "FR_BATTLE_COLOSSEUM_2P", "FR_BATTLE_COLOSSEUM_4P", "FR_ check(c.map == CENTER_2F and c.x == 9 and c.y == 1, room .. " save continues at the dynamic warp") end +for _, room in ipairs({ "FR_UNION_ROOM_PLAZA", "FR_UNION_ROOM" }) do + for _, case in ipairs({ + { heal = "FR_PEWTER_CITY_POKEMON_CENTER_1F", want = "FR_PEWTER_CITY_POKEMON_CENTER_2F" }, + { heal = "SEVII_ONE_ISLAND_POKECENTER", want = "SEVII_ONE_ISLAND_POKECENTER_2F" }, + { heal = "FR_PLAYERS_HOUSE_1F", want = CENTER_2F }, + }) do + local s = linkSession(room) + s.dynamicWarp = nil + s.healMap = case.heal + local saved = Schema.toSaveTable(s) + check((tonumber(saved.specialSaveWarpFlags) or 0) == 0, room .. " save with no dynamic warp sets no continue warp") + local c = Schema.fromSaveTable(saved) + check(c.map == case.want and c.x == 5 and c.y == 1 and c.facing == "down", + room .. " save with no dynamic warp (heal " .. case.heal .. ") continues at the Union Room door (got " + .. tostring(c.map) .. " " .. tostring(c.x) .. "," .. tostring(c.y) .. ")") + end + local c = Schema.fromSaveTable(Schema.toSaveTable(linkSession(room))) + check(c.map == CENTER_2F and c.x == 9 and c.y == 1, room .. " save with a dynamic warp still uses it") +end + local outside = linkSession(CENTER_2F) outside.x, outside.y = 7, 4 local o = Schema.fromSaveTable(Schema.toSaveTable(outside)) diff --git a/tests/game3_link_relay_union_test.lua b/tests/game3_link_relay_union_test.lua index 0ac39354..bb7009f3 100644 --- a/tests/game3_link_relay_union_test.lua +++ b/tests/game3_link_relay_union_test.lua @@ -18,17 +18,18 @@ end local FakeRelay = require("tests.g3link_fake_relay") -local UNION_MAP = "FR_UNION_ROOM" +local Plaza = require("src.core.game3.link.union_plaza_map") +local UNION_MAP = Plaza.MAP_ID local CENTER_MAP = "FR_TEST_POKEMON_CENTER_2F" local MAPS = { - [UNION_MAP] = { warps = { { x = 7, y = 11, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, + [UNION_MAP] = { warps = { { x = 12, y = 24, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, [CENTER_MAP] = { warps = { { x = 9, y = 1, destMap = "FR_TRADE_CENTER", destWarp = 1 } } }, FR_TRADE_CENTER = { warps = { { x = 5, y = 8, mapGroup = 0x7F, mapNum = 0x7F, destWarp = 128 } } }, } local store = { flags = {}, vars = {} } local session = { - store = store, map = UNION_MAP, x = 7, y = 11, name = "RED", gender = 0, trainerId = 0x1234, + store = store, map = UNION_MAP, x = 12, y = 24, name = "RED", gender = 0, trainerId = 0x1234, party = { { species = 1, level = 12 }, { species = 4, level = 9 } }, bag = { pockets = { items = {} } }, } @@ -42,7 +43,7 @@ package.loaded["src.core.game3.runtime"] = { isActive = function() return true end, _game = game, } -local Player = { cellX = 7, cellY = 11, facing = "down" } +local Player = { cellX = 12, cellY = 23, facing = "down" } package.loaded["src.core.game3.player"] = Player local mapLoads = {} package.loaded["src.core.game3.map"] = { @@ -181,64 +182,160 @@ local join = Client.last("joinPlaza") eq(join and join[1], "union", "joinPlaza union") eq(join and join[2] and join[2].rulesetId, "g3_link", "with the live g3_link profile") eq(join and join[3] and join[3].name, "RED", "and the avatar") +eq(join and join[4], 40, "announcing the 40-player plaza cap") +eq(Union.capacity(), Plaza.CAP, "the relay room holds 40 players") eq(Client.status, "idle", "presence status goes idle in the Union Room") +check(Union.isUnionMap(UNION_MAP) and Union.isUnionMap("FR_UNION_ROOM"), "both rooms are Union Room maps") +check(not Union.isUnionMap(CENTER_MAP), "the Pokemon Center is not") -print("[test] 2. plaza members fill the leader slots in slot order, self excluded") -Client._plaza = { kind = "union", instance = 1, you = 2, members = { +print("[test] 2. every other trainer stands on the cell of their relay slot, self excluded") +Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 1, you = 2, members = { + member("aaaa0002", 1, "GREEN", 3, 1), member("0000beef", 2, "RED", 0x1234, 0), member("aaaa0001", 5, "BLUE", 0x2222, 0), - member("aaaa0002", 1, "GREEN", 3, 1), } } run(1) eq(Union.playerCount(), 2, "two other trainers") -eq(Union.players[1] and Union.players[1].name, "GREEN", "the lowest relay slot takes cart slot 1") -eq(Union.players[2] and Union.players[2].name, "BLUE", "the next takes cart slot 2") +eq(Union.players[1] and Union.players[1].name, "GREEN", "relay slot 1 is GREEN") +eq(Union.players[5] and Union.players[5].name, "BLUE", "relay slot 5 keeps its number (slot = cell)") +eq(Union.players[2], nil, "our own slot is never drawn") +eq(Union.vobj(2), nil, "and has no avatar") eq(Union.players[1] and Union.players[1].id, "aaaa0002", "the slot remembers the relay id") -eq(objectCalls.added[1], Union.LOCAL_IDS[1], "slot 1's avatar spawned on the scheduled refresh") -eq(getVar(Union.VAR_OBJ_GFX_ID_0), Union.graphicsIdFor(1, 3), "with the class graphics id") -local green = liveObjects[Union.LOCAL_IDS[1]] -eq(green and green.raiseY, -Union.FLY_HEIGHT, "UNION_ROOM_SPAWN_IN starts 160 px up (FLY_DOWN)") -run(10) -eq(green.raiseY, -Union.FLY_HEIGHT + 10 * Union.FLY_STEP, "and drops 8 px a frame") -run(10) -eq(green.raiseY, 0, "landing after 20 frames") -eq(Union.leaderObj(1).state, 1, "the leader is shown") -eq(Union.avatarData().leader_coords[1][1], 4, "leader slot 1 sits at sUnionRoomPlayerCoords[0]") +eq(#objectCalls.added, 0, "no cart map object is used") +local g1 = Union.vobj(1) +local cx1, cy1, cf1 = Plaza.cellFor(1) +check(g1 and g1.visible, "GREEN is a virtual object") +eq(g1 and g1.x, cx1, "on slot 1's cell x") +eq(g1 and g1.y, cy1, "on slot 1's cell y") +eq(g1 and g1.dir, Union.FACE_DIR[cf1], "facing the cell's default") +eq(Union.vobjId(5), Union.VOBJ_BASE + 5, "virtual object ids are VOBJ_BASE + slot") +local b5 = Union.vobj(5) +local cx5, cy5 = Plaza.cellFor(5) +eq(b5 and b5.x, cx5, "BLUE on slot 5's cell x") +eq(b5 and b5.y, cy5, "BLUE on slot 5's cell y") +local rec1 = VirtualObjects.get(Union.vobjId(1)) +eq(rec1 and rec1.graphicsId, Union.graphicsIdFor(1, 3), "with the class graphics id") +check(rec1 and rec1.y2 < 0, "flying in") +run(20) +eq(VirtualObjects.get(Union.vobjId(1)).y2, 0, "and landed") +eq(VirtualObjects.count(), 2, "two avatars in the virtual object pool") -print("[test] 3. refreshes are throttled like the cart, departures fly out") +print("[test] 3. the plaza rebuilds only on a new (instance, rev)") +local builds = Union.plazaBuilds +run(30) +eq(Union.plazaBuilds, builds, "no rebuild while the rev stands still") +Client._plaza.members[3].status = "battling" +run(1) +eq(Union.players[5].activity, Union.ACTIVITY.NONE + Union.IN_UNION_ROOM, "an unannounced change is not applied") +Client._plaza.rev = 2 +run(1) +eq(Union.plazaBuilds, builds + 1, "a new rev rebuilds once") +eq(Union.players[5].activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, "BLUE is battling") +eq(b5.x, cx5, "a busy player stays on its cell") +eq(b5.dir, Union.DIR.EAST, "facing its activity") +Client._plaza.members[3].status = "idle" +Client._plaza.instance, Client._plaza.rev = 2, 2 +run(1) +eq(Union.plazaBuilds, builds + 2, "a new instance with the same rev rebuilds too") +eq(b5.dir, Union.FACE_DIR[select(3, Plaza.cellFor(5))], "BLUE is idle again") +local blueRow = table.remove(Client._plaza.members, 3) +Client._plaza.rev = 3 +run(1) +eq(Union.players[5], nil, "a leaver is gone on the delta that drops it") +eq(b5.anim, "out", "and flies out") +run(Union.FLY_HEIGHT / Union.FLY_STEP) +eq(VirtualObjects.get(Union.vobjId(5)), nil, "then leaves the pool") +eq(VirtualObjects.count(), 1, "GREEN stays") +table.insert(Client._plaza.members, blueRow) +Client._plaza.rev = 4 +run(1) +check(Union.vobjVisible(5), "a rejoin on the same slot comes back to the same cell") +run(20) + +print("[test] 3a. a short relay blip keeps the room, a long drop empties it") local realClock = Union.clock local fakeNow = 1000 Union.clock = function() return fakeNow end -local blueRow = table.remove(Client._plaza.members, 2) +Client._state = "reconnecting" run(1) -check(Union.players[2] and not Union.players[2].gone, "a member missing for a moment is not gone yet") -table.insert(Client._plaza.members, 2, blueRow) +fakeNow = fakeNow + Union.GONE_GRACE_SECONDS / 2 +run(1) +eq(Union.playerCount(), 2, "a blip keeps everyone") +Client._state = "online" +run(1) +eq(Union.playerCount(), 2, "and the resumed plaza (same rev) is applied again") +Client._state = "reconnecting" run(1) fakeNow = fakeNow + Union.GONE_GRACE_SECONDS + 1 run(1) -check(Union.players[2] and not Union.players[2].gone, "and a blip that comes back never flies out") -table.remove(Client._plaza.members, 2) -run(1) -fakeNow = fakeNow + Union.GONE_GRACE_SECONDS +eq(Union.playerCount(), 0, "a drop past the grace empties the room") +Client._state = "online" run(1) +eq(Union.playerCount(), 2, "the same rev after the drop still rebuilds") Union.clock = realClock -eq(Union.playerCount(), 2, "not refreshed on the next frame") -check(Union.players[2] and Union.players[2].gone, "BLUE is marked gone once the grace runs out") -local frames = 0 -while Union.playerCount() == 2 and frames < Union.REFRESH_FRAMES + 5 do - run(1) - frames = frames + 1 -end -eq(Union.playerCount(), 1, "BLUE left on the periodic refresh") -check(frames > 200, "which waits for the 300-frame refresh (" .. frames .. ")") -eq(Union.players[1] and Union.players[1].name, "GREEN", "GREEN keeps slot 1 (sticky)") -local blue = liveObjects[Union.LOCAL_IDS[2]] -check(blue ~= nil and blue.raiseY < 0, "UNION_ROOM_SPAWN_OUT flies the avatar up") -run(Union.FLY_HEIGHT / Union.FLY_STEP) -eq(objectCalls.removed[#objectCalls.removed], Union.LOCAL_IDS[2], "then removes the object") -eq(liveObjects[Union.LOCAL_IDS[2]], nil, "slot 2's avatar is gone") +run(20) -print("[test] 3b. a busy pair is one group: leader and member at the cart offsets, facing each other") +print("[test] 3b. a full 40-player plaza: 39 avatars, no per-frame allocation") +local full = {} +for slot = 1, Plaza.CAP do + if slot == 2 then + full[#full + 1] = member("0000beef", 2, "RED", 0x1234, 0) + else + full[#full + 1] = member(string.format("bbbb%04x", slot), slot, "P" .. slot, slot * 7, slot % 2) + end +end +full[1] = member("aaaa0002", 1, "GREEN", 3, 1) +Client._plaza = { kind = "union", instance = 3, cap = 40, rev = 10, you = 2, members = full } +run(1) +eq(Union.playerCount(), Plaza.CAP - 1, "39 other trainers") +run(25) +eq(VirtualObjects.count(), Plaza.CAP - 1, "39 avatars in the pool") +local cellsOk, seen = true, {} +for slot = 1, Plaza.CAP do + if slot ~= 2 then + local v = Union.vobj(slot) + local x, y = Plaza.cellFor(slot) + if not (v and v.visible and v.x == x and v.y == y) or seen[y * 64 + x] then cellsOk = false end + seen[y * 64 + x] = true + end +end +check(cellsOk, "every avatar on its own slot cell") +builds = Union.plazaBuilds +local okJit, jit = pcall(require, "jit") +if okJit and jit and jit.off then jit.off() jit.flush() end +for _ = 1, 10 do Union.relayTick(1 / 60) end +collectgarbage("collect") +collectgarbage("stop") +local kb = collectgarbage("count") +for _ = 1, 600 do Union.relayTick(1 / 60) end +local grown = (collectgarbage("count") - kb) * 1024 +collectgarbage("restart") +if okJit and jit and jit.on then jit.on() end +eq(Union.plazaBuilds, builds, "600 frames on one rev never rebuild") +check(grown == 0, string.format("and allocate nothing (%d bytes)", grown)) +Client._plaza.members[40].status = "chatting" +Client._plaza.rev = 11 +run(1) +eq(Union.plazaBuilds, builds + 1, "one status change is one rebuild") +eq(Union.vobj(40).dir, Union.DIR.SOUTH, "a chatter faces south on its cell") +local x40, y40 = Plaza.cellFor(40) +Player.cellX, Player.cellY, Player.facing = x40 - 1, y40, "right" +input.pressed = { a = true } +run(1) +input.pressed = {} +eq(Union.state, "recv_join_chat_request", "slot 40's chatter is talkable from the west") +check(settle(), "the join prompt asks YES/NO") +answer(false) +drain() +eq(Union.state, "main", "back to the main loop") +Client._plaza = { kind = "union", instance = 1, cap = 40, rev = 20, you = 2, members = { + member("aaaa0002", 1, "GREEN", 3, 1), + member("0000beef", 2, "RED", 0x1234, 0), +} } +run(25) +eq(Union.playerCount(), 1, "back to GREEN alone") + +print("[test] 3c. anyone chatting can be joined, from any side; idle trainers from any side") local pink = member("aaaa0003", 3, "PINK", 5, 1) pink.status = "chatting" pink.group = { leader = "aaaa0003", members = { "aaaa0003", "aaaa0004" }, activity = "chat" } @@ -247,41 +344,30 @@ yellow.status = "chatting" yellow.group = pink.group table.insert(Client._plaza.members, pink) table.insert(Client._plaza.members, yellow) -run(1) -eq(Union.playerCount(), 2, "the member rides with its leader instead of taking a slot") -eq(Union.players[2] and Union.players[2].name, "PINK", "the leader takes the free slot") -eq(Union.players[2] and #Union.players[2].partners, 1, "with YELLOW as its group member") -eq(Union.players[2].activity, Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM, "the group is chatting") -local vl = Union.vobj(2, 0) -local vm = Union.vobj(2, 1) -check(vl and vl.visible, "the leader is a virtual object") -check(vm and vm.visible, "and so is the member") -eq(vl and vl.x, 13, "leader at sUnionRoomPlayerCoords[1].x") -eq(vl and vl.y, 8, "leader at sUnionRoomPlayerCoords[1].y") -eq(vm and vm.x, 14, "member at offset (1,0)") -eq(vl and vl.dir, Union.DIR.SOUTH, "a chat leader faces south") -eq(vm and vm.dir, Union.DIR.WEST, "the member faces the leader") -eq(VirtualObjects.count(), 2, "both are in the virtual object pool") -check((VirtualObjects.get(Union.vobjId(2, 0)) or {}).y2 < 0, "flying in") -run(20) -eq((VirtualObjects.get(Union.vobjId(2, 0)) or {}).y2, 0, "and landed") -eq(liveObjects[Union.LOCAL_IDS[2]], nil, "no object event for a busy leader") - -print("[test] 3c. talking to a chatting group offers to join; a member gives a reaction") +Client._plaza.rev = 21 +run(21) +eq(Union.playerCount(), 3, "no grouping: the chat member has its own slot") +eq(Union.players[4] and Union.players[4].name, "YELLOW", "YELLOW on slot 4") +eq(Union.players[3].partners, nil, "no partner lists") +local vy = Union.vobj(4) +local x4, y4 = Plaza.cellFor(4) +eq(vy and vy.x, x4, "YELLOW stands on slot 4's cell") +eq(vy and vy.dir, Union.DIR.SOUTH, "facing its chat") Union.toMain() Message.reset() -Player.cellX, Player.cellY, Player.facing = 13, 9, "up" +Player.cellX, Player.cellY, Player.facing = x4, y4 + 1, "up" input.pressed = { a = true } run(1) input.pressed = {} -eq(Union.state, "recv_join_chat_request", "A in front of the chat leader") +eq(Union.state, "recv_join_chat_request", "A in front of a chat member who is not the host") +eq(vy.dir, Union.DIR.SOUTH, "YELLOW turns to face the player") check(settle(), "the join prompt asks YES/NO") if romBundle then - check(shows(RomText.ascii("sText_JoinChatFemale")), "gTexts_UR_JoinChat for a female leader") + check(shows(RomText.ascii("sText_JoinChatMale")), "gTexts_UR_JoinChat for a male chatter") end answer(true) local jinv = Client.last("invite") -eq(jinv and jinv[1], "aaaa0003", "joining asks the leader") +eq(jinv and jinv[1], "aaaa0004", "joining asks YELLOW directly") eq(jinv and jinv[2], "chat", "for the chat") eq(jinv and jinv[3] and jinv[3].join, true, "as a join") eq(Union.state, "send_activity_request", "waiting for the relay") @@ -290,32 +376,76 @@ jh.state, jh.why = "closed", "busy" run(1) eq(Union.state, "print_and_exit", "a refused join") if romBundle then - check(shows(RomText.ascii(RomText.key("gTexts_UR_ChatDeclined", 1))), "reads gTexts_UR_ChatDeclined") + check(shows(RomText.ascii(RomText.key("gTexts_UR_ChatDeclined", 0))), "reads gTexts_UR_ChatDeclined") end drain() eq(Union.state, "main", "back to the main loop") -Player.cellX, Player.cellY, Player.facing = 14, 9, "up" +eq(vy.dir, Union.DIR.SOUTH, "YELLOW faces its chat again") +local x3, y3 = Plaza.cellFor(3) +pink.group.members = { "aaaa0003", "aaaa0004", "c1", "c2", "c3" } +Client._plaza.rev = 22 +run(1) +Player.cellX, Player.cellY, Player.facing = x3 + 1, y3, "left" input.pressed = { a = true } run(1) input.pressed = {} -eq(Union.state, "print_and_exit", "A in front of a group member") -eq(vm.dir, Union.DIR.SOUTH, "the member turns to face the player") -run(2) -check(Message.isOpen(), "a reaction line") +eq(Union.state, "print_and_exit", "a full chat (5 seats) only gets a reaction") +eq(Union.vobj(3).dir, Union.DIR.EAST, "PINK turns east to the player") drain() -eq(vm.dir, Union.DIR.WEST, "and turns back to its leader") -Player.cellX, Player.cellY, Player.facing = 7, 11, "down" +local xg, yg = Plaza.cellFor(1) +for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }) do + Union.toMain() + Message.reset() + Player.cellX, Player.cellY, Player.facing = xg + side[1], yg + side[2], side[3] + input.pressed = { a = true } + run(1) + input.pressed = {} + eq(Union.state, "do_something_prompt", "idle GREEN is talkable facing " .. side[3]) + Union.toMain() + Union.flow = nil + Message.reset() +end +eq(Union.vobj(1).dir, Union.FACE_DIR[cf1], "and turns back to its cell facing") +Player.cellX, Player.cellY, Player.facing = 12, 23, "down" + +print("[test] 3d. a relay that refuses this build shows the update prompt once") +Union.toMain() +Message.reset() +Client._state = "error" +Client.upgradeRequired = function() return { type = "upgrade_required", text = "This build is too old for online play. Please update." } end +run(1) +eq(Union.state, "print_and_exit", "upgrade_required prints") +tickMsg() +check(Message.isOpen() and tostring(Message.currentPage()):find("too old", 1, true) ~= nil, "the relay's update text") +drain() +eq(Union.state, "main", "back to the main loop") +run(5) +eq(Union.state, "main", "and it is shown only once") +Client.upgradeRequired = nil +Client._state = "online" +run(25) +eq(Union.playerCount(), 3, "the room comes back when the relay does") +Union.toMain() +Message.reset() print("[test] 4. talking to a trainer: HiDoSomething, the invite menu, the cart's reject") run(1) local held = ctx.stateWait -eq(held, Union.scriptWaitTask, "the Union Room holds the next waitstate (UnionRoom_EventScript_Player#)") +eq(held, Union.scriptWaitTask, "the Union Room holds the next waitstate") ctx.stateWait = nil Flags.setVar(store, ctx, 0x800D, 1) eq(held(), false, "setvar VAR_RESULT, 1 then waitstate: the script waits") Flags.setVar(store, ctx, 0x800D, 0) run(1) +eq(Union.state, "main", "a VAR_RESULT that is not the attendant talks to no slot") +eq(Union.flow, nil, "and opens no flow") +eq(held(), true, "the waitstate is released") +Player.cellX, Player.cellY, Player.facing = xg, yg + 1, "up" +input.pressed = { a = true } +run(1) +input.pressed = {} eq(Union.state, "do_something_prompt", "talking to GREEN") +Player.cellX, Player.cellY, Player.facing = 12, 23, "down" check(settle(), "the activity menu opens") eq(Screen.mode, "activity", "GREETINGS / BATTLE / CHAT / EXIT") if romBundle then @@ -342,7 +472,6 @@ check(shows(RomText.ascii(RomText.key("gTexts_UR_BattleDeclined", 1))), "the partner's gendered battle refusal (GetURoomActivityRejectMsg)") drain() eq(Union.state, "main", "back to the main loop") -eq(held(), true, "and the talk script is released") Union.partnerId = 1 Union.chooseActivity(3) @@ -499,9 +628,10 @@ eq(board and board.wantType, 10, "wanting a FIRE type") drain() eq(Union.state, "main", "registration complete") -local greenMember = Client._plaza.members[2] +local greenMember = Client._plaza.members[1] eq(greenMember.name, "GREEN", "GREEN's plaza row") greenMember.board = { species = 4, level = 9, wantType = 12 } +Client._plaza.rev = Client._plaza.rev + 1 run(1) eq(Union.players[1].board and Union.players[1].board.species, 4, "GREEN's board offer is on the member row") local offers = Union.boardOffers() @@ -542,6 +672,39 @@ end eq(Union.state, "main", "back from the board") Player.cellX, Player.cellY, Player.facing = 7, 11, "down" +print("[test] 7b. the board lists every offer in the room and scrolls; EXIT never collides") +for slot = 5, 16 do + local m = member(string.format("cccc%04x", slot), slot, "T" .. slot, slot, 0) + m.board = { species = slot, level = slot, wantType = 12 } + table.insert(Client._plaza.members, m) +end +Client._plaza.rev = Client._plaza.rev + 1 +run(1) +eq(#Union.boardOffers(), 13, "13 offers from 40 possible slots") +Union.openTradingBoard() +eq(Screen.mode, "board", "the trading board") +local items = Screen.list.items +eq(#items, 15, "header, 13 offers, EXIT") +eq(items[#items].id, Screen.BOARD_EXIT, "EXIT has its own id") +eq(items[10].id, 8, "the ninth offer keeps id 8") +check(items[10].entry ~= nil and not items[10].disabled, "and is a real, pickable offer") +eq(Screen.list.maxShowed, 5, "five rows at a time") +Screen.select(10) +check(Screen.list.scroll > 0, "the list scrolls to reach it") +Screen.confirm() +check(Union.state ~= "main", "picking offer id 8 opens that offer, not EXIT (" .. tostring(Union.state) .. ")") +Union.toMain() +Message.reset() +Union.openTradingBoard() +Screen.select(#Screen.list.items) +Screen.confirm() +eq(Union.state, "main", "EXIT closes the board") +for i = #Client._plaza.members, 1, -1 do + if tostring(Client._plaza.members[i].id):sub(1, 4) == "cccc" then table.remove(Client._plaza.members, i) end +end +Client._plaza.rev = Client._plaza.rev + 1 +run(25) + Union.trade().playerSpecies, Union.trade().playerLevel = 1, 12 Flags.setVar(store, ctx, 0x800D, Union.INTERACT_ATTENDANT) run(1) diff --git a/tests/game3_link_union_test.lua b/tests/game3_link_union_test.lua index 93e7476c..a9fdec5c 100644 --- a/tests/game3_link_union_test.lua +++ b/tests/game3_link_union_test.lua @@ -111,16 +111,6 @@ local function getVar(id) return tonumber(Flags.getVar(store, ctx, id)) or 0 end -local function peerHello(name, trainerId, gender) - return { - type = Union.MSG.HELLO, - name = name, - trainerId = trainerId, - gender = gender or 0, - activity = Union.ACTIVITY.SEARCH + Union.IN_UNION_ROOM, - } -end - print("[test] 1. the union room specials answer to their pret index") local EXPECTED = { TryBecomeLinkLeader = 0x16B, @@ -153,7 +143,7 @@ end eq(#logs, 0, "nothing reached the unknown-special log") Link.reset() -print("[test] 3. RunUnionRoom starts the session and announces to the peer") +print("[test] 3. RunUnionRoom always opens the relay plaza, never a cable room") local host, guest = FakeRelay.pair({ game = game }) host:update(0) guest:update(0) @@ -164,74 +154,17 @@ Flags.setVar(store, ctx, Link.VAR_RESULT, 5) Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) eq(Union.state, "init", "RunUnionRoom armed the union room task") eq(getVar(Link.VAR_RESULT), 0, "VAR_RESULT is cleared on entry") +eq(Union.relay, true, "with a cable link up and no relay the room is still the plaza") +eq(Union.capacity(), 40, "holding 40 players") Union.update(0) eq(Union.state, "main", "the session reached the main loop") guest:update(0) -local heard = guest:take(Union.MSG.HELLO) -check(heard ~= nil, "the peer heard this player enter the union room") -eq(heard and heard.name, "RED", "with the player's name") - -print("[test] 4. the player list fills from the transport and spawns an avatar") -guest:send(peerHello("BLUE", 0x2222, 0)) +eq(guest:take("game3_union_hello"), nil, "nothing is announced over the cable") +guest:send({ type = "game3_union_hello", name = "BLUE", trainerId = 0x2222, gender = 0, activity = 0x4C }) host:update(0) Union.update(0) -eq(Union.playerCount(), 1, "one other player is in the room") -local listed = Union.list()[1] -eq(listed and listed.name, "BLUE", "the list names the peer") -eq(listed and listed.slot, 1, "in the first leader slot") -eq(objectCalls.added[1], Union.LOCAL_IDS[1], "the slot 1 union room object was spawned") -eq(Flags.getFlag(store, ctx, Union.FLAG_HIDE_PLAYER_1), false, - "FLAG_HIDE_UNION_ROOM_PLAYER_1 was cleared") -eq(getVar(Union.VAR_OBJ_GFX_ID_0), Union.graphicsIdFor(0, 0x2222), - "VAR_OBJ_GFX_ID_0 holds the trainer-class graphics id") - -print("[test] 5. BufferUnionRoomPlayerName hands the nurse the name once") -local _, first = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(first, 1, "the first call reports TRUE") -eq(ctx.stringVars[1], "BLUE", "and buffers the name into STR_VAR_1") -local _, second = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(second, 0, "the second call reports FALSE") - -print("[test] 6. talking to a player opens the activity chooser") -Flags.setVar(store, ctx, Link.VAR_RESULT, 1) -Union.update(0) -eq(Union.partnerId, 1, "the interaction picked the player in slot 1") -Union.update(0) -check(Screen.isOpen(), "the union room screen opened") -eq(Screen.mode, "activity", "in activity-chooser mode") -eq(#Screen.items, 4, "with the four pret entries") -teq(Screen.labelFor(Screen.items[1]), "GREETINGS", "GREETINGS first") -teq(Screen.labelFor(Screen.items[4]), "EXIT", "EXIT last") - -print("[test] 7. BATTLE has no level limit") -session.party = { { species = 1, level = 42 }, { species = 4, level = 60 } } -Screen.cursor = 2 -Screen.confirm() -eq(Union.activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, - "the battle request carries ACTIVITY_BATTLE_SINGLE | IN_UNION_ROOM") -eq(Union.state, "send_activity_request", "and the request went out") -guest:update(0) -local request = guest:take(Union.MSG.REQUEST) -check(request ~= nil, "the peer received the activity request") -eq(request and request.activity, Union.ACTIVITY.BATTLE_SINGLE + Union.IN_UNION_ROOM, - "with the same activity id") - -print("[test] 8. a partner that never answers frees the room again") -eq(Union.state, "send_activity_request", "the request is still outstanding") -Union.update(Union.RESPONSE_SECONDS + 0.1) -eq(Union.lastResult, "busy", "the partner is reported busy") -Union.update(0) -eq(Union.state, "main", "and the union room goes back to its main loop") -eq(Union.activity, nil, "with no pending activity") - -print("[test] 9. EXIT leaves the chooser without contacting anybody") -Union.state = "do_something_prompt" -Union.partnerId = 1 -Union.update(0) -Screen.cursor = 4 -Screen.confirm() -eq(Union.activity, nil, "EXIT clears the pending activity") -eq(Union.state, "main", "and returns to the union room main loop") +eq(Union.playerCount(), 0, "and a cable hello adds nobody") +eq(#objectCalls.added, 0, "no cart player object is spawned") print("[test] 10. stepping on the union room pad returns the dynamic warp, not a header map") local pad = MAPS[UNION_MAP].warps[1] @@ -243,29 +176,15 @@ eq(destX, 5, "at the counter door x") eq(destY, 3, "at the counter door y") print("[test] 11. leaving the union room map ends the session") -objectCalls.removed = {} Space.mapId = CENTER_MAP session.map = CENTER_MAP local alive = Union.update(0) eq(alive, false, "the union room task stopped") eq(Union.state, "off", "the session is closed") -eq(Union.playerCount(), 0, "the player list is empty") -eq(objectCalls.removed[1], Union.LOCAL_IDS[1], "the spawned avatar was removed") -guest:update(0) -check(guest:take(Union.MSG.BYE) ~= nil, "the peer was told this player left") -print("[test] 12. InitUnionRoom scans from the Pokemon Center without spawning avatars") -objectCalls.added = {} +print("[test] 12. InitUnionRoom has no cable to scan") Natives.special(ctx, NativesLink.SPECIAL.InitUnionRoom, adapters) -eq(Union.state, "search", "the Pokemon Center ON_RESUME scan is running") -guest:send(peerHello("GREEN", 0x3333, 1)) -host:update(0) -Union.update(0) -eq(Union.playerCount(), 1, "the scan found a union room player") -eq(#objectCalls.added, 0, "and spawned nothing on the Pokemon Center map") -local _, greeted = NativesLink.HANDLERS[NativesLink.SPECIAL.BufferUnionRoomPlayerName](ctx, adapters) -eq(greeted, 1, "the nurse can greet the player it found") -eq(ctx.stringVars[1], "GREEN", "by name") +eq(Union.state, "off", "the Pokemon Center ON_RESUME call starts nothing") print("[test] 13. the link group flows answer LINKUP through VAR_RESULT") Link.reset() @@ -275,11 +194,6 @@ local host2, guest2 = FakeRelay.pair({ game = game }) host2:update(0) guest2:update(0) Link.attach(host2) -Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) -Union.update(0) -guest2:send(peerHello("BLUE", 0x2222, 0)) -host2:update(0) -Union.update(0) Flags.setVar(store, ctx, Link.VAR_0x8004, Union.LINK_GROUP.SINGLE_BATTLE) local yielded = Natives.special(ctx, NativesLink.SPECIAL.TryBecomeLinkLeader, adapters) @@ -331,90 +245,6 @@ else end end -print("[test] an incoming activity request is answered, never parked") -Link.reset() -Union.reset() -local reqHost, reqGuest = FakeRelay.pair({ game = game }) -reqHost:update(0) -reqGuest:update(0) -Link.attach(reqHost) -Space.mapId = UNION_MAP -session.map = UNION_MAP -Natives.special(ctx, NativesLink.SPECIAL.RunUnionRoom, adapters) -Union.update(0) -reqGuest:update(0) -while reqGuest:take(Union.MSG.HELLO) do end -reqGuest:send({ type = Union.MSG.REQUEST, name = "BLUE", - activity = Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM }) -reqHost:update(0) -Union.update(1 / 60) -eq(Union.activity, Union.ACTIVITY.CHAT + Union.IN_UNION_ROOM, - "the request lands in UR_STATE_RECV_ACTIVITY_REQUEST with the activity asked for") -eq(Union._requestName, "BLUE", "and the name of the trainer who asked") --- pokefirered/src/union_room.c:4486 -local prompt = Union.requestPrompt() -if romBundle then - eq(prompt, require("src.core.game3.rom_text").ascii("gText_UR_ChatInvitation"), - "the prompt is the cart's chat invitation") -else - print("[skip] ROM text: the contact prompt reads gText_UR_ChatInvitation") -end -Union.update(1 / 60) -reqHost:update(0) -reqGuest:update(0) -local answer = reqGuest:take(Union.MSG.RESPONSE) -check(answer ~= nil, "the peer gets a RESPONSE instead of timing out on a parked room") -check(Union.state ~= "player_contacted_you", "and the room leaves player_contacted_you") - -print("[test] the chooser's CHAT and GREETINGS start real activities") -local Chat = require("src.core.game3.link.chat") -Union.state = "main" -Union.activity = nil -Union.chooseActivity(3) -eq(Union.state, "send_activity_request", "CHAT asks the partner first") -reqGuest:update(0) -reqGuest:take(Union.MSG.REQUEST) -reqGuest:send({ type = Union.MSG.RESPONSE, accept = true }) -reqHost:update(0) -Union.update(1 / 60) -eq(Union.state, "in_activity", "an accepted CHAT enters the chat activity") -check(Chat.isActive(), "and the chat session is live") -Chat.say("HI") -reqGuest:update(0) -local said = reqGuest:take(Chat.MSG.LINE) -eq(said and said.text, "HI", "a line the player writes goes out on the wire") -reqGuest:send({ type = Chat.MSG.LINE, name = "BLUE", text = "HELLO" }) -reqHost:update(0) -Chat.update(1 / 60) -eq(#Chat.lines, 2, "and the peer's line lands in the log") -check(Chat.lines[2] and Chat.lines[2].text:find("BLUE", 1, true) == 1 - and Chat.lines[2].text:sub(-6) == ":HELLO", "with the peer's name and words") -Chat.stop("left") -Union.update(1 / 60) -eq(Union.state, "main", "leaving the chat puts the room back in its main loop") - -Union.state = "main" -Union.activity = nil -Union.chooseActivity(1) -eq(Union.state, "send_activity_request", "GREETINGS asks the partner first") -reqGuest:update(0) -reqGuest:take(Union.MSG.REQUEST) -reqGuest:send({ type = Union.MSG.RESPONSE, accept = true }) -reqHost:update(0) -Union.update(1 / 60) --- pokefirered/src/union_room.c:1954 CB2_ShowCard -eq(Union.state, "in_activity", "an accepted GREETINGS shows a trainer card") -eq(Union.lastResult, "card_shown", "built from CreateTrainerCardInBuffer") -local cardOut = nil -reqGuest:update(0) -local seen = reqGuest:take(Link.MSG.CARD) -while seen do - cardOut = seen - seen = reqGuest:take(Link.MSG.CARD) -end -check(cardOut ~= nil, "and this machine's own card went out to the peer") -eq(cardOut and cardOut.card and cardOut.card.name, session.name, "carrying the player's name") - print("[test] the link group at the counter lists the machine on the cable") Link.reset() Union.reset() diff --git a/tests/game3_union_plaza_map_test.lua b/tests/game3_union_plaza_map_test.lua new file mode 100644 index 00000000..f086dc5a --- /dev/null +++ b/tests/game3_union_plaza_map_test.lua @@ -0,0 +1,199 @@ +#!/usr/bin/env luajit +package.path = "./?.lua;./?/init.lua;" .. package.path + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end +local function eq(a, b, msg) + check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b))) +end + +local Plaza = require("src.core.game3.link.union_plaza_map") + +print("[test] contract") +eq(Plaza.MAP_ID, "FR_UNION_ROOM_PLAZA", "map id") +eq(Plaza.CAP, 40, "cap") +eq(#Plaza.CELLS, 41, "41 cells on the grid") +eq(#Plaza.ROLES, 25, "role grid is 25 rows") +local badRow = nil +for y, row in ipairs(Plaza.ROLES) do + if #row ~= 25 then badRow = y end + for i = 1, #row do + if not Plaza.SOURCE[row:sub(i, i)] then badRow = y end + end +end +check(badRow == nil, "every role row is 25 wide with a known role: " .. tostring(badRow)) + +print("[test] slot table") +local seen, unique = {}, 0 +for slot = 1, Plaza.CAP do + local x, y, facing = Plaza.cellFor(slot) + check(x ~= nil and y ~= nil and facing ~= nil, "slot " .. slot .. " has a cell") + local k = tostring(x) .. "," .. tostring(y) + if not seen[k] then unique = unique + 1 end + seen[k] = true + eq(Plaza.slotAt(x, y), slot, "slotAt inverts slot " .. slot) +end +eq(unique, 40, "40 unique cells") +check(Plaza.cellFor(0) == nil and Plaza.cellFor(41) == nil, "slots outside 1..40 have no cell") +check(Plaza.slotAt(12, 24) == nil and Plaza.slotAt(0, 0) == nil, "slotAt is nil off the cell table") + +local ex, ey = Plaza.entry() +local function dist(slot) + local x, y = Plaza.cellFor(slot) + return math.abs(x - ex) + math.abs(y - (ey - 1)) +end +local sorted = true +for slot = 2, Plaza.CAP do + if dist(slot) < dist(slot - 1) then sorted = false end +end +check(sorted, "fill order moves outward from the entrance") +local x1, y1 = Plaza.cellFor(1) +eq(x1, 12, "slot 1 sits on the entrance column") +eq(y1, 17, "slot 1 is the first cell up from the door") +local sx, sy = Plaza.cellFor(40) +check(sy == 5, "slot 40 is on the far row") + +print("[test] geometry") +eq(ex, 12, "entry x") +eq(ey, 24, "entry y") +eq(Plaza.roleAt(ex, ey), "X", "entry is the exit mat") +local attX, attY = 3, 2 +local blocked = {} +local function key(x, y) return y * 64 + x end +for _, c in ipairs(Plaza.CELLS) do blocked[key(c.x, c.y)] = true end +blocked[key(attX, attY)] = true +local function walkable(x, y) + local r = Plaza.roleAt(x, y) + return r ~= nil and not Plaza.BLOCKED[r] and not blocked[key(x, y)] +end +local reach = { [key(ex, ey)] = true } +local queue = { { ex, ey } } +local head = 1 +while queue[head] do + local p = queue[head] + head = head + 1 + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + local nx, ny = p[1] + d[1], p[2] + d[2] + if walkable(nx, ny) and not reach[key(nx, ny)] then + reach[key(nx, ny)] = true + queue[#queue + 1] = { nx, ny } + end + end +end +local allSides, onBad = true, {} +for _, c in ipairs(Plaza.CELLS) do + for _, d in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if not reach[key(c.x + d[1], c.y + d[2])] then + allSides = false + print((" cell %d,%d side %d,%d unreachable"):format(c.x, c.y, d[1], d[2])) + end + end + local r = Plaza.roleAt(c.x, c.y) + if Plaza.BLOCKED[r] or c.y <= 3 or c.y >= 23 or (c.x == ex and c.y >= 19) then + onBad[#onBad + 1] = c.x .. "," .. c.y + end +end +check(allSides, "every cell is reachable from all 4 sides with the room full") +eq(#onBad, 0, "no cell on a wall, the counter rows or the exit lane") +check(reach[key(2, 2)] == true, "the trading board spot below the counter is reachable") +check(reach[key(3, 3)] == true, "the attendant is reachable from the front") + +print("[test] a cache that cannot build the plaza is a hard error") +eq(Plaza.shouldUse, nil, "no switch back to the cart room") +local function fails(game, want, msg) + local ok, err = pcall(Plaza.ensure, game) + check(not ok and tostring(err):find(want, 1, true) ~= nil, msg .. ": " .. tostring(err)) +end +local realMap = package.loaded["src.core.game3.map"] +package.loaded["src.core.game3.map"] = { ensureMidLayout = function() end } +fails({ data = {} }, "no map table", "no map table") +fails({ data = { maps = {} } }, "FR_UNION_ROOM is missing from the cache", "no FR_UNION_ROOM") +fails({ data = { maps = { FR_UNION_ROOM = { warps = {} } } } }, "FR_UNION_ROOM has no layout", + "FR_UNION_ROOM without a layout") +local tiny = { width = 4, height = 4, cellAt = function() return { mid = 1, coll = 0, elev = 0 } end } +fails({ data = { maps = { FR_UNION_ROOM = { warps = {}, midLayout = tiny } } } }, "layout has no cell", + "a truncated FR_UNION_ROOM layout") +local Adapters = require("src.core.game3.scripting.adapters") +local broken = Adapters.host(nil, { data = { maps = { FR_UNION_ROOM = { warps = {} } } } }, nil) +local realVersions = package.loaded["src.import.gba.versions"] +package.loaded["src.import.gba.versions"] = { + frMapFor = function() return "FR_UNION_ROOM" end, + seviiMapFor = function() return nil end, +} +local okWarp, warpErr = pcall(broken.warp, 1, 1, 0xFF, 7, 11, function() end, "warpspinenter") +package.loaded["src.import.gba.versions"] = realVersions +package.loaded["src.core.game3.map"] = realMap +check(not okWarp and tostring(warpErr):find("FR_UNION_ROOM has no layout", 1, true) ~= nil, + "the Union Room warp errors instead of landing in the cart room: " .. tostring(warpErr)) + +print("[test] built from the imported cache") +local Cache = require("tests.game3_cache") +local root = Cache.mount("meta.json", { native = true }) +if not root then + print("[skip] plaza cache build: " .. tostring(Cache.reason)) +else + local Dataset = require("src.core.game3.dataset") + local maps = Dataset.buildMaps() + local src = maps.FR_UNION_ROOM + check(src ~= nil, "FR_UNION_ROOM is in the cache") + Dataset.attachMidLayouts({ FR_UNION_ROOM = src }, Cache.cache()) + local game = { data = { maps = maps } } + local def = Plaza.ensure(game) + check(def ~= nil and maps[Plaza.MAP_ID] == def, "ensure registers the plaza def") + eq(Plaza.ensure(game), def, "ensure is idempotent") + if def and src and src.midLayout then + local L = def.midLayout + eq(L.width, 25, "layout width") + eq(L.height, 25, "layout height") + eq(def.width, 25, "def width") + eq(def.height, 25, "def height") + eq(def.pair, src.pair, "same tileset pair as the cart room") + local srcMids = {} + local SL = src.midLayout + for y = 0, (SL.trueHeight or SL.height) - 1 do + for x = 0, (SL.trueWidth or SL.width) - 1 do + srcMids[SL:midAt(x, y)] = true + end + end + local foreign, collMismatch = 0, 0 + for y = 0, 24 do + for x = 0, 24 do + if not srcMids[L:midAt(x, y)] then foreign = foreign + 1 end + local c = L:collAt(x, y) + local r = Plaza.roleAt(x, y) + if (c ~= 0) ~= (Plaza.BLOCKED[r] == true or r == "X") then + collMismatch = collMismatch + 1 + end + end + end + eq(foreign, 0, "every metatile comes from the Union Room layout") + eq(collMismatch, 0, "blocked roles match the cart collision") + for slot = 1, Plaza.CAP do + local x, y = Plaza.cellFor(slot) + if L:collAt(x, y) ~= 0 then check(false, "slot " .. slot .. " cell is floor") end + end + eq(#def.warps, 1, "one warp") + local w, sw = def.warps[1], src.warps[1] + eq(w.x, 12, "warp x") + eq(w.y, 24, "warp y") + eq(w.mapGroup, sw.mapGroup, "warp group matches FR_UNION_ROOM") + eq(w.mapNum, sw.mapNum, "warp num matches FR_UNION_ROOM") + eq(w.destWarp, sw.destWarp, "warp dest matches FR_UNION_ROOM") + eq(w.destMap, sw.destMap, "warp destMap matches FR_UNION_ROOM") + eq(L:collAt(12, 24), SL:collAt(7, 11), "exit tile keeps the cart warp collision") + eq(def.music, src.music, "music copied") + end +end + +if failed > 0 then + print(("[FAIL] %d check(s) failed"):format(failed)) + os.exit(1) +end +print("[PASS] union plaza map") diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0d4d7f5a..37e84a8a 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -288,7 +288,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "performance", "colors", "tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill", "videoMode", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock", - "speedOverworld", "speedBattle", "speedMenu", + "speedOverworld", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } local function orow(menu, id) for _, row in ipairs(menu.rows) do diff --git a/tests/online_client_gen3.lua b/tests/online_client_gen3.lua index c73faf87..d1d8df90 100644 --- a/tests/online_client_gen3.lua +++ b/tests/online_client_gen3.lua @@ -367,7 +367,7 @@ end do local w = newWorld() - local A = w:add(0x30, "ANN") + local A, sA = w:add(0x30, "ANN") local B = w:add(0x31, "BEN") local Mon = w:add(0x32, "MON") w:pump() @@ -379,8 +379,17 @@ do check(p ~= nil and p.kind == "union", "plaza_state lands") eq(p and p.you, 1, "the first member takes slot 1") eq(p and #p.members, 1, "and is alone") + eq(p and p.cap, 40, "plaza_state carries the 40 cap") + local sentJoin = w.relay:sent(sA, "plaza_join")[1] + eq(sentJoin and sentJoin.cap, 40, "a union plaza_join announces cap 40") + local inst0, rev0 = A.plazaRev() + check(inst0 ~= nil and type(rev0) == "number", "plazaRev reports (instance, rev)") B.joinPlaza("union", PROFILE3, avatar("BEN", 201, 0)) w:pump() + local inst1, rev1 = A.plazaRev() + eq(inst1, inst0, "the joiner lands in the same instance") + check(rev1 > rev0, "a plaza_delta moves the rev forward") + eq(select(2, B.plazaRev()), rev1, "the joiner's plaza_state rev matches the delta rev") eq(#A.plaza().members, 2, "plaza_delta adds the joiner") eq(A.plaza().members[2].slot, 2, "members sort by slot") eq(A.plaza().members[2].avatar.trainerId, 201, "and carry their avatar") @@ -681,6 +690,8 @@ do "group_open", "group_list" } local seq = table.concat(order, ",") eq(seq, table.concat(want, ","), "a fresh welcome re-sends the session state in order") + local rejoin = w.relay:sent(sa, "plaza_join") + eq(rejoin[#rejoin] and rejoin[#rejoin].cap, 40, "the re-sent plaza_join keeps cap 40") local presence = w.relay:sent(sa, "presence") eq(presence[#presence].status, "busy", "the re-sent presence is the latest") local sp = w.relay:sent(sa, "set_profiles") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 652ecd31..a182bc9e 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2972,8 +2972,6 @@ do check(seek("vsync"), "cursor reaches VSYNC") press("a") eq(og.save.options.vsync, "off", "A cycles VSYNC to OFF") - -- RFC 0007: the single GAME SPEED row is now three independent rows, - -- one per GameSpeed.CATEGORIES entry. check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED") press("a") eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X") @@ -2982,11 +2980,7 @@ do -- 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.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL") - check(seek("speedBattle"), "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") + check(not seek("speedBattle"), "no BATTLE SPEED row: battles always run 1X") check(seek("speedMenu"), "cursor reaches MENU SPEED") press("a") eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X") diff --git a/tests/support/fake_relay.lua b/tests/support/fake_relay.lua index 21c9d139..218c929e 100644 --- a/tests/support/fake_relay.lua +++ b/tests/support/fake_relay.lua @@ -38,6 +38,7 @@ function FakeRelay.new(opts) invites = {}, tokens = {}, plazas = { union = {} }, + plazaRev = 0, wireless = {}, groups = {}, groupWatch = {}, @@ -369,9 +370,9 @@ end local function memberOf(s, slot) return { id = s.id, name = s.name, verified = true, slot = slot, - online = s.online, status = s.recruiting and "recruiting" or "idle", + online = s.online, status = s.status or (s.recruiting and "recruiting" or "idle"), avatar = copy(s.avatar), recruiting = copy(s.recruiting), - board = copy(s.presence.board) } + board = copy(s.presence.board), group = copy(s.group) } end function FakeRelay:plazaInstance(s) @@ -381,8 +382,12 @@ function FakeRelay:plazaInstance(s) return nil end +FakeRelay.PLAZA_CAP = 40 + function FakeRelay:plazaDelta(inst, index, fields, except) - local msg = { type = "plaza_delta", kind = "union", instance = index, + self.plazaRev = self.plazaRev + 1 + inst.rev = self.plazaRev + local msg = { type = "plaza_delta", kind = "union", instance = index, rev = inst.rev, joined = fields.joined or {}, left = fields.left or {}, changed = fields.changed or {} } for id in pairs(inst.slots) do @@ -397,8 +402,8 @@ function FakeRelay:plazaStateMsg(s) members[#members + 1] = memberOf(self.sessions[id], slot) end table.sort(members, function(a, b) return a.slot < b.slot end) - return { type = "plaza_state", kind = "union", instance = index, - you = inst.slots[s.id], members = members } + return { type = "plaza_state", kind = "union", instance = index, cap = FakeRelay.PLAZA_CAP, + rev = inst.rev, you = inst.slots[s.id], members = members } end function FakeRelay:plazaJoin(s, msg) @@ -409,6 +414,12 @@ function FakeRelay:plazaJoin(s, msg) if type(msg.profile) ~= "table" or msg.profile.engine ~= 3 then return self:joinError(s, "bad_profile") end + if not (math.floor(tonumber(msg.cap) or 0) >= FakeRelay.PLAZA_CAP) then + self:to(s, { type = "upgrade_required", protocol = self.minProtocol, minBuild = nil, + text = "This build is too old for online play. Please update." }) + s.transport.closed = true + return + end s.avatar = copy(msg.avatar) s.where = "union" if self:plazaInstance(s) then return self:to(s, self:plazaStateMsg(s)) end @@ -416,13 +427,13 @@ function FakeRelay:plazaJoin(s, msg) for i, inst in ipairs(self.plazas.union) do local n = 0 for _ in pairs(inst.slots) do n = n + 1 end - if n < 9 and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end + if n < FakeRelay.PLAZA_CAP and (not best or n > best.n) then best, bestIndex = { inst = inst, n = n }, i end end local inst, index if best then inst, index = best.inst, bestIndex else - inst = { slots = {} } + inst = { slots = {}, rev = 0 } self.plazas.union[#self.plazas.union + 1] = inst index = #self.plazas.union end @@ -431,8 +442,8 @@ function FakeRelay:plazaJoin(s, msg) local slot = 1 while used[slot] do slot = slot + 1 end inst.slots[s.id] = slot - self:to(s, self:plazaStateMsg(s)) self:plazaDelta(inst, index, { joined = { memberOf(s, slot) } }, s.id) + self:to(s, self:plazaStateMsg(s)) end function FakeRelay:plazaLeave(s, kind) diff --git a/tools/run_driver.sh b/tools/run_driver.sh index 086b21c2..6d5ab8f3 100755 --- a/tools/run_driver.sh +++ b/tools/run_driver.sh @@ -3,6 +3,7 @@ set -u ROOT="$(cd "$(dirname "$0")/.." && pwd)" SLOW_DRIVERS="game3_daycare_egg game3_daycare_menu game3_daycare_deposit game3_import2_fresh_cache game3_import2_town_map game3_trade_scene game3_u8c_yesno_frame game3_u9c_catch_ball_open" +REALTIME_DRIVERS="battle_fanfare_speed_bug1952_test" help_text() { cat <