From 63069753528430f0059ca380a8e86ef0839221a9 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 24 Jul 2026 10:10:51 -0400 Subject: [PATCH 1/3] new options, and hot keys and readme clean up --- README.md | 28 ++++- docs/new-features.md | 49 +++++++-- src/battle/BattleState.lua | 3 + src/core/Game.lua | 16 ++- src/core/SaveData.lua | 6 +- src/render/BattleTransition.lua | 5 + src/render/Renderer.lua | 62 ++++++++++- src/render/TileRenderer.lua | 179 ++++++++++++++++++++++++++------ src/render/Zoom.lua | 46 +++++++- src/ui/OptionsMenu.lua | 37 ++++++- tests/mod_graphics_tests.lua | 91 ++++++++++++++++ tests/mod_ui_tests.lua | 35 +++++-- tests/run_tests.lua | 75 ++++++++++--- 13 files changed, 557 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index ed4d9a97..e1a2a9e1 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,32 @@ audio channel programs copied out of the verified ROM. ## Controls -arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is -B; Escape opens START. F1 saves and F2 loads. Controllers are supported. +| Action | Keyboard | Controller | +|--------|----------|------------| +| Move | Arrow keys / WASD | D-pad / left stick | +| A | Z / Enter / Space | A | +| B | X / Backspace | B | +| Start | Escape | Start | +| Select | Tab / Shift | Back / Select | + +Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are +supported out of the box. + +### Hotkeys + +| Key | What it does | +|-----|----------------| +| `-` / `=` | Zoom out / in (overworld; also mouse wheel) | +| `2` | Cycle COLORS | +| `3` | Cycle TILT (free-roam overworld) | +| `4` | Cycle ZOOM through every level (free-roam overworld) | +| `5` | Cycle GBC FX | +| `F1` | Save | +| `F2` | Load | +| `F10` | Open / close the mod manager | + +COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu +and persist in `options.lua`. ## Running From Source diff --git a/docs/new-features.md b/docs/new-features.md index d4b747f7..3db7507a 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md. ## Survey zoom -The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world -pixel (full survey) and 2× the window fit scale (close-up), in crisp -integer steps. This has no Game Boy equivalent: +The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` +zooms the overworld between 1 pixel per world pixel (full survey) and 2× +the window fit scale (close-up), in crisp integer steps. This has no Game +Boy equivalent: - Connected maps render their full bodies, and their NPCs appear as visual-only "ghosts", they wander but have no sight lines, triggers, dialogue, or collision until the map is actually entered. - Menus, text boxes, and battles draw at normal scale on top of the zoomed world. Zoom input is ignored while a script, menu, or battle is - active; the zoom level persists across warps and is never saved. -- Beyond the border ring the border block repeats indefinitely (interiors - stay black, seaside towns stay water, except OVERWORLD-tileset maps, - whose beyond-edge space fills with the solid tree wall instead of the - per-map border block), and each visible map area is colorized with its - own SGB palette (the original recolored the whole screen per map). + active; the zoom offset is persisted as `save.options.zoom` (default + `0` = FIT) and survives New Game via `options.lua`. +- Hotkey `4` ticks through every integer zoom level (survey → FIT → + close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`. +- Beyond the border ring the void fill repeats indefinitely (see VOID + FILL below); interiors keep their own border block. Each visible map + area is colorized with its own SGB palette (the original recolored the + whole screen per map). - Neighbor maps load two connection hops out so corner-adjacent maps don't pop in and out, and ghost NPCs share instances with the real ones so their wander positions persist across seamless connection crossings (a warp or fresh map entry still respawns everything at its script position, like the original's per-entry sprite init). +## VOID FILL + +The Options **VOID FILL** row picks what paints the infinite beyond-edge +space on OVERWORLD-tileset maps during survey zoom: + +- **TREES** (default): solid tree wall block `$0F`. +- **WATER**: animated water tile `$14` (same hshift cycle as on-map water). +- **BLACK**: solid black. + +Other tilesets are unchanged (house/cave borders stay as authored). +Persisted as `save.options.voidFill`. + ## Tilt mode The `3` key (and the Options menu TILT row) cycles a visual-only perspective @@ -139,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original. Options persist in a standalone `options.lua` (separate from the game progress `save.lua`), so audio/display/battle preferences survive New Game and aren't wiped when a save slot is cleared. Changing a row in the Options -menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also +menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also flushes the live options. Old saves that still embed an `options` table are migrated once into `options.lua` on load. @@ -150,6 +165,18 @@ migrated once into `options.lua` on load. hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc SuperPalettes + per-species mon colors) - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming +- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and + `-`/`=` step one level and save +- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` - MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - a hard render frame-rate cap (`save.options.fpsCap`). \ No newline at end of file + a hard render frame-rate cap (`save.options.fpsCap`). + +## Battle transition cascade + white battle letterbox + +Into-battle wipes still run the original eight styles inside the classic +160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8 +blocks cascade outward from that square into the surrounding world so the +void outside the OG wipe fills in lockstep. Once the battle state is up, +letterbox voids around the battle canvas fill **white** instead of black +so the whole window reads as one continuous battle screen. \ No newline at end of file diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 0d7dfa26..951a49ed 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart") local BattleState = {} BattleState.__index = BattleState BattleState.isOpaque = true +-- Letterbox voids around the 160x144 battle canvas fill white so the +-- window reads as one continuous battle screen (no black bars). +BattleState.letterboxWhite = true -- Battle colors itself per-pixel (species pics + HP bar tints), so the -- SGB whole-screen remap must not run over it. diff --git a/src/core/Game.lua b/src/core/Game.lua index 5175c42f..0d9532fe 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -246,7 +246,11 @@ end function Game:zoomStep(delta) local Zoom = require("src.render.Zoom") if not Zoom.gateOK(self.stack:top(), self.overworld) then return end - Zoom.step(delta, Renderer:fitScale()) + local offset = Zoom.step(delta, Renderer:fitScale()) + if self.save and self.save.options then + self.save.options.zoom = offset + self:writeOptions() + end end function Game:wheelmoved(_, dy) @@ -320,6 +324,14 @@ function Game:keypressed(key) self:writeOptions() end return + elseif key == "4" then + -- cycle ZOOM through every integer level (survey → FIT → close-up → wrap) + local Zoom = require("src.render.Zoom") + if Zoom.gateOK(self.stack:top(), self.overworld) then + self.save.options.zoom = Zoom.cycle(Renderer:fitScale()) + self:writeOptions() + end + return elseif key == "5" then -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- desktop. Mobile refuses the present shader (issue #136). @@ -445,6 +457,8 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) + require("src.render.Zoom").applyOptions(opts) + require("src.render.TileRenderer").applyOptions(opts) -- returns true when a persisted GBC FX level was cleared on mobile local gbcCleared = require("src.render.GBCFX").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 9d810c79..3e96383a 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -186,10 +186,14 @@ function SaveData.defaultOptions() musicFilter = 0, -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) speed = 1, - -- port display options (OptionsMenu / hotkeys 2/3/5) + -- port display options (OptionsMenu / hotkeys 2/3/4/5) colors = "gbc", tilt = 0, gbcfx = 0, + -- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua + zoom = 0, + -- OVERWORLD beyond-edge fill: trees | water | black + voidFill = "trees", -- windowed | borderless (desktop fullscreen); ignored on mobile videoMode = "windowed", -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) diff --git a/src/render/BattleTransition.lua b/src/render/BattleTransition.lua index 88e40c4b..f83c6ac3 100644 --- a/src/render/BattleTransition.lua +++ b/src/render/BattleTransition.lua @@ -230,6 +230,11 @@ function BattleTransition:draw() love.graphics.setColor(0, 0, 0, 1) local prog = math.min(1, self.t / self.wipeLen) + -- Cascade black 8x8 blocks across the window area *outside* the classic + -- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer + -- paints them in screen space after the world blit (see endFrame). + local renderer = self.game and self.game.renderer + if renderer then renderer.battleCascadeProg = prog end local style = self.style -- a registered style may draw itself; the eight built-ins do not diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 470adb42..9ff968e8 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -94,6 +94,8 @@ function Renderer:beginFrame(transparent) -- warp-fade overlay from Transition (issue #121); cleared each frame so -- a popped transition cannot leave a sticky black veil self.worldFadeAlpha = nil + -- battle-transition cascade outside the 160x144 wipe (BattleTransition) + self.battleCascadeProg = nil -- last frame's trueColor rects and sprite redraws go before anything -- draws this one PaletteFX.clearTrueColor() @@ -107,6 +109,46 @@ function Renderer:beginFrame(transparent) end end +-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox +-- into the surrounding window, matching BattleTransition wipe progress. +-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. +function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S) + if not prog or prog <= 0 then return end + local TILE = 8 * S + if TILE < 1 then TILE = 1 end + local cols = math.ceil(ww / TILE) + local rows = math.ceil(wh / TILE) + local cx, cy = ox + vpw / 2, oy + vph / 2 + local order = {} + for row = 0, rows - 1 do + for col = 0, cols - 1 do + local x, y = col * TILE, row * TILE + -- any tile with area outside the letterbox participates + if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then + local dist = math.max(math.abs(x + TILE / 2 - cx), + math.abs(y + TILE / 2 - cy)) + order[#order + 1] = { x, y, dist } + end + end + end + if #order == 0 then return end + table.sort(order, function(a, b) + if a[3] ~= b[3] then return a[3] < b[3] end + if a[2] ~= b[2] then return a[2] < b[2] end + return a[1] < b[1] + end) + local n = math.floor(#order * math.min(1, prog) + 1e-6) + if prog >= 1 then n = #order end + love.graphics.setColor(0, 0, 0, 1) + love.graphics.setScissor(0, 0, ww, wh) + for i = 1, n do + local t = order[i] + love.graphics.rectangle("fill", t[1], t[2], TILE, TILE) + end + love.graphics.setScissor() + love.graphics.setColor(1, 1, 1, 1) +end + function Renderer:beginWorldPass() local vw, vh = self:worldViewSize() if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw @@ -349,7 +391,20 @@ function Renderer:endFrame(zones, worldZones) present = self.presentCanvas love.graphics.setCanvas(present) end - love.graphics.setColor(0, 0, 0, 1) + -- Default letterbox is black. Battle (and any state that opts in via + -- letterboxWhite) fills the voids white so the window matches the + -- white battle canvas instead of showing black bars. + local clearR, clearG, clearB = 0, 0, 0 + if not self.worldActive then + local ok, Game = pcall(require, "src.core.Game") + local stack = ok and Game and Game.stack + local base = stack and stack.visibleBase and stack:visibleBase() + local state = base and stack.states and stack.states[base] + if state and state.letterboxWhite then + clearR, clearG, clearB = 1, 1, 1 + end + end + love.graphics.setColor(clearR, clearG, clearB, 1) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) @@ -460,6 +515,11 @@ function Renderer:endFrame(zones, worldZones) love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.setColor(1, 1, 1, 1) end + -- Battle transition: cascade black blocks into the area outside the + -- classic 160x144 wipe square (world still shows through until filled). + if self.battleCascadeProg then + self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S) + end end -- UI stays in the classic centered GB letterbox blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index 1e01b1fd..54534cb9 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) --- OVERWORLD maps fill beyond-edge space with the solid tree wall --- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, --- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), --- not each map's own border_block, which can be grass ($0B, the --- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other --- tilesets keep their designated border (interiors stay black/void) +-- OVERWORLD maps fill beyond-edge space from save.options.voidFill: +-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon) +-- water — solid water $43 (Cinnabar/Route 19 border block) +-- black — solid black (no tiled metatile) +-- Other tilesets keep their designated border (interiors stay black/void). local TREE_WALL_BLOCK = 0x0F +local WATER_BORDER_BLOCK = 0x43 +TileRenderer.VOID_FILLS = { "trees", "water", "black" } +TileRenderer.voidFill = "trees" + local function borderBlockFor(map) - if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end + if map.def.tileset == "OVERWORLD" then + local mode = TileRenderer.voidFill or "trees" + if mode == "water" then return WATER_BORDER_BLOCK end + if mode == "black" then return false end + return TREE_WALL_BLOCK + end return map.def.borderBlock end TileRenderer.borderBlockFor = borderBlockFor +function TileRenderer.setVoidFill(mode) + local ok = false + for _, m in ipairs(TileRenderer.VOID_FILLS) do + if m == mode then ok = true; break end + end + TileRenderer.voidFill = ok and mode or "trees" +end + +function TileRenderer.cycleVoidFill() + local cur = TileRenderer.voidFill or "trees" + local idx = 1 + for i, m in ipairs(TileRenderer.VOID_FILLS) do + if m == cur then idx = i; break end + end + TileRenderer.setVoidFill( + TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1]) + return TileRenderer.voidFill +end + +function TileRenderer.applyOptions(opts) + TileRenderer.setVoidFill(opts and opts.voidFill or "trees") +end + +function TileRenderer.voidFillLabel(mode) + mode = mode or TileRenderer.voidFill or "trees" + if mode == "water" then return "WATER" end + if mode == "black" then return "BLACK" end + return "TREES" +end + local imageCache = {} local function getImage(path) @@ -487,42 +525,118 @@ function TileRenderer.new(map, data) self.anims = anims self.claimedBy = claimedBy - -- a repeating 32x32 image of the border block, tiled behind - -- everything the 3-block ring doesn't cover (the survey zoom sees - -- far past the ring; interiors keep their black border this way) - pcall(function() - local border = map.tileset.blocks[borderBlockFor(map) + 1] - if not border then return end - local canvas = love.graphics.newCanvas(32, 32) - love.graphics.push("all") - love.graphics.setCanvas(canvas) - love.graphics.clear(1, 1, 1, 1) - for ty = 0, 3 do - for tx = 0, 3 do - local quad = self.quads[border[ty * 4 + tx + 1]] - if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end - end - end - love.graphics.setCanvas() - love.graphics.pop() - local img = love.graphics.newImage(canvas:newImageData()) - img:setWrap("repeat", "repeat") - img:setFilter("nearest", "nearest") - self.borderFill = img - end) + -- border-fill image is built lazily in :ensureBorderFill so a VOID FILL + -- option change can swap trees/water/black without reloading the map + self.borderFillMode = nil + self.borderFill = nil return self end +-- Bake a static repeating 32x32 of `block` into self.borderFill. +local function bakeBorderFill(self, block) + local border = self.map.tileset.blocks[block + 1] + if not border then return end + local canvas = love.graphics.newCanvas(32, 32) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 1, 1, 1) + for ty = 0, 3 do + for tx = 0, 3 do + local quad = self.quads[border[ty * 4 + tx + 1]] + if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end + end + end + love.graphics.setCanvas() + love.graphics.pop() + local img = love.graphics.newImage(canvas:newImageData()) + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + self.borderFill = img +end + +-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map +-- water), wrap-tiled so the void scrolls in lockstep with on-map water. +local function ensureWaterBorderFill(self) + if self.borderWaterTextures then return true end + local map = self.map + local perRow = map.tileset.tilesPerRow + local colors, gbcKey + if self.gbcAtlas and self.data then + local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE) + local groupColors = PaletteFX.worldGroupColors( + self.data, map.tileset.id, map.id, nil) + colors = group and groupColors and groupColors[group + 1] or nil + gbcKey = "#gbc:" .. map.id + end + local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE, + colors, gbcKey) + if not textures then return false end + for _, img in ipairs(textures) do + img:setWrap("repeat", "repeat") + img:setFilter("nearest", "nearest") + end + self.borderWaterTextures = textures + return true +end + +-- (re)build the repeating border image when the VOID FILL mode or tileset +-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a +-- solid clear; "water" keeps the live hshift textures instead of a bake. +function TileRenderer:ensureBorderFill() + local block = borderBlockFor(self.map) + local mode = block == false and "black" + or ((self.map.def.tileset == "OVERWORLD") + and (TileRenderer.voidFill or "trees") + or "map") + local ready = (mode == "black") + or (mode == "water" and self.borderWaterTextures) + or (mode ~= "water" and mode ~= "black" and self.borderFill) + if self.borderFillMode == mode and ready then return end + if self.borderFill and self.borderFill.release then + pcall(self.borderFill.release, self.borderFill) + end + self.borderFill = nil + -- shared shift-variant cache: drop the reference only, never release + self.borderWaterTextures = nil + self.borderFillMode = mode + if mode == "black" or block == false or block == nil then return end + if mode == "water" then + if ensureWaterBorderFill(self) then return end + -- headless / missing pixels: fall back to the static water block bake + end + pcall(bakeBorderFill, self, block) +end + -- tile the border block across the whole view (world-aligned so it -- meshes seamlessly with the ring batch) function TileRenderer:drawBorderFill(camX, camY, vw, vh) - if not self.borderFill then return end + self:ensureBorderFill() + if self.borderFillMode == "black" then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, vw, vh) + love.graphics.setColor(1, 1, 1, 1) + return + end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end local x, y = math.floor(camX), math.floor(camY) -- one reused Quad per renderer, mutated in place: this runs every -- overworld frame, so allocating a fresh Quad here churned the GC local q = self.borderQuad + if self.borderFillMode == "water" and self.borderWaterTextures then + local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1 + local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1] + if not tex then return end + if q then + q:setViewport(x, y, vw, vh, 8, 8) + else + q = love.graphics.newQuad(x, y, vw, vh, 8, 8) + self.borderQuad = q + end + love.graphics.draw(tex, q, 0, 0) + return + end + if not self.borderFill then return end if q then q:setViewport(x, y, vw, vh, 32, 32) else @@ -730,6 +844,9 @@ function TileRenderer:releaseBatches() safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderQuad); self.borderQuad = nil + -- shared shift-variant cache; only drop the reference + self.borderWaterTextures = nil + self.borderFillMode = nil self.win = nil if self.quads then for _, q in pairs(self.quads) do safeRelease(q) end diff --git a/src/render/Zoom.lua b/src/render/Zoom.lua index 4e76a11f..a3208242 100644 --- a/src/render/Zoom.lua +++ b/src/render/Zoom.lua @@ -1,6 +1,7 @@ -- Overworld survey zoom: integer pixels-per-world-pixel scales stepped --- by the mouse wheel. Stored as an offset from the window fit scale S --- so a resize keeps the relative zoom. Session-only; never saved. +-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an +-- offset from the window fit scale S so a resize keeps the relative +-- zoom. Persisted as save.options.zoom (default 0 = FIT). -- Spec: docs/new-features.md (survey zoom) local Zoom = {} @@ -12,16 +13,51 @@ function Zoom.scale(S) return math.max(1, math.min(2 * S, S + Zoom.offset)) end +-- legal offset range for a given fit scale +function Zoom.offsetRange(S) + S = math.max(1, math.floor(tonumber(S) or 1)) + return 1 - S, S +end + +function Zoom.clampOffset(offset, S) + local lo, hi = Zoom.offsetRange(S) + offset = math.floor(tonumber(offset) or 0) + if offset < lo then return lo end + if offset > hi then return hi end + return offset +end + function Zoom.step(delta, S) - Zoom.offset = Zoom.offset + delta - if S + Zoom.offset < 1 then Zoom.offset = 1 - S end - if S + Zoom.offset > 2 * S then Zoom.offset = S end + Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S) + return Zoom.offset +end + +-- Advance one zoom level toward max close-up, then wrap to full survey. +-- Returns the new offset. +function Zoom.cycle(S) + local lo, hi = Zoom.offsetRange(S) + local next = Zoom.offset + 1 + if next > hi then next = lo end + Zoom.offset = next + return Zoom.offset end function Zoom.reset() Zoom.offset = 0 end +function Zoom.applyOptions(opts) + Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0) +end + +-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / … +function Zoom.offsetLabel(offset) + offset = math.floor(tonumber(offset) or 0) + if offset == 0 then return "FIT" end + if offset < 0 then return "OUT" .. tostring(-offset) end + return "IN" .. tostring(offset) +end + -- world pixels covered by a w x h letterbox viewport at fit scale S -- (legacy GB-framed size; prefer fillViewSize for the live world pass) function Zoom.viewSize(S, w, h) diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index c838e422..0e6bb3ca 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -3,7 +3,8 @@ -- (cycles the merged rulesets registry; gen1_faithful keeps the original -- quirks), plus the port's audio rows and display rows: music/SFX -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / --- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager. +-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens +-- the mod manager. -- Rows are descriptors fed through the ui.options.rows hook, so mods can -- add their own; CANCEL is appended after the hook and stays fixed on the -- bottom line like pokered's. @@ -11,12 +12,15 @@ local PaletteFX = require("src.render.PaletteFX") local Tilt = require("src.render.Tilt") local GBCFX = require("src.render.GBCFX") +local Zoom = require("src.render.Zoom") +local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") local VideoMode = require("src.core.VideoMode") local FrameCap = require("src.core.FrameCap") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local OptionRows = require("src.ui.OptionRows") +local Renderer = require("src.render.Renderer") local OptionsMenu = {} OptionsMenu.__index = OptionsMenu @@ -201,6 +205,37 @@ local function buildRows(game) GBCFX.setLevel(o.gbcfx) return true end }, + { id = "zoom", label = "ZOOM", + value = function(g) + return Zoom.offsetLabel(g.save.options.zoom or 0) + end, + step = function(g, dir) + local o = g.save.options + local S = Renderer:fitScale() + local lo, hi = Zoom.offsetRange(S) + local off = (o.zoom or 0) + dir + if off > hi then off = lo + elseif off < lo then off = hi end + o.zoom = off + Zoom.offset = off + return true + end }, + { id = "voidFill", label = "VOID FILL", + value = function(g) + return TileRenderer.voidFillLabel(g.save.options.voidFill) + end, + step = function(g, dir) + local o = g.save.options + local modes = TileRenderer.VOID_FILLS + local cur = o.voidFill or "trees" + local i = 1 + for idx, m in ipairs(modes) do + if m == cur then i = idx; break end + end + o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1] + TileRenderer.setVoidFill(o.voidFill) + return true + end }, { id = "videoMode", label = "VIDEO MODE", value = function(g) return VideoMode.modeLabel(g.save.options.videoMode) diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index ff306751..dd365866 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -754,6 +754,97 @@ do love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor end +-- ------- battle transition cascade outside the OG square + white letterbox +do + local rects = {} + local color = { 1, 1, 1, 1 } + local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor + local savedGetDims = love.graphics.getDimensions + local savedGetPix = love.graphics.getPixelDimensions + -- taller-than-fit window so the 160x144 letterbox leaves real voids + -- (default 640x576 is an exact 4x fit with nothing outside the square) + love.graphics.getDimensions = function() return 800, 600 end + love.graphics.getPixelDimensions = function() return 800, 600 end + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { + mode = mode, x = x, y = y, w = w, h = h, + r = color[1], g = color[2], b = color[3], a = color[4], + } + end + love.graphics.setColor = function(r, g, b, a) + color[1], color[2], color[3], color[4] = r, g, b, a or 1 + end + + Renderer:init() + local btGame = { renderer = Renderer, stack = { pop = noop } } + local wipe = BattleTransition.new(btGame, nil, {}) + wipe.phase = "wipe" + wipe.t = math.floor(wipe.wipeLen / 2) + Renderer:beginFrame(true) + Renderer:beginWorldPass() + Renderer:endWorldPass() + wipe:draw() + check(Renderer.battleCascadeProg ~= nil + and Renderer.battleCascadeProg > 0 + and Renderer.battleCascadeProg < 1, + "battle wipe publishes mid-progress cascade to the renderer") + rects = {} + Renderer:endFrame(nil, fullWorldZones()) + local cascadeTile + for _, r in ipairs(rects) do + -- fitScale at 800x600 is min(5,4)=4 → 32px tiles outside the letterbox + if r.mode == "fill" and r.r == 0 and r.a == 1 and r.w == 32 and r.h == 32 then + cascadeTile = r + break + end + end + check(cascadeTile ~= nil, + "endFrame paints cascading black tiles outside the OG wipe square") + love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor + love.graphics.getDimensions = savedGetDims + love.graphics.getPixelDimensions = savedGetPix +end + +do + -- BattleState asks for white letterbox voids instead of black bars + local BattleState = require("src.battle.BattleState") + check(BattleState.letterboxWhite == true, + "BattleState opts into white letterbox fill") + local rects = {} + local color = { 1, 1, 1, 1 } + local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { + mode = mode, x = x, y = y, w = w, h = h, + r = color[1], g = color[2], b = color[3], a = color[4], + } + end + love.graphics.setColor = function(r, g, b, a) + color[1], color[2], color[3], color[4] = r, g, b, a or 1 + end + local Game = require("src.core.Game") + local savedStack = Game.stack + Game.stack = { + states = { { letterboxWhite = true, isOpaque = true } }, + visibleBase = function() return 1 end, + } + Renderer:init() + Renderer:beginFrame(false) + rects = {} + Renderer:endFrame(nil, nil) + local clear + for _, r in ipairs(rects) do + if r.mode == "fill" and r.x == 0 and r.y == 0 and r.w == 640 and r.h == 576 then + clear = r + break + end + end + check(clear and clear.r == 1 and clear.g == 1 and clear.b == 1, + "endFrame fills the window white when the visible base wants letterboxWhite") + Game.stack = savedStack + love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor +end + -- ------- the transition.style hook local stack = { pop = function() end } diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 5db78621..0b40ac6b 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -206,7 +206,8 @@ end local om = OptionsMenu.new(optGame()) local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", "musicVol", "sfxVol", "musicFilter", "colors", "tilt", - "gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" } + "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap", + "speed", "mods", "controls" } check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") for i, id in ipairs(WANT_IDS) do check(om.rows[i].id == id, "options row order: " .. id) @@ -236,17 +237,35 @@ check(om.game.save.options.musicVol == 6, "music volume steps down") for _ = 1, 10 do om.rows[5].step(om.game, -1) end check(om.game.save.options.musicVol == 0, "music volume clamps at 0") +-- ZOOM / VOID FILL rows +local Zoom = require("src.render.Zoom") +local TileRenderer = require("src.render.TileRenderer") +om.game.save.options.zoom = 0 +Zoom.offset = 0 +check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0") +om.rows[11].step(om.game, 1) +check(om.game.save.options.zoom == 1 and Zoom.offset == 1, + "ZOOM row steps to IN1") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "water" + and TileRenderer.voidFill == "water", + "VOID FILL row cycles TREES → WATER") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK") +om.rows[12].step(om.game, 1) +check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") + -- the MAX FPS row cycles the render-cap steps and shows the value plain om.game.save.options.fpsCap = nil -check(om.rows[12].value(om.game) == "60", +check(om.rows[14].value(om.game) == "60", "MAX FPS row defaults to 60 with no saved cap") -om.rows[12].step(om.game, 1) +om.rows[14].step(om.game, 1) check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75") -check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap") +check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap") om.game.save.options.fpsCap = 160 -om.rows[12].step(om.game, 1) +om.rows[14].step(om.game, 1) check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") -om.rows[12].step(om.game, -1) +om.rows[14].step(om.game, -1) check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling") -- ------- FrameCap normalize / cycle (issue #88) @@ -276,7 +295,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6 -- the MODS row is the manager's discoverable home local mgGame = optGame() om = OptionsMenu.new(mgGame) -om.rows[14].activate(mgGame) +om.rows[16].activate(mgGame) check(getmetatable(mgGame.stack:top()) == ManagerState, "the MODS row opens the manager") check(mgGame.stack:top().screenId == "ManagerState", @@ -286,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState", local BindingsMenu = require("src.ui.BindingsMenu") local cbGame = optGame() om = OptionsMenu.new(cbGame) -om.rows[15].activate(cbGame) +om.rows[17].activate(cbGame) local bm = cbGame.stack:top() check(getmetatable(bm) == BindingsMenu, "the CONTROLS row opens the rebind list") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 83edcb96..5c56b70d 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1573,6 +1573,13 @@ do eq(back.options.colors, "og", "options.lua round-trips colors") eq(back.options.tilt, 2, "options.lua round-trips tilt") eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx") + -- zoom / voidFill ride the same options.lua path when present + rep.options.zoom = -2 + rep.options.voidFill = "water" + SD.saveOptions(rep.options) + local optBack = SD.loadOptions() + eq(optBack.zoom, -2, "options.lua round-trips zoom") + eq(optBack.voidFill, "water", "options.lua round-trips voidFill") local origOpts, loadedOpts = rep.options, back.options rep.options, back.options = nil, nil local same, where = deepEq(rep, back, "save") @@ -1720,6 +1727,16 @@ do ow.runner = { isRunning = function() return true end } check(not Zoom.gateOK(ow, ow), "gate closed while a script runs") Zoom.reset() + + -- cycle wraps survey → … → close-up → survey; labels track offset + eq(Zoom.offsetLabel(0), "FIT", "zoom label FIT at offset 0") + eq(Zoom.offsetLabel(-2), "OUT2", "zoom label OUT for negative offset") + eq(Zoom.offsetLabel(3), "IN3", "zoom label IN for positive offset") + Zoom.offset = S -- max close-up + eq(Zoom.cycle(S), 1 - S, "zoom cycle wraps from max in to full survey") + Zoom.applyOptions({ zoom = -1 }) + eq(Zoom.offset, -1, "applyOptions restores saved zoom offset") + Zoom.reset() end -- ---------------------------------------------------------------- zoom camera @@ -2307,6 +2324,8 @@ do eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC") eq(og.save.options.tilt, 0, "new saves default TILT to OFF") eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF") + eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT") + eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES") eq(og.save.options.videoMode, "windowed", "new saves default VIDEO MODE to WINDOWED") eq(om.scroll, 0, "options viewport starts at the top") @@ -2345,7 +2364,25 @@ do for _ = 1, 4 do press("a") end eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF") press("down") - eq(om.index, 11, "cursor reaches VIDEO MODE") + eq(om.index, 11, "cursor reaches ZOOM") + local ZoomOpt = require("src.render.Zoom") + press("a") + eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1") + eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option") + press("left") + eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT") + press("down") + eq(om.index, 12, "cursor reaches VOID FILL") + local TR = require("src.render.TileRenderer") + press("a") + eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER") + eq(TR.voidFill, "water", "TileRenderer.voidFill tracks VOID FILL option") + press("a") + eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK") + press("a") + eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES") + press("down") + eq(om.index, 13, "cursor reaches VIDEO MODE") press("a") eq(og.save.options.videoMode, "borderless", "A cycles VIDEO MODE to BORDERLESS") @@ -2353,7 +2390,7 @@ do eq(og.save.options.videoMode, "windowed", "VIDEO MODE wraps back to WINDOWED") press("down") - eq(om.index, 12, "cursor reaches MAX FPS") + eq(om.index, 14, "cursor reaches MAX FPS") press("a") eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75") eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option") @@ -2362,7 +2399,7 @@ do for _ = 1, #FrameCap.STEPS - 1 do press("a") end eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60") press("down") - eq(om.index, 13, "cursor reaches GAME SPEED") + eq(om.index, 15, "cursor reaches GAME SPEED") press("a") eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") -- Driven by the level list rather than a literal press count: adding a @@ -2371,25 +2408,27 @@ do for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") press("down") - eq(om.index, 14, "cursor reaches MODS") + eq(om.index, 16, "cursor reaches MODS") press("down") - eq(om.index, 15, "cursor reaches CONTROLS") + eq(om.index, 17, "cursor reaches CONTROLS") press("down") - eq(om.index, 16, "CANCEL stays the fixed final row") - eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen") + eq(om.index, 18, "CANCEL stays the fixed final row") + eq(om.scroll, 13, "CANCEL keeps the last option boxes on screen") om:draw() -- smoke: scrolled layout draws under the headless stub press("a") check(popped, "A on CANCEL closes the options menu") local om2 = OptionsMenu.new(og) OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} - eq(om2.index, 16, "up from the top wraps to CANCEL") - eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail") + eq(om2.index, 18, "up from the top wraps to CANCEL") + eq(om2.scroll, 13, "wrapping to CANCEL scrolls to the tail") -- headless-safe: no love.audio, setters only update internal state require("src.core.Music").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options) PaletteFX.applyOptions(og.save.options) Tilt.applyOptions(og.save.options) GBCFX.applyOptions(og.save.options) + require("src.render.Zoom").applyOptions(og.save.options) + require("src.render.TileRenderer").applyOptions(og.save.options) VideoMode.applyOptions(og.save.options) end end @@ -2727,18 +2766,26 @@ end -- ================= BUGS.md batch: border-tree ================= do --- ---------------------------------------------------------------- border fill (tree wall) +-- ---------------------------------------------------------------- border fill (tree wall / VOID FILL) local TileRenderer = require("src.render.TileRenderer") --- OVERWORLD maps fill beyond-edge space with the solid tree wall $0F --- (ViridianCity/CeruleanCity/CeladonCity border_block: four regular-tree --- metatiles); per-map borders like Pallet's all-grass $0B (the --- CutTreeBlockSwaps $0B->$0A block) only apply to other tilesets +-- OVERWORLD maps fill beyond-edge space from VOID FILL (default trees $0F); +-- per-map borders like Pallet's all-grass $0B only apply to other tilesets +TileRenderer.setVoidFill("trees") eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F, "OVERWORLD border fill uses the tree wall block") eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, "interior border fill keeps the map's border block") eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F, "Pallet Town border fill is trees, not its all-grass border block") +TileRenderer.setVoidFill("water") +eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x43, + "VOID FILL water uses the solid water border block") +TileRenderer.setVoidFill("black") +eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), false, + "VOID FILL black disables the tiled border block") +eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7, + "VOID FILL does not change interior borders") +TileRenderer.setVoidFill("trees") local treeWallCuttable = false for _, swap in ipairs(Data.field.cutTreeSwaps) do if swap.before == 0x0F then treeWallCuttable = true end From 13b58dc51ee967d2437eb7bf4be32d9eb07a1311 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 24 Jul 2026 13:09:00 -0400 Subject: [PATCH 2/3] oooooooooooh bazinga --- src/link/CodeEntry.lua | 44 ++ src/link/Handshake.lua | 11 + src/link/LinkBattle.lua | 366 +++++++++++++-- src/link/LinkState.lua | 139 +++++- src/link/Net.lua | 171 ++++++- src/link/Tournament.lua | 577 ++++++++++++++++++++++++ src/render/PaletteFX.lua | 16 + tests/drivers/net_relay_smoke.lua | 63 +++ tests/drivers/online_play_test.lua | 53 +++ tests/drivers/tournament_guest_test.lua | 72 +++ tests/drivers/tournament_host_test.lua | 57 +++ tests/run_link_tests.lua | 251 +++++++++++ 12 files changed, 1785 insertions(+), 35 deletions(-) create mode 100644 src/link/CodeEntry.lua create mode 100644 src/link/Tournament.lua create mode 100644 tests/drivers/net_relay_smoke.lua create mode 100644 tests/drivers/online_play_test.lua create mode 100644 tests/drivers/tournament_guest_test.lua create mode 100644 tests/drivers/tournament_host_test.lua diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua new file mode 100644 index 00000000..f0335662 --- /dev/null +++ b/src/link/CodeEntry.lua @@ -0,0 +1,44 @@ +-- Shared 6-slot room-code entry widget: the digit-scrub interaction +-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the +-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn +-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read +-- aloud or handwritten never has to be checked twice). + +local CodeEntry = {} + +CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" +CodeEntry.LENGTH = 6 + +function CodeEntry.new() + local chars = {} + for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based + return { chars = chars, pos = 1 } +end + +local N = #CodeEntry.CHARSET + +function CodeEntry.up(state) + state.chars[state.pos] = state.chars[state.pos] % N + 1 +end + +function CodeEntry.down(state) + state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1 +end + +function CodeEntry.left(state) + state.pos = math.max(1, state.pos - 1) +end + +function CodeEntry.right(state) + state.pos = math.min(CodeEntry.LENGTH, state.pos + 1) +end + +function CodeEntry.text(state) + local out = {} + for i = 1, CodeEntry.LENGTH do + out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i]) + end + return table.concat(out) +end + +return CodeEntry diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 2cdad249..6872a5cc 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -67,6 +67,17 @@ function Handshake.linkModified(game) return false end +-- online play (the relay-based online match / tournament flows in +-- LinkState/Tournament) meets strangers, not a coordinating friend, so it +-- skips the LAN path's per-peer compatibility negotiation entirely and +-- just requires vanilla on both ends: no mod-added Pokemon, no surprises. +-- Mods only ever get baked in at boot (Loader:load), so this is a gate on +-- attempting to go online, not a live mod toggle -- the player disables +-- mods via the mod manager and relaunches. +function Handshake.onlineAllowed(game) + return #Handshake.mods(game) == 0 +end + -- mode is nil on the guest: it pairs and announces itself before the host -- has picked, and compatibility is decided from the two hellos, not the mode function Handshake.hello(game, mode) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index dce7f012..2c4c7a53 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -15,6 +15,7 @@ -- them in link battles). local Fingerprint = require("src.link.Fingerprint") +local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") local Protocol = require("src.link.Protocol") @@ -45,6 +46,38 @@ local function mkBattler(data, mon, isPlayer) return BattleState.makeBattler(data, mon, isPlayer, nil) end +-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator +-- (hostParty/guestParty): pack->unpack clamp so every perspective watching +-- a given match holds identical mon copies. errFmt(packedMon, why) builds +-- the message shown when strict mode refuses an unrebuildable mon. +local function unpackParty(game, packed, unpackOpts, errFmt) + local out = {} + for _, p in ipairs(packed or {}) do + local mon, why = Protocol.unpackMon(game.data, p, unpackOpts) + if mon then + table.insert(out, mon) + elseif unpackOpts.strict then + return nil, errFmt(p, why) + end + end + return out +end + +-- decode a wire action message against whichever battler it belongs to +-- (the opponent's, from a real participant's perspective; either side's, +-- from a spectator's) +local function decodeWireAction(s, msg, battler) + if msg.kind == "move" then + local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1))) + return battler.curMoves[slot] + elseif msg.kind == "struggle" then + return { id = "STRUGGLE", pp = 1, struggle = true } + elseif msg.kind == "locked" then + return s:lockedAction(battler) + end + return nil +end + -- canonical (host-side-first) state hash, unchanged since v1: it stays on -- the wire as `value` so a pre-mod peer still compares something it agrees -- with, while the components below carry the real coverage @@ -150,25 +183,14 @@ function LinkBattle.new(game, net, opts) -- both parties pass through the same pack->unpack clamp on both -- machines, so the copies are identical everywhere local unpackOpts = { strict = opts.strict or false } - local myParty, theirParty = {}, {} - for _, p in ipairs(opts.myParty or {}) do - local mon = Protocol.unpackMon(game.data, p, unpackOpts) - if mon then - table.insert(myParty, mon) - elseif unpackOpts.strict then - return nil, ("Your %s can't\nbattle on the\nother game."):format( - tostring(p.species)) - end - end - for _, p in ipairs(opts.theirParty or {}) do - local mon, why = Protocol.unpackMon(game.data, p, unpackOpts) - if mon then - table.insert(theirParty, mon) - elseif unpackOpts.strict then - return nil, ("Their %s isn't\nin this game.\n(%s)"):format( - tostring(p.species), tostring(why)) - end - end + local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) + return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) + end) + if not myParty then return nil, myErr end + local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) + return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) + end) + if not theirParty then return nil, theirErr end if #myParty == 0 or #theirParty == 0 then Logger.warn("link: empty party on one side") end @@ -227,6 +249,17 @@ function LinkBattle.new(game, net, opts) if text then s:say(text) end end + -- a tournament shot clock (opts.turnLimit) costs the slow player + -- specifically, unlike RUN or a desync -- both of which stay a draw -- + -- so it needs its own result rather than reusing endAsDraw + local function endWithResult(s, result, text) + if s.linkEnded then return end + s.result = result + s.afterQueue = "finish" + s.phase = "messages" + if text then s:say(text) end + end + local function orderMove(action) if action and action.id then return game.data.moves[action.id] end return nil @@ -271,15 +304,7 @@ function LinkBattle.new(game, net, opts) -- decode a remote action message against the enemy battler local function decodeTheirAction(s, msg) - if msg.kind == "move" then - local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1))) - return s.enemy.curMoves[slot] - elseif msg.kind == "struggle" then - return { id = "STRUGGLE", pp = 1, struggle = true } - elseif msg.kind == "locked" then - return s:lockedAction(s.enemy) - end - return nil + return decodeWireAction(s, msg, s.enemy) end -- with the handshake guaranteeing both games share a link surface, a @@ -520,6 +545,19 @@ function LinkBattle.new(game, net, opts) if not s.result then endAsDraw(s, ("%s left the\nbattle."):format(theirName)) end + elseif msg.type == "forfeit" then + -- the peer's own shot clock ran out; unlike a mutual RUN/desync + -- draw, this has a definite winner (us) + if not s.result then + endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName)) + end + else + -- a tournament control message (bracket_update, the next + -- match_start, ...) can arrive while this match is still + -- finishing up; Tournament.lua drains this once it regains the + -- stack top rather than losing it to this poll loop + s.pendingTournamentMessages = s.pendingTournamentMessages or {} + table.insert(s.pendingTournamentMessages, msg) end end if net.closed and not s.linkEnded and not s.result then @@ -535,16 +573,48 @@ function LinkBattle.new(game, net, opts) end return end + if opts.turnLimit and s.phase == "menu" then + if not s.turnClockActive then + s.turnClockActive = true + s.turnClock = opts.turnLimit + end + s.turnClock = s.turnClock - dt + if s.turnClock <= 0 then + s.turnClockActive = false + send({ type = "forfeit" }) + endWithResult(s, "lose", "Time's up! You\nforfeit the match.") + end + elseif opts.turnLimit then + s.turnClockActive = false + end baseUpdate(s, dt) end + if opts.turnLimit then + local baseDraw = self.draw + self.draw = function(s, ...) + baseDraw(s, ...) + if s.phase == "menu" and s.turnClockActive then + love.graphics.setColor(1, 1, 1, 1) + Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4) + end + end + end + local baseFinish = self.finish self.finish = function(s) if not s.linkEnded then s.linkEnded = true send({ type = "bye" }) end - net:close() + -- opts.keepNetOpen: a tournament match's `net` is the caller's + -- long-lived tournament connection (still needed for the next round, + -- spectating, bracket updates, ...), not a dedicated match socket -- + -- closing it here the way a plain 1v1 link battle does would sever + -- the whole tournament, not just this match. + if not opts.keepNetOpen then + net:close() + end if game.linkNet == net then game.linkNet = nil end baseFinish(s) end @@ -552,6 +622,244 @@ function LinkBattle.new(game, net, opts) return self end +-- A tournament spectator: reconstructs the exact same lockstep battle a +-- live match's two real participants are playing, from a copy of the +-- traffic the relay fans out to onlookers (see pokeserver's `spectate` +-- envelope). No local input drives anything here -- both sides' actions +-- arrive over the wire, tagged by which real player sent them -- so it's +-- a read-only replay, not a third participant: no hash/desync checking +-- (a spectator has nothing to verify against), no shot clock (nothing to +-- act on), and `finish` must NOT close `net`, since that's the caller's +-- long-lived tournament connection, not a dedicated match socket. +-- +-- opts: { hostParty = packed, guestParty = packed, hostName, guestName, +-- seed, verdict, strict }. `self.player` is always the host's battler and +-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's +-- tie-break (below) land on the same result the host's own instance +-- already computed with invertTie=false. +function LinkBattle.newSpectator(game, net, opts) + local BattleState = require("src.battle.BattleState") + local hostName = opts.hostName or "HOST" + local guestName = opts.guestName or "GUEST" + + if not Handshake.battleAllowed(opts.verdict) then + return nil, "Link battle needs\nthe same mods on\nboth games." + end + + local unpackOpts = { strict = opts.strict or false } + local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) + return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species)) + end) + if not hostParty then return nil, hostErr end + local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) + return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( + guestName, tostring(p.species), tostring(why)) + end) + if not guestParty then return nil, guestErr end + if #hostParty == 0 or #guestParty == 0 then + Logger.warn("link: empty party on one side (spectator)") + end + + local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species + or "RATTATA", 5) + self.kind = "link" -- exact same visual treatment as a real link battle + self.spectating = true -- Tournament.lua's marker: don't report a result for this one + self.net = net + game.linkNet = net + self.rng = makeRng(opts.seed or 1) + self.player = mkBattler(game.data, hostParty[1], true) + self.enemy = mkBattler(game.data, guestParty[1], false) + self.enemyParty = guestParty + self.playerParty = hostParty + self.opponentName = guestName + self.introText = ("%s vs %s!"):format(hostName, guestName) + + local function orderMove(action) + if action and action.id then return game.data.moves[action.id] end + return nil + end + + local function endSpectate(s, text) + if s.linkEnded then return end + s.result = s.result or "ended" + s.afterQueue = "finish" + s.phase = "messages" + if text then s:say(text) end + end + + local function sendOutHost(s, mon) + local previous = s.player + s.player = mkBattler(game.data, mon, true) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[1], battler = s.player, previous = previous, + }) + s.sendingOut = true + s:sayNext(s:sendOutText(s.player.name)) + s:animNext("POOF_ANIM", false) + s:actNext(function() + s.sendingOut = false + s:startGrowIn(s.player) + require("src.core.Sound").playCry(s.data, s.player.mon.species) + end) + end + + local function sendOutGuest(s, mon) + local previous = s.enemy + s.enemy = mkBattler(game.data, mon, false) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[2], battler = s.enemy, previous = previous, + }) + s.enemySendingOut = true + s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name)) + s:actNext(function() + s.enemySendingOut = false + s:startGrowIn(s.enemy) + s:actNext(function() + require("src.core.Sound").playCry(s.data, s.enemy.mon.species) + end) + end) + end + + local function resolveSpecTurn(s, hostMsg, guestMsg) + if hostMsg.kind == "run" or guestMsg.kind == "run" then + endSpectate(s, "The match ended.") + return + end + s.phase = "messages" + s.afterQueue = "linkNext" + s.turnCount = (s.turnCount or 0) + 1 + + if hostMsg.kind == "switch" then + local idx = hostMsg.index + s:act(function() sendOutHost(s, hostParty[idx]) end) + end + if guestMsg.kind == "switch" then + local idx = guestMsg.index + s:act(function() sendOutGuest(s, guestParty[idx]) end) + end + + s:act(function() + local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run" + and decodeWireAction(s, hostMsg, s.player) or nil + local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run" + and decodeWireAction(s, guestMsg, s.enemy) or nil + Runtime.emit("battle.turn_started", { + battle = s, turn = s.turnCount, + playerAction = hostAction, enemyAction = guestAction, + }) + if hostAction and guestAction then + local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction) + local first + if Runtime.wantsHook("battle.turn_order") then + first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c) + return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie) + end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false }) + else + first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false) + end + local order + if first then + order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } } + else + order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } } + end + for _, entry in ipairs(order) do + s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end) + end + elseif hostAction then + s:act(function() s:executeAction(s.player, s.enemy, hostAction) end) + elseif guestAction then + s:act(function() s:executeAction(s.enemy, s.player, guestAction) end) + end + s:act(function() s:endOfTurn() end) + end) + end + + self.resolveTurn = function() end -- a spectator's own input never drives anything + self.resolveSwitch = function() end + self.tryRun = function() end + self.openParty = function(s) s.phase = "waitBoth" end + + self.playerMonFainted = function(s) + for _, mon in ipairs(hostParty) do + if mon.hp > 0 then + s:act(function() sendOutHost(s, mon) end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName)) + s.result = "guestWin" + s.afterQueue = "finish" + end + + self.enemyMonFainted = function(s) + for _, mon in ipairs(guestParty) do + if mon.hp > 0 then + s:act(function() sendOutGuest(s, mon) end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName)) + s.result = "hostWin" + s.afterQueue = "finish" + end + + self.hostMsg, self.guestMsg = nil, nil + local baseUpdate = self.update + self.update = function(s, dt) + net:update() + for _, msg in ipairs(net:poll()) do + if msg.type == "spectate" then + local inner = msg.msg + if inner.type == "action" then + if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end + if s.hostMsg and s.guestMsg then + local h, g = s.hostMsg, s.guestMsg + s.hostMsg, s.guestMsg = nil, nil + resolveSpecTurn(s, h, g) + end + elseif inner.type == "bye" or inner.type == "forfeit" then + if not s.result then endSpectate(s, "The match ended.") end + end + -- "hello"/"party"/"hash" ride along too (Tournament.lua already + -- consumed hello/party before building this battle); none of them + -- need any action here + else + -- same reasoning as the real-participant loop above: don't lose a + -- bracket_update/match_start_spectate that arrives mid-match + s.pendingTournamentMessages = s.pendingTournamentMessages or {} + table.insert(s.pendingTournamentMessages, msg) + end + end + if net.closed and not s.linkEnded and not s.result then + endSpectate(s) + end + if s.phase == "messages" and s.afterQueue == "linkNext" then + if not s:updateQueue() then + s.afterQueue = "waitBoth" + s.phase = "waitBoth" + end + return + end + if s.phase == "menu" or s.phase == "waitBoth" then + return -- frozen between resolved turns; never a real decision here + end + baseUpdate(s, dt) + end + + local baseFinish = self.finish + self.finish = function(s) + s.linkEnded = true + if game.linkNet == net then game.linkNet = nil end + baseFinish(s) -- deliberately doesn't touch net: it's the caller's + -- tournament connection, still needed after this match + end + + return self +end + -- backwards-compatible entry points (LinkState passes role explicitly) function LinkBattle.newHost(game, net, opts) opts.role = "host" diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 091578fd..2295dd6d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -2,6 +2,7 @@ -- the other joins by typing that address in. Direct peer-to-peer over -- lua-enet (bundled with LÖVE), no relay server. +local CodeEntry = require("src.link.CodeEntry") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Net = require("src.link.Net") @@ -16,6 +17,12 @@ LinkState.isOpaque = true local CURSOR = 0xED +-- stages before any Net object is meaningfully "this session's link" -- +-- .net can still be a leftover failed attempt sitting on self, so error/ +-- closed checks below skip these rather than keying off self.net's +-- presence alone +local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } + -- how long the host waits for a v2 hello before deciding the peer predates -- the handshake (a pre-mod guest sends nothing until it hears the mode) local HELLO_GRACE = 2 @@ -119,25 +126,54 @@ function LinkState:update(dt) local input = self.game.input if self.net then self.net:update() - if self.net.error and self.stage ~= "menu" then + if self.net.error and not PRE_CONNECT_STAGES[self.stage] then self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) return end -- the peer vanished without a bye (only once the inbox is drained, -- so a final message travelling with the disconnect still counts) if self.net.closed and #self.net.inbox == 0 - and self.stage ~= "menu" and self.stage ~= "addrEntry" - and self.stage ~= "notice" and self.stage ~= "battleRunning" then + and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" + and self.stage ~= "codeEntry" and self.stage ~= "notice" + and self.stage ~= "battleRunning" then self:exitWith("The link was\nbroken.") return end end if self.stage == "menu" then + if input:wasPressed("down") then + self.index = self.index % 3 + 1 + elseif input:wasPressed("up") then + self.index = (self.index - 2) % 3 + 1 + elseif input:wasPressed("b") then + self:exitWith(nil) + elseif input:wasPressed("a") then + if self.index == 1 then + self.stage = "lanMenu" + self.index = 1 + elseif self.index == 2 or self.index == 3 then + if not Handshake.onlineAllowed(self.game) then + self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.") + return + end + if self.index == 2 then + self.stage = "onlineMenu" + else + local Tournament = require("src.link.Tournament") + self.game.stack:pop() + self.game.stack:push(Tournament.new(self.game)) + end + self.index = 1 + end + end + + elseif self.stage == "lanMenu" then if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("b") then - self:exitWith(nil) + self.stage = "menu" + self.index = 1 elseif input:wasPressed("a") then self.net = Net.new() if self.index == 1 then @@ -151,6 +187,62 @@ function LinkState:update(dt) end end + elseif self.stage == "onlineMenu" then + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("b") then + self.stage = "menu" + self.index = 2 + elseif input:wasPressed("a") then + if self.index == 1 then + self.net = Net.new() + if self.net:hostOnline() then + self.stage = "onlineHosting" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + else + self.stage = "codeEntry" + self.codeEntry = CodeEntry.new() + end + end + + elseif self.stage == "onlineHosting" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "modeSelect" + self.index = 1 + end + + elseif self.stage == "codeEntry" then + if input:wasPressed("b") then + self.stage = "onlineMenu" + self.index = 2 + elseif input:wasPressed("up") then + CodeEntry.up(self.codeEntry) + elseif input:wasPressed("down") then + CodeEntry.down(self.codeEntry) + elseif input:wasPressed("left") then + CodeEntry.left(self.codeEntry) + elseif input:wasPressed("right") then + CodeEntry.right(self.codeEntry) + elseif input:wasPressed("a") then + local code = CodeEntry.text(self.codeEntry) + self.net = Net.new() + if self.net:joinOnline(nil, code) then + self.stage = "onlineJoining" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + end + + elseif self.stage == "onlineJoining" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "waitMode" + self:sendHello(nil) -- the host owns the mode; this is just who we are + end + elseif self.stage == "hosting" then if input:wasPressed("b") then self:exitWith(nil) return end if self.net.paired then @@ -389,12 +481,49 @@ end function LinkState:draw() if self.stage == "menu" then - drawTitle("LINK CABLE CLUB") + drawTitle("BOIS CLUB LIVE") + Font.draw("LINK CABLE (LAN)", 32, 44) + Font.draw("ONLINE MATCH", 32, 60) + Font.draw("TOURNAMENT", 32, 76) + Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) + + elseif self.stage == "lanMenu" then + drawTitle("LINK CABLE (LAN)") Font.draw("HOST A GAME", 32, 48) Font.draw("JOIN A GAME", 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.draw("UDP port " .. Net.defaultPort(), 8, 128) + elseif self.stage == "onlineMenu" then + drawTitle("ONLINE MATCH") + Font.draw("HOST ONLINE", 32, 48) + Font.draw("JOIN ONLINE", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + + elseif self.stage == "onlineHosting" then + drawTitle("HOSTING ONLINE") + Font.draw("Tell your friend", 16, 40) + Font.draw("the code:", 16, 52) + Font.draw(self.net.code or "??????", 32, 68) + Font.draw("Waiting for join...", 8, 96) + + elseif self.stage == "codeEntry" then + drawTitle("ENTER CODE") + for i = 1, CodeEntry.LENGTH do + local x = 16 + (i - 1) * 16 + local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i]) + Font.draw(ch, x, 64) + if i == self.codeEntry.pos then + Font.drawCode(0xEE, x, 76) -- ▼ under the active slot + end + end + Font.draw("A: connect B: back", 8, 128) + + elseif self.stage == "onlineJoining" then + drawTitle("CONNECTING...") + Font.draw("Calling...", 8, 56) + Font.draw(self.net.target or "", 8, 72) + elseif self.stage == "hosting" then drawTitle("HOSTING") Font.draw("Friend joins at:", 16, 48) diff --git a/src/link/Net.lua b/src/link/Net.lua index 76c61490..ca72d5a9 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -14,6 +14,15 @@ -- Plain luajit (headless tests) has no enet; Net.available() reports -- that, and Net.loopbackPair() returns two in-memory ends with the -- same API so the protocol/battle logic stays testable offline. +-- +-- A second backend, alongside the ENet one above, talks plain TCP to a +-- pokeserver relay instead of direct peer-to-peer: both sides dial out +-- to a public server (works through NAT with no hole-punching), the host +-- gets a 6-character room code instead of an IP, and the server forwards +-- messages between them. Same newline-delimited JSON on the wire, same +-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see +-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't +-- know or care which backend is in play. local Json = require("src.link.Json") local Logger = require("src.core.Logger") @@ -21,10 +30,14 @@ local Logger = require("src.core.Logger") local hasEnet, enet = pcall(require, "enet") if not hasEnet then enet = nil end +local hasSocket, socket = pcall(require, "socket") +if not hasSocket then socket = nil end + local Net = {} Net.__index = Net Net.DEFAULT_PORT = 7777 +Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778" function Net.available() return enet ~= nil @@ -34,6 +47,10 @@ function Net.defaultPort() return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT end +function Net.defaultRelayAddress() + return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS +end + -- monotonic-ish clock for the join timeout local function now() if love and love.timer and love.timer.getTime then @@ -64,13 +81,17 @@ function Net.new() enetHost = nil, -- our enet host object (both ends have one) peer = nil, -- the connected remote peer inbox = {}, - outbox = {}, -- messages queued before pairing completes + outbox = {}, -- messages queued before pairing completes (enet only) paired = false, address = nil, -- host: "ip:port" the other player types in error = nil, closed = false, mode = nil, joinTimeout = 10, + tcpSocket = nil, -- relay backend: the luasocket TCP connection + rxBuf = "", -- relay backend: bytes read but not yet a full line + txBuf = "", -- relay backend: bytes queued but not yet written + code = nil, -- relay backend, hosting: the room/tournament code }, Net) end @@ -133,8 +154,56 @@ function Net:join(address) return true end +-- opens a TCP connection to a pokeserver relay (blocking connect with a +-- short timeout -- this runs once, from a single explicit user action, not +-- from a per-frame poll, so blocking briefly is fine). Callers then send +-- whatever control message starts their session ({type="host"}, +-- {type="join",...}, {type="host_tournament",...}, ...); every reply that +-- isn't one of the four generic ones below lands in the normal inbox for +-- the caller (LinkState, or Tournament.lua) to interpret. +function Net:connectTCP(addr) + if not socket then + self.error = "online play needs luasocket (bundled with LOVE)" + return false + end + local host, port = addr:match("^(.-):(%d+)$") + host = host or addr + port = tonumber(port) or 7778 + local tcp = socket.tcp() + tcp:settimeout(5) + local ok, err = tcp:connect(host, port) + if not ok then + self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err)) + return false + end + tcp:settimeout(0) + self.tcpSocket = tcp + self.rxBuf = "" + self.txBuf = "" + return true +end + +function Net:hostOnline(addr) + if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end + self.mode = "onlineHosting" + self:send({ type = "host" }) + return true +end + +function Net:joinOnline(addr, code) + if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end + self.mode = "onlineJoining" + self.target = code + self:send({ type = "join", code = code }) + return true +end + function Net:send(msg) if self.closed then return end + if self.tcpSocket then + self.txBuf = self.txBuf .. Json.encode(msg) .. "\n" + return + end if self.peerEnd then -- loopback: re-encode through json like the wire local decoded = Json.decode(Json.encode(msg)) if decoded and not self.peerEnd.closed then @@ -155,8 +224,102 @@ function Net:send(msg) end end +-- one control message recognized on every relay connection, regardless of +-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only +-- ever fires for a paired 1v1 room (tournaments signal disconnects through +-- bracket_update/tournament_over instead), so it's unambiguous here. +local function handleGenericRelayControl(self, msg) + if msg.type == "hosted" then + self.code = msg.code + return true + elseif msg.type == "paired" then + self.paired = true + return true + elseif msg.type == "join_error" then + self.error = ({ + not_found = "That code wasn't\nfound.", + full = "That game already\nhas two players.", + expired = "That code has\nexpired.", + })[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) + self.closed = true + return true + elseif msg.type == "peer_gone" then + self.closed = true + return true + end + return false +end + +function Net:handleTCPLine(line) + local msg = Json.decode(line) + if not msg then + Logger.warn("link: bad relay message %q", line:sub(1, 60)) + return + end + if not handleGenericRelayControl(self, msg) then + table.insert(self.inbox, msg) + end +end + +-- pulls every complete "\n"-terminated line out of rxBuf (leaving a +-- trailing partial line, if any, for the next call to complete) and hands +-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored +-- out of updateTCP so the framing logic is testable without a real +-- connection (see the relay-path tests in tests/run_link_tests.lua). +function Net:drainLines() + while true do + local nl = self.rxBuf:find("\n", 1, true) + if not nl then break end + local line = self.rxBuf:sub(1, nl - 1) + self.rxBuf = self.rxBuf:sub(nl + 1) + if #line > 0 then self:handleTCPLine(line) end + end +end + +-- non-blocking pump for the relay TCP backend: flush queued writes, drain +-- whatever's arrived into complete lines. Uses a byte-count receive +-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a +-- non-blocking caller recover the partial line across calls -- a +-- byte-count read hands back whatever's available via the third return +-- value on timeout, which we can buffer ourselves. +function Net:updateTCP() + if self.closed then return end + local sock = self.tcpSocket + if #self.txBuf > 0 then + local sent, err, lastByte = sock:send(self.txBuf) + if sent then + self.txBuf = "" + elseif err == "timeout" then + self.txBuf = self.txBuf:sub((lastByte or 0) + 1) + else + self.error = "send failed: " .. tostring(err) + self.closed = true + return + end + end + while true do + local data, err, partial = sock:receive(8192) + local chunk = data or partial or "" + if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end + if err == "closed" then + self.closed = true + break + elseif err and err ~= "timeout" then + self.error = tostring(err) + self.closed = true + break + end + if not data then break end -- nothing more buffered this frame + end + self:drainLines() +end + -- pump enet events; decoded JSON messages are queued for poll() function Net:update() + if self.tcpSocket then + self:updateTCP() + return + end if self.peerEnd then return end -- loopback needs no pumping if not self.enetHost or self.closed then return end while true do @@ -219,6 +382,12 @@ function Net:close() self.closed = true return end + if self.tcpSocket then + pcall(function() self.tcpSocket:close() end) + self.tcpSocket = nil + self.closed = true + return + end if self.enetHost then if self.peer and self.paired and not self.closed then -- graceful goodbye: disconnect_later delivers the queued diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua new file mode 100644 index 00000000..1bd9e840 --- /dev/null +++ b/src/link/Tournament.lua @@ -0,0 +1,577 @@ +-- Tournament play: host or join a bracket over the pokeserver relay. +-- Single-elimination, server-managed (pokeserver's `tournaments` map). +-- Matches run one at a time in bracket order; everyone not currently +-- playing -- still waiting their turn, or already eliminated -- watches +-- the live match play out via LinkBattle.newSpectator, reconstructed from +-- a copy of the real traffic the server fans out. Battle mode only (no +-- trade); vanilla only (Handshake.onlineAllowed already gated entry here +-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole +-- time, uninterrupted by individual matches. + +local CodeEntry = require("src.link.CodeEntry") +local Font = require("src.render.Font") +local Handshake = require("src.link.Handshake") +local LinkBattle = require("src.link.LinkBattle") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") + +local Tournament = {} +Tournament.__index = Tournament +Tournament.isOpaque = true + +local CURSOR = 0xED +local MUSIC = "Music_IndigoPlateau" +local TURN_LIMITS = { 3, 6, 9 } +local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 } +local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs + -- (LuaJIT's # still reports the literal's full size, but + -- ipairs stops dead at the hole), so level bounds use + -- this string in self.settings instead of nil, converted + -- to nil only on the wire +local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, + 70, 75, 80, 85, 90, 95, 100 } +local SETTINGS_ROWS = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING + +local function indexOf(list, value) + for i, v in ipairs(list) do + if v == value then return i end + end + return 1 +end + +-- accepts either the internal ANY sentinel or a raw wire value (nil means +-- "any" there too, since the server never round-trips the sentinel string) +local function levelLabel(v) + return (v == ANY or v == nil) and "ANY" or tostring(v) +end + +local function levelForWire(v) + return v == ANY and nil or v +end + +local function partyStats(party) + local size, minLevel, maxLevel = 0, nil, nil + for _, mon in ipairs(party or {}) do + size = size + 1 + local lvl = mon.level or 1 + minLevel = minLevel and math.min(minLevel, lvl) or lvl + maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl + end + return size, minLevel or 0, maxLevel or 0 +end + +function Tournament.new(game) + local self = setmetatable({}, Tournament) + self.game = game + self.stage = "menu" + self.index = 1 + self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY, + participating = true } + self.settingsIndex = 1 + self.roster = {} + self.spectatorRoster = {} + Sound.startLoop(game.data, MUSIC) + return self +end + +function Tournament:exitWith(message) + Sound.stopLoop(MUSIC) + Runtime.emit("link.ended", { reason = message and "error" or "bye" }) + if self.net then self.net:close() end + self.game.stack:pop() + if message then + self.game.stack:push(TextBox.new(self.game, message)) + end +end + +-- ------------------------------------------------------------------- +-- host / join +-- ------------------------------------------------------------------- + +function Tournament:startHosting() + self.net = Net.new() + if not self.net:connectTCP(Net.defaultRelayAddress()) then + self:exitWith("Link error:\n" .. (self.net.error or "?")) + return + end + local size, minL, maxL = partyStats(self.game.save.party) + self.isCreator = true + self.participating = self.settings.participating + self.net:send({ + type = "host_tournament", + turnLimit = self.settings.turnLimit, + requiredPartySize = self.settings.requiredPartySize, + minLevel = levelForWire(self.settings.minLevel), + maxLevel = levelForWire(self.settings.maxLevel), + participating = self.settings.participating, + name = self.game.save.player.name, + partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, + }) + self.stage = "registering" +end + +function Tournament:startJoining(code) + self.net = Net.new() + if not self.net:connectTCP(Net.defaultRelayAddress()) then + self:exitWith("Link error:\n" .. (self.net.error or "?")) + return + end + local size, minL, maxL = partyStats(self.game.save.party) + self.isCreator = false + self.participating = true -- joining is always to compete; only hosting can opt out + self.code = code + self.net:send({ + type = "join_tournament", code = code, name = self.game.save.player.name, + partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, + }) + self.stage = "registering" +end + +-- ------------------------------------------------------------------- +-- message handling (shared between "registering"/"bracket" and drained +-- again from a just-finished match's pendingTournamentMessages) +-- ------------------------------------------------------------------- + +local JOIN_ERROR_TEXT = { + not_found = "That code wasn't\nfound.", + already_started = "That tournament\nhas already begun.", + expired = "That code has\nexpired.", +} + +function Tournament:handleMessage(msg) + if msg.type == "tournament_hosted" then + self.code = msg.code + self.settings.turnLimit = msg.turnLimit + self.participating = msg.participating + self.stage = "bracket" + elseif msg.type == "tournament_host_error" then + if msg.reason == "party_ineligible" then + self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( + msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + else + self:exitWith("Couldn't host\nthat tournament.") + end + elseif msg.type == "tournament_join_error" then + if msg.reason == "party_ineligible" then + self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( + msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + else + self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.") + end + elseif msg.type == "tournament_roster" then + self.roster = msg.players + self.spectatorRoster = msg.spectators or {} + self.settings.turnLimit = msg.turnLimit + self.settings.requiredPartySize = msg.requiredPartySize + self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel + self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel + self.stage = "bracket" + elseif msg.type == "bracket_update" then + self.bracket = msg.tournament + self.code = self.bracket.code + if self.stage == "registering" then self.stage = "bracket" end + elseif msg.type == "match_start" then + self:enterMatch(msg) + elseif msg.type == "match_start_spectate" then + self:enterSpectate(msg) + elseif msg.type == "tournament_bye" then + self.byeRound = msg.round + elseif msg.type == "tournament_over" then + self.champion = msg.champion + self.stage = "done" + end +end + +-- ------------------------------------------------------------------- +-- entering a match: real participant +-- ------------------------------------------------------------------- + +function Tournament:sendHello(mode) + self.myHello = Handshake.hello(self.game, mode) + self.net:send(self.myHello) +end + +function Tournament:pollHello() + local msgs = self.net:poll() + local keep, got = {}, false + for _, msg in ipairs(msgs) do + if msg.type == "hello" and not self.peerHello then + self.peerHello = msg + got = true + else + keep[#keep + 1] = msg + end + end + for i = #keep, 1, -1 do + table.insert(self.net.inbox, 1, keep[i]) + end + return got +end + +function Tournament:enterMatch(msg) + self.isHost = (msg.role == "host") + self.opponentName = msg.opponent + self.matchRound = msg.round + self.matchTurnLimit = msg.turnLimit + self.peerHello = nil + self:sendHello(self.isHost and "battle" or nil) + self.stage = "matchHello" +end + +function Tournament:beginMatchBattle() + local verdict = Handshake.checkCompat(self.myHello, self.peerHello) + if not (verdict == "full" or verdict == "vanilla_peer") then + -- shouldn't happen (both sides already passed the online-play mods + -- gate), but a mismatched engine/build is still possible -- bail out + -- of just this match rather than crash the tournament + self:exitWith("Link error:\nversion mismatch\nwith opponent.") + return + end + self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirName = self.opponentName or "FOE", + seed = self.isHost and self.linkSeed or nil, + verdict = verdict, + strict = Handshake.strict(verdict), + turnLimit = self.matchTurnLimit, + keepNetOpen = true, -- this is the tournament's own connection, not a + -- dedicated match socket -- don't let finish() close it + } + self.stage = "matchWaitParty" + self.pendingBattleOpts = opts + self.net:send({ type = "party", + mons = Protocol.packParty(self.game.save.party), + seed = self.linkSeed }) +end + +-- ------------------------------------------------------------------- +-- entering a match: spectator +-- ------------------------------------------------------------------- + +function Tournament:enterSpectate(msg) + self.matchRound = msg.round + self.spectate = { + hostName = msg.playerHost, guestName = msg.playerGuest, + hostParty = nil, guestParty = nil, seed = nil, + } + self.stage = "spectateWait" +end + +-- ------------------------------------------------------------------- +-- update +-- ------------------------------------------------------------------- + +function Tournament:update(dt) + local input = self.game.input + + if self.stage == "matchRunning" or self.stage == "spectateRunning" then + if self.game.stack:top() == self then + -- the battle popped; drain anything the network handed to it but it + -- didn't itself understand (a bracket_update, the next match...) + local battle = self.activeBattle + self.activeBattle = nil + if self.stage == "matchRunning" and battle and battle.result and self.net + and not self.net.closed then + -- a real participant reports its own outcome; the server resolves + -- the match once both sides have (or one disconnects) + self.net:send({ type = "tournament_result", result = battle.result }) + end + if battle and battle.pendingTournamentMessages then + for _, msg in ipairs(battle.pendingTournamentMessages) do + self:handleMessage(msg) + end + end + if self.stage == "matchRunning" or self.stage == "spectateRunning" then + self.stage = "bracket" + end + end + return + end + + if self.net then + self.net:update() + if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" + and self.stage ~= "codeEntry" then + self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) + return + end + if self.net.closed and self.stage ~= "done" then + self:exitWith("The tournament\nconnection was\nlost.") + return + end + end + + if self.stage == "matchHello" then + if input:wasPressed("b") then self:exitWith(nil) return end + self:pollHello() + if self.peerHello then self:beginMatchBattle() end + for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + return + elseif self.stage == "matchWaitParty" then + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "party" then + self.pendingBattleOpts.theirParty = msg.mons + if self.isHost then + self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed + else + self.pendingBattleOpts.seed = msg.seed + end + local battle, why = self.isHost + and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) + or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) + if not battle then + self:exitWith(why or "Link battle\ncan't start.") + return + end + -- anything after `party` in this same batch belongs to the + -- battle now, not to Tournament -- put it back for its own poll() + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + self.activeBattle = battle + self.game.stack:push(battle) + self.stage = "matchRunning" + return + else + self:handleMessage(msg) + end + end + return + elseif self.stage == "spectateWait" then + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "spectate" and msg.msg.type == "party" then + local inner = msg.msg + if msg.side == "host" then + self.spectate.hostParty = inner.mons + self.spectate.seed = inner.seed + else + self.spectate.guestParty = inner.mons + end + if self.spectate.hostParty and self.spectate.guestParty then + local battle, why = LinkBattle.newSpectator(self.game, self.net, { + hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty, + hostName = self.spectate.hostName, guestName = self.spectate.guestName, + seed = self.spectate.seed, + }) + if not battle then + self:exitWith(why or "Can't watch this\nmatch.") + return + end + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + self.activeBattle = battle + self.game.stack:push(battle) + self.stage = "spectateRunning" + return + end + else + self:handleMessage(msg) + end + end + return + end + + if self.net then + for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + end + + if self.stage == "menu" then + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("b") then + self:exitWith(nil) + elseif input:wasPressed("a") then + if self.index == 1 then + self.stage = "hostSettings" + self.settingsIndex = 1 + else + self.stage = "codeEntry" + self.codeEntry = CodeEntry.new() + end + end + + elseif self.stage == "hostSettings" then + if input:wasPressed("b") then + self.stage = "menu" + self.index = 1 + elseif input:wasPressed("up") then + self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1 + elseif input:wasPressed("down") then + self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1 + elseif input:wasPressed("left") or input:wasPressed("right") then + local delta = input:wasPressed("right") and 1 or -1 + if self.settingsIndex == 1 then + local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize) + i = ((i - 1 + delta) % #PARTY_SIZES) + 1 + self.settings.requiredPartySize = PARTY_SIZES[i] + elseif self.settingsIndex == 2 then + local i = indexOf(LEVEL_STEPS, self.settings.minLevel) + i = ((i - 1 + delta) % #LEVEL_STEPS) + 1 + self.settings.minLevel = LEVEL_STEPS[i] + elseif self.settingsIndex == 3 then + local i = indexOf(LEVEL_STEPS, self.settings.maxLevel) + i = ((i - 1 + delta) % #LEVEL_STEPS) + 1 + self.settings.maxLevel = LEVEL_STEPS[i] + elseif self.settingsIndex == 4 then + local i = indexOf(TURN_LIMITS, self.settings.turnLimit) + i = ((i - 1 + delta) % #TURN_LIMITS) + 1 + self.settings.turnLimit = TURN_LIMITS[i] + elseif self.settingsIndex == 5 then + self.settings.participating = not self.settings.participating + end + elseif input:wasPressed("a") or input:wasPressed("start") then + self:startHosting() + end + + elseif self.stage == "codeEntry" then + if input:wasPressed("b") then + self.stage = "menu" + self.index = 2 + elseif input:wasPressed("up") then + CodeEntry.up(self.codeEntry) + elseif input:wasPressed("down") then + CodeEntry.down(self.codeEntry) + elseif input:wasPressed("left") then + CodeEntry.left(self.codeEntry) + elseif input:wasPressed("right") then + CodeEntry.right(self.codeEntry) + elseif input:wasPressed("a") then + self:startJoining(CodeEntry.text(self.codeEntry)) + end + + elseif self.stage == "registering" then + if input:wasPressed("b") then self:exitWith(nil) end + + elseif self.stage == "bracket" then + if input:wasPressed("b") then self:exitWith(nil) return end + if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then + self.net:send({ type = "start_tournament" }) + end + + elseif self.stage == "done" then + if input:wasPressed("a") or input:wasPressed("b") then + self:exitWith(nil) + end + end +end + +-- ------------------------------------------------------------------- +-- draw +-- ------------------------------------------------------------------- + +local function drawTitle(text) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(text, 8, 6) +end + +local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "PLAYING" } + +function Tournament:draw() + if self.stage == "menu" then + drawTitle("TOURNAMENT") + Font.draw("HOST", 32, 48) + Font.draw("JOIN", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + + elseif self.stage == "hostSettings" then + drawTitle("TOURNAMENT RULES") + local values = { + tostring(self.settings.requiredPartySize), + levelLabel(self.settings.minLevel), + levelLabel(self.settings.maxLevel), + self.settings.turnLimit .. "s", + self.settings.participating and "YES" or "NO", + } + for i, label in ipairs(SETTINGS_LABELS) do + local y = 32 + (i - 1) * 16 + Font.draw(label, 16, y) + Font.draw(values[i], 96, y) + if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end + end + Font.draw("START: create", 8, 128) + + elseif self.stage == "codeEntry" then + drawTitle("ENTER CODE") + for i = 1, CodeEntry.LENGTH do + local x = 16 + (i - 1) * 16 + local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i]) + Font.draw(ch, x, 64) + if i == self.codeEntry.pos then + Font.drawCode(0xEE, x, 76) + end + end + Font.draw("A: join B: back", 8, 128) + + elseif self.stage == "registering" then + drawTitle("CONNECTING...") + Font.draw("B: cancel", 8, 128) + + elseif self.stage == "bracket" or self.stage == "matchHello" + or self.stage == "matchWaitParty" or self.stage == "spectateWait" then + drawTitle(("TOURNAMENT %s"):format(self.code or "??????")) + if self.bracket then + local y = 20 + for _, round in ipairs(self.bracket.rounds) do + Font.draw(("ROUND %d"):format(round.round), 8, y) + y = y + 10 + for _, m in ipairs(round.matches) do + local line + if m.bye then + line = ("%s (bye)"):format(m.a or m.b or "?") + else + local mark = m.state == "live" and "*" or (m.winner and "" or "") + line = ("%s%s vs %s%s"):format( + m.winner == m.a and ">" or " ", m.a or "?", + m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) + end + Font.draw(line, 12, y) + y = y + 10 + if y > 120 then break end + end + end + else + if self.participating == false then + Font.draw("(organizing --", 16, 32) + Font.draw("not playing)", 16, 42) + end + Font.draw("Waiting for", 16, 48) + Font.draw("players to join:", 16, 60) + local y = 60 + for i, name in ipairs(self.roster) do + y = 60 + i * 10 + Font.draw(name, 24, y) + end + for _, name in ipairs(self.spectatorRoster) do + y = y + 10 + Font.draw(name .. " (watch)", 24, y) + end + end + if self.isCreator and #self.roster >= 2 and not self.bracket then + Font.draw("A: START B: cancel", 8, 132) + else + Font.draw("B: cancel", 8, 132) + end + + elseif self.stage == "done" then + drawTitle("TOURNAMENT OVER") + if self.champion then + Font.draw(("%s is the"):format(self.champion), 16, 56) + Font.draw("champion!", 16, 68) + end + Font.draw("A: continue", 8, 128) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return Tournament diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index d2fa39d0..64da0727 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -295,6 +295,16 @@ function PaletteFX.monPal(data, species, transformed) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + -- a species' own `palette` field (per-record override) wins over the + -- vanilla species->name map. Mod-registered palettes live in + -- data.palettes.palettes even when the active pack is the RED++ gbc pack, + -- so fall back to it when the pack itself doesn't carry the name. + local def = data and data.pokemon and data.pokemon[species] + if def and def.palette then + local pal = p.palettes[def.palette] + or (data and data.palettes and data.palettes.palettes[def.palette]) + if pal then return pal end + end local name = p.pokemon[species] or "MEWMON" local c = p.palettes[name] if c then return c end @@ -308,6 +318,12 @@ end -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed) if transformed then return "GRAYMON" end + -- honor the per-record palette override, matching monPal + local def = data and data.pokemon and data.pokemon[species] + if def and def.palette and data.palettes + and data.palettes.palettes[def.palette] then + return def.palette + end local p = PaletteFX.pack(data) if p and p.pokemon[species] then return p.pokemon[species] end if data and data.palettes and data.palettes.pokemon[species] then diff --git a/tests/drivers/net_relay_smoke.lua b/tests/drivers/net_relay_smoke.lua new file mode 100644 index 00000000..1412535c --- /dev/null +++ b/tests/drivers/net_relay_smoke.lua @@ -0,0 +1,63 @@ +-- Driver: exercises src/link/Net.lua's TCP relay backend for real, inside +-- LOVE (real lua-enet/luasocket), against a pokeserver already running at +-- 127.0.0.1:7778 (POKEPORT_RELAY_ADDR overrides). Doesn't touch the game +-- UI at all -- just proves host/join/relay/close over a real socket. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Net = require("src.link.Net") + local addr = os.getenv("POKEPORT_RELAY_ADDR") or "127.0.0.1:7778" + + local host = Net.new() + local ok = host:hostOnline(addr) + U.log("hostOnline ok:", ok, "error:", host.error) + + local frames = 0 + while not host.code and not host.error and frames < 180 do + host:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host code:", host.code, "error:", host.error) + + local guest = Net.new() + local gok = guest:joinOnline(addr, host.code) + U.log("joinOnline ok:", gok, "error:", guest.error) + + frames = 0 + while (not host.paired or not guest.paired) and frames < 180 do + host:update() + guest:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host.paired:", host.paired, "guest.paired:", guest.paired) + + host:send({ type = "hello", name = "RED" }) + local relayed = nil + frames = 0 + while not relayed and frames < 180 do + host:update() -- flushes host's queued send + guest:update() + for _, msg in ipairs(guest:poll()) do + if msg.type == "hello" then relayed = msg end + end + frames = frames + 1 + coroutine.yield() + end + U.log("relayed hello name:", relayed and relayed.name) + + guest:close() + frames = 0 + while not host.closed and frames < 180 do + host:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host saw peer_gone / closed:", host.closed) + + host:close() + + local pass = host.code ~= nil and host.paired and guest.paired + and relayed ~= nil and relayed.name == "RED" and host.closed + U.log("NET_RELAY_SMOKE:", pass and "PASS" or "FAIL") +end diff --git a/tests/drivers/online_play_test.lua b/tests/drivers/online_play_test.lua new file mode 100644 index 00000000..b2c163b4 --- /dev/null +++ b/tests/drivers/online_play_test.lua @@ -0,0 +1,53 @@ +-- Driver: screenshots the new online-play menu surface (LinkState's +-- restructured top menu, the online host/join flow). Pushed directly +-- (bypassing Start-menu navigation, matching options_test.lua's +-- convention) so it doesn't depend on party/save state. +-- +-- Run with no mods discoverable (mod enable/disable only takes effect at +-- the next boot -- see Handshake.onlineAllowed's comment -- so a live +-- toggle can't simulate "vanilla" mid-session; the mod directory has to +-- actually be absent for this run). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + local LinkState = require("src.link.LinkState") + + local function closeAnyOpenScreens() + while game.stack:top() and game.stack:top().exitWith do + game.stack:top():exitWith(nil) + U.wait(2) + end + end + + -- top menu (3 rows: LAN / ONLINE MATCH / TOURNAMENT) + game.stack:push(LinkState.new(game)) + U.wait(5) + U.shot(game, DIR .. "/link_0_top_menu.png") + U.tap(game, "down"); U.wait(2) + U.shot(game, DIR .. "/link_1_top_menu_online.png") + + -- ONLINE MATCH -> HOST ONLINE (needs pokeserver reachable at the + -- default relay address; POKEPORT_RELAY_ADDR overrides) + U.tap(game, "a"); U.wait(3) -- into onlineMenu + U.shot(game, DIR .. "/link_2_online_menu.png") + U.tap(game, "a"); U.wait(20) -- HOST ONLINE -> connect + U.shot(game, DIR .. "/link_3_online_hosting.png") + + -- back out, try JOIN ONLINE's code-entry screen + closeAnyOpenScreens() + game.stack:push(LinkState.new(game)) + U.wait(5) + U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row + U.tap(game, "a"); U.wait(3) + U.tap(game, "down"); U.wait(2) -- JOIN ONLINE row + U.tap(game, "a"); U.wait(3) + U.shot(game, DIR .. "/link_4_code_entry.png") + U.tap(game, "up"); U.wait(1) + U.tap(game, "right"); U.wait(1) + U.shot(game, DIR .. "/link_5_code_entry_scrubbed.png") + closeAnyOpenScreens() + + U.log("ONLINE_PLAY_DRIVER: done") +end diff --git a/tests/drivers/tournament_guest_test.lua b/tests/drivers/tournament_guest_test.lua new file mode 100644 index 00000000..781afb4d --- /dev/null +++ b/tests/drivers/tournament_guest_test.lua @@ -0,0 +1,72 @@ +-- Driver: joins the tournament tournament_host_test.lua creates, reading +-- the code from a shared file since the two LOVE processes otherwise +-- can't coordinate. Meant for a fresh throwaway POKEPORT_IDENTITY, so it +-- injects a test party directly (mirroring run_link_tests.lua's +-- makeFakeGame) rather than playing through a whole new-game intro. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney" + local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + -- the default tournament rule is exactly 3 Pokemon; overwrite whatever + -- this identity's party actually is (safe: a match uses clamped copies) + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "BLASTOISE", 50), + Pokemon.new(game.data, "GENGAR", 35), + Pokemon.new(game.data, "ALAKAZAM", 45), + } + game.save.player.name = "BLUE" + + local CodeEntry = require("src.link.CodeEntry") + local Tournament = require("src.link.Tournament") + local t = Tournament.new(game) + game.stack:push(t) + U.wait(3) + + local code = nil + local waited = 0 + while not code and waited < 1800 do + local f = io.open(CODE_FILE, "r") + if f then + local c = f:read("*l") + f:close() + if c and #c == 6 then code = c end + end + U.wait(1) + waited = waited + 1 + end + U.log("guest read code:", code) + if not code then + U.log("TOURNAMENT_GUEST_DRIVER: never saw a code, aborting") + return + end + + U.tap(game, "down"); U.wait(2) -- JOIN row + U.tap(game, "a"); U.wait(3) -- into codeEntry + U.shot(game, DIR .. "/guest_0_code_entry.png") + for i = 1, CodeEntry.LENGTH do + local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true) + if idx then t.codeEntry.chars[i] = idx end + end + U.tap(game, "a"); U.wait(3) -- confirm -> startJoining + U.shot(game, DIR .. "/guest_1_joining.png") + + waited = 0 + while #t.roster == 0 and t.stage ~= "done" and waited < 1800 do + U.wait(1) + waited = waited + 1 + end + U.log("guest roster:", table.concat(t.roster or {}, ",")) + U.shot(game, DIR .. "/guest_2_roster.png") + + local doneWait = 0 + while t.stage ~= "done" and doneWait < 5400 do + U.tap(game, "a") + U.wait(2) + doneWait = doneWait + 1 + end + U.shot(game, DIR .. "/guest_3_done.png") + U.log("TOURNAMENT_GUEST_DRIVER: champion=", t.champion, "stage=", t.stage) +end diff --git a/tests/drivers/tournament_host_test.lua b/tests/drivers/tournament_host_test.lua new file mode 100644 index 00000000..45bf0347 --- /dev/null +++ b/tests/drivers/tournament_host_test.lua @@ -0,0 +1,57 @@ +-- Driver: hosts a real 2-player tournament against a real running +-- pokeserver, paired with tournament_guest_test.lua running in a second +-- LOVE process. Coordinates over a shared code file (TOURNEY_CODE_FILE) +-- since the two processes have no other way to talk before the code +-- exists. The default tournament rule is exactly 3 Pokemon, so this +-- overwrites whatever party the identity actually has with 3 fixed mons +-- -- a tournament match never touches the real save (clamped copies, same +-- as any other link battle), so clobbering it here for the test is safe. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney" + local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 40), + } + game.save.player.name = "RED" + + local Tournament = require("src.link.Tournament") + local t = Tournament.new(game) + game.stack:push(t) + U.wait(3) + + U.shot(game, DIR .. "/host_0_menu.png") + U.tap(game, "a"); U.wait(3) -- HOST -> hostSettings + U.shot(game, DIR .. "/host_1_settings.png") + U.tap(game, "start"); U.wait(30) -- create with defaults (3 mons, any/any, 6s) + U.shot(game, DIR .. "/host_2_hosting.png") + U.log("host code:", t.code, "error:", t.net and t.net.error) + + local f = io.open(CODE_FILE, "w") + if f then f:write(t.code or ""); f:close() end + + local waited = 0 + while #t.roster < 2 and waited < 1800 do + U.wait(1) + waited = waited + 1 + end + U.log("host roster:", table.concat(t.roster, ",")) + U.shot(game, DIR .. "/host_3_roster.png") + + U.tap(game, "a"); U.wait(1) -- start_tournament + U.shot(game, DIR .. "/host_4_bracket.png") + + local doneWait = 0 + while t.stage ~= "done" and doneWait < 5400 do + U.tap(game, "a") + U.wait(2) + doneWait = doneWait + 1 + end + U.shot(game, DIR .. "/host_5_done.png") + U.log("TOURNAMENT_HOST_DRIVER: champion=", t.champion, "stage=", t.stage) +end diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index e6c8cd9a..aa586b18 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -127,6 +127,130 @@ else print("skip real enet pairing (lua-enet not available under this interpreter)") end +-- ---------------------------------------------------------------- relay (TCP) transport +-- Pure framing logic needs no socket at all: Net:drainLines()/handleTCPLine +-- operate directly on rxBuf, so this much runs even under plain luajit. +do + local n = Net.new() + local encoded = Json.encode({ type = "hosted", code = "ABCDEF" }) + n.rxBuf = encoded .. "\n" + n:drainLines() + eq(n.code, "ABCDEF", "relay framing: a complete hosted line sets net.code") + check(n.rxBuf == "", "relay framing: a complete line is fully consumed") + + local n2 = Net.new() + n2.rxBuf = encoded:sub(1, 5) -- the line straddles two reads + n2:drainLines() + check(n2.code == nil, "relay framing: a partial line doesn't parse yet") + n2.rxBuf = n2.rxBuf .. encoded:sub(6) .. "\n" + n2:drainLines() + eq(n2.code, "ABCDEF", "relay framing: completing the line resolves it") + + local n3 = Net.new() + n3.rxBuf = Json.encode({ type = "join_error", reason = "not_found" }) .. "\n" + n3:drainLines() + check(n3.error ~= nil and n3.closed, "relay framing: join_error sets error and closes") + + local n4 = Net.new() + n4.rxBuf = Json.encode({ type = "paired" }) .. "\n" + n4:drainLines() + check(n4.paired, "relay framing: paired flips net.paired") + + local n5 = Net.new() + n5.paired = true + n5.rxBuf = Json.encode({ type = "peer_gone" }) .. "\n" + n5:drainLines() + check(n5.closed, "relay framing: peer_gone closes the connection") + + local n6 = Net.new() + n6.rxBuf = Json.encode({ type = "hello", name = "RED" }) .. "\n" + n6:drainLines() + eq(#n6.inbox, 1, "relay framing: an unrecognized control type lands in the inbox") + eq(n6.inbox[1] and n6.inbox[1].name, "RED", "relay framing: ...with its payload intact") +end + +-- real pokeserver over TCP localhost (only when luasocket is present, i.e. +-- inside LOVE or a luajit with luasocket installed, AND node is on PATH to +-- spawn the real relay; otherwise this section is skipped, same spirit as +-- the enet gate above) +local hasSocket = pcall(require, "socket") +local nodeCheck = os.execute("command -v node >/dev/null 2>&1") +local hasNode = nodeCheck == true or nodeCheck == 0 +if not hasSocket then + print("skip real relay pairing (luasocket not available under this interpreter)") +elseif not hasNode then + print("skip real relay pairing (node not on PATH to spawn pokeserver)") +else + local PORT = 17778 + local pidFile = os.tmpname() + os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_test.log 2>&1 & echo $! > %q)") + :format(PORT, PORT + 1, pidFile)) + + local function busyWait(seconds) + local t0 = os.clock() + while os.clock() - t0 < seconds do end + end + + local function tcpConnectable(host, port) + local socket = require("socket") + local tcp = socket.tcp() + tcp:settimeout(0.2) + local ok = tcp:connect(host, port) + tcp:close() + return ok ~= nil + end + + local ready = false + for _ = 1, 50 do + ready = tcpConnectable("127.0.0.1", PORT) + if ready then break end + busyWait(0.1) + end + + if not ready then + print("skip real relay pairing (couldn't reach the spawned pokeserver)") + else + local host = Net.new() + check(host:hostOnline("127.0.0.1:" .. PORT), "relay: hostOnline connects: " .. tostring(host.error)) + local deadline = os.clock() + 3 + while not host.code and os.clock() < deadline do host:update() end + check(host.code ~= nil, "relay: a real server assigns a room code") + + local guest = Net.new() + check(guest:joinOnline("127.0.0.1:" .. PORT, host.code or ""), + "relay: joinOnline connects: " .. tostring(guest.error)) + deadline = os.clock() + 3 + while (not host.paired or not guest.paired) and os.clock() < deadline do + host:update() + guest:update() + end + check(host.paired and guest.paired, "relay: both sides pair over a real TCP server") + + host:send({ type = "hello", name = "RED" }) + local relayed = nil + deadline = os.clock() + 3 + while not relayed and os.clock() < deadline do + host:update() + guest:update() + for _, m in ipairs(guest:poll()) do + if m.type == "hello" then relayed = m end + end + end + eq(relayed and relayed.name, "RED", "relay: a message round-trips through the real server") + + host:close() + guest:close() + end + + local pidHandle = io.open(pidFile, "r") + if pidHandle then + local pid = pidHandle:read("*l") + pidHandle:close() + if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end + end + os.remove(pidFile) +end + -- ---------------------------------------------------------------- trade session local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) } local partyB = { Pokemon.new(Data, "MACHOKE", 32) } @@ -240,6 +364,133 @@ eq(gameA.save.money, 3000, "no prize money in link battles") eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp, "the real party is untouched (battle used clamped copies)") +-- ---------------------------------------------------------------- tournament shot clock +-- opts.turnLimit only applies to tournament matches; the guest mashes +-- through its own menu every frame while the host never presses anything, +-- so the host's clock is the only one that can expire. +local gameC = makeFakeGame("PIKACHU") +local gameD = makeFakeGame("SNORLAX") +gameD.save.player.name = "YELLOW" +local netC, netD = Net.loopbackPair() +local packedC = Protocol.packParty(gameC.save.party) +local packedD = Protocol.packParty(gameD.save.party) +local battleC = LinkBattle.newHost(gameC, netC, { + myParty = packedC, theirParty = packedD, theirName = "YELLOW", seed = 42, + turnLimit = 0.05, +}) +local battleD = LinkBattle.newGuest(gameD, netD, { + myParty = packedD, theirParty = packedC, theirName = "RED", seed = 42, + turnLimit = 0.05, +}) +local resC, resD = nil, nil +battleC.onFinish = function(r) resC = r end +battleD.onFinish = function(r) resD = r end +gameC.stack:push(battleC) +gameD.stack:push(battleD) + +-- both sides need "a" to get through the intro's messages/animations +-- (send-out poofs, cries...) before the host's own menu even shows; only +-- once the host's decision point is actually up does withholding input +-- from it mean anything +local guardIntro = 0 +while battleC.phase ~= "menu" and guardIntro < 6000 do + guardIntro = guardIntro + 1 + Input.pressed = { a = true } + gameC.stack:update(1 / 60) + gameD.stack:update(1 / 60) +end +check(battleC.phase == "menu", "shot clock: host reaches its own decision point") + +local guard2 = 0 +while resD == nil and guard2 < 6000 do + guard2 = guard2 + 1 + Input.pressed = { a = true } + gameD.stack:update(1 / 60) -- guest mashes through its menu + Input.pressed = {} + gameC.stack:update(1 / 60) -- host presses nothing; its clock ticks down +end +check(resD == "win", "shot clock: the timed-out player's opponent wins immediately") + +-- the timed-out host still has to dismiss its own "time's up" message to +-- reach finish() -- exactly like a slow-but-present player would; this +-- isn't the clock's business, just the ordinary message queue +local guard3 = 0 +while resC == nil and guard3 < 6000 do + guard3 = guard3 + 1 + Input.pressed = { a = true } + gameC.stack:update(1 / 60) +end +check(resC == "lose", "shot clock: the timed-out host is recorded as the loser") + +-- ---------------------------------------------------------------- tournament spectator replay +-- A spectator (LinkBattle.newSpectator) reconstructs the same lockstep +-- battle from a copy of the wire traffic tagged by side -- exactly what +-- pokeserver's tournament fan-out gives it. Feed it directly here rather +-- than standing up a real relay: wrap the host/guest loopback sends so +-- every message they exchange also lands, tagged, in a fake spectator net. +local gameE = makeFakeGame("CHARIZARD") +local gameF = makeFakeGame("BLASTOISE") +gameF.save.player.name = "BLUE" +local gameSpec = makeFakeGame("RATTATA") +local netE, netF = Net.loopbackPair() +local packedE = Protocol.packParty(gameE.save.party) +local packedF = Protocol.packParty(gameF.save.party) +local specSeed = 13579 + +local specInbox = {} +local origSendE, origSendF = netE.send, netF.send +netE.send = function(self, msg) + origSendE(self, msg) + table.insert(specInbox, { type = "spectate", side = "host", msg = msg }) +end +netF.send = function(self, msg) + origSendF(self, msg) + table.insert(specInbox, { type = "spectate", side = "guest", msg = msg }) +end +local specNet = { + closed = false, + update = function() end, + poll = function() + local msgs = specInbox + specInbox = {} + return msgs + end, +} + +local battleE = LinkBattle.newHost(gameE, netE, { + myParty = packedE, theirParty = packedF, theirName = "BLUE", seed = specSeed, +}) +local battleF = LinkBattle.newGuest(gameF, netF, { + myParty = packedF, theirParty = packedE, theirName = "RED", seed = specSeed, +}) +local battleSpec = LinkBattle.newSpectator(gameSpec, specNet, { + hostParty = packedE, guestParty = packedF, hostName = "RED", guestName = "BLUE", + seed = specSeed, +}) +check(battleSpec ~= nil, "spectator battle constructs") +eq(battleSpec.spectating, true, "spectator battle is marked as such (not a reportable match)") + +local resE, resF = nil, nil +battleE.onFinish = function(r) resE = r end +battleF.onFinish = function(r) resF = r end +gameE.stack:push(battleE) +gameF.stack:push(battleF) +gameSpec.stack:push(battleSpec) + +local guard3 = 0 +while (resE == nil or resF == nil) and guard3 < 60000 do + guard3 = guard3 + 1 + Input.pressed = { a = true } + gameE.stack:update(1 / 60) + gameF.stack:update(1 / 60) + gameSpec.stack:update(1 / 60) +end +check(resE ~= nil and resF ~= nil, "spectator test: the underlying match completes") +eq(battleSpec.player.mon.hp, battleE.player.mon.hp, + "spectator's host-side HP matches the host's own view") +eq(battleSpec.enemy.mon.hp, battleF.player.mon.hp, + "spectator's guest-side HP matches the guest's own view") + -- ---------------------------------------------------------------- mod link compat -- Self-contained like the tests/mod_*.lua suites: own bootstrap and -- assert-based checks, so it lands here as a single pass/fail line. From 35f5c9513caae1182f835af5322d58e2858fbf76 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 24 Jul 2026 13:30:54 -0400 Subject: [PATCH 3/3] Update BattleState.lua --- src/battle/BattleState.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 951a49ed..3d1b84ad 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -2393,10 +2393,17 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- PP: not for continuations, struggle, called moves, or (under -- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever -- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm). + -- That rule doesn't apply in a link battle: "the enemy" there is a real + -- human peer independently tracking their own PP the normal way, not an + -- AI Gen 1 never bothered to decrement -- applying it made each side + -- silently skip decrementing the OTHER side's PP for the move it just + -- used, so both simulations' party state diverged by exactly 1 PP on + -- the very first move either side made, failing the lockstep hash + -- check turn 1 of literally every link battle. local isContinuation = releasing or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) or moveInst == user.rageMove - local enemyUnlimited = not user.isPlayer + local enemyUnlimited = not user.isPlayer and self.kind ~= "link" and self.ruleset and self.ruleset.enemyUnlimitedPP if not isContinuation and not moveInst.struggle and not isCalled and not enemyUnlimited then