diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 143f5d6c..e6390c1d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -812,7 +812,7 @@ jobs: else .commit.author.name end' 2>/dev/null \ | grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)" - notes="Download the correct version for your computer below." + notes="Download the right version for your device below." if [ -n "$closed" ]; then notes+=$'\n\n## Issues closed\n\n'"$closed" fi diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index 7a2f525d..2d59d5e4 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -130,13 +130,23 @@ keeps validating with the value it has shipped since before the taxonomy existed. A translation may also set `"language": true` in the manifest. That is the -one claim online play acts on: an install running nothing but verified -translations may take an ONLINE MATCH or a TOURNAMENT instead of being -asked to restart vanilla. The claim is checked, not taken -- the mod +one claim online play acts on. Online rooms, spectating and tournaments live +in the launcher's ONLINE tab, and a match is played in an **arena boot**: the +game starts with the mod loader in `disableAll` (nothing runs) or `cartOnly` +(one sealed cart and nothing else), never in the enable state the player has +saved. A verified translation is the single exception `disableAll` carves +out, so an install running nothing but translations still enters rooms and +tournaments. The claim is checked, not taken -- the mod qualifies only if every record it writes lands in `text`, `strings` or `font`, it wraps no hook, subscribes to no event and requests no permission. Anything else and it is an ordinary content mod that happens to -ship text. +ship text, and it simply does not load in an arena. + +A mod is not shut out of link play by any of that: it just cannot ride the +launcher's rooms. `LinkState.newFromSession(game, transport, mode, isHost, +opts)` still adopts an already-paired transport of the mod's own and runs a +LAN-style battle or trade on it, hello and fingerprint check intact (see +`docs/modding.md`). ### 4. `games` (and the legacy `gen2compat`) diff --git a/README.md b/README.md index 720e12f4..225ee79b 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,19 @@ famous bugs included: | `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ | | `randMin` / `randMax` | Damage random factor 217-255, same as faithful | +## Online play + +The launcher has an **ONLINE** tab. Connect once and you get a lobby of who +else is around, with what game and what rules: host a battle or join one, +watch any live match or tournament as a spectator, run a bracket where +everyone not playing watches the match that is on, or trade Pokemon between +save files, yours or someone else's. Picking a battle opens the game +straight into it, no intro and no overworld, and drops you back in the tab +when it ends. Every room names exactly what it runs, so both sides are on +the same engine, the same version and the same ruleset: vanilla, or a sealed +custom cart that both players have installed. The in-game LINK menu is still +there and is still local network only. + ## Running From Source Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index c47a1ad4..2df735c2 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -782,7 +782,7 @@ end -- buying again means talking to the counter again (#623). local function prizeCounter(window) return function(game, ow, npc, done) - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") local Commands = require("src.script.Commands") local TextBox = require("src.render.TextBox") local t = game.data.text @@ -793,28 +793,25 @@ local function prizeCounter(window) t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) return end - -- ExchangeCoinsForPrizesText plays before the prize window opens. game.stack:push(TextBox.new(game, t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", function() - local items = {} + local rows = {} for _, p in ipairs(prizeWindow(window)) do - local label - if p.kind == "mon" then - label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) - else - label = game.data.items[p.item].name - end - table.insert(items, - { label = label, right = tostring(p.cost), value = p }) + rows[#rows + 1] = { + name = (p.kind == "mon") + and game.data.pokemon[p.species].name + or game.data.items[p.item].name, + cost = p.cost, + prize = p, + } + end + local function close() + game.stack:pop() + game.stack:pop() end - -- NoThanksText (data/events/prizes.asm) sits under the three prizes - table.insert(items, { label = "NO THANKS" }) - local list - -- close the window first: every ending in HandlePrizeChoice leaves - -- the menu for good, and the closing line belongs over the map local function finish(msg) - list:close() + close() game.stack:push(TextBox.new(game, msg, done)) end local function buy(p) @@ -844,40 +841,38 @@ local function prizeCounter(window) game.save.coins = game.save.coins - p.cost -- no thank-you line: HereYouGoText is unreferenced in the asm, -- which just redraws the coin box (PrintPrizePrice) and returns - list:close() + close() done() end - list = ListMenu.new(game, "PRIZES (COINS)", items, { - footer = ("COINS %d"):format(game.save.coins or 0), - onChoose = function(item) - local p = item.value - if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice) - list:close() - done() - return - end - local name = (p.kind == "mon") - and game.data.pokemon[p.species].name - or game.data.items[p.item].name - -- SoYouWantPrizeText names the prize out of wNameBuffer, which - -- is not one of TextBox's RAM tokens, so fill it in here - local ask = (t._SoYouWantPrizeText - or "So, you want\n{RAM:wNameBuffer}?") - :gsub("{RAM:wNameBuffer}", name) - game.stack:push(TextBox.new(game, ask, nil, { - choice = function(yes) - if not yes then - finish(t._OhFineThenText or "Oh, fine then.") - return - end - buy(p) - end, - })) - end, - onCancel = done, - }) - game.stack:push(list) - end)) + game.stack:push(TextBox.new(game, + t._WhichPrizeText or "Which prize do\nyou want?", nil, { + instant = true, + stay = { onShown = function() + game.stack:push(PrizeCounter.new(game, rows, { + onCancel = function() + close() + done() + end, + onPick = function(row) + local p = row.prize + local ask = (t._SoYouWantPrizeText + or "So, you want\n{RAM:wNameBuffer}?") + :gsub("{RAM:wNameBuffer}", row.name) + game.stack:push(TextBox.new(game, ask, nil, { + instant = true, + choice = function(yes) + if not yes then + finish(t._OhFineThenText or "Oh, fine then.") + return + end + buy(p) + end, + })) + end, + })) + end }, + })) + end, { instant = true })) end end diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 8069bf09..29192d9f 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -391,30 +391,84 @@ local DRINK_PRICES = { { id = "LEMONADE", price = 350 }, } +-- engine/events/vending_machine.asm:56-65 +local function deliveryRumble(game, onDone) + local left, wait = 60, 2 + return { + draw = function() end, + update = function(self) + wait = wait - 1 + if wait > 0 then return end + wait = 2 + require("src.core.Sound").play(game.data, "Push_Boulder") + left = left - 1 + if left <= 0 then + game.stack:pop() + onDone() + end + end, + } +end + +-- engine/events/vending_machine.asm local function vendingMachine(game, ow, npc, done) - local ListMenu = require("src.ui.ListMenu") + local t = text(game) + local Menu = require("src.ui.Menu") + local Font = require("src.render.Font") + local money = function() return game.save.money end + local function closeSession(msg, menuPopped) + if not menuPopped then game.stack:pop() end + game.stack:pop() + push(game, msg, done, { money = money }) + end + local function notThirsty() + closeSession(t._VendingMachineText7 or "Not thirsty!", true) + end + local function buy(d) + if game.save.money < d.price then + closeSession(t._VendingMachineText4 or "Oops, not enough\nmoney!") + return + end + if not require("src.inventory.Bag").add(game.save, d.id, 1, game.data) then + closeSession(t._VendingMachineText6 or "There's no more\nroom for stuff!") + return + end + game.stack:push(deliveryRumble(game, function() + game.save.money = game.save.money - d.price + closeSession(fill(t._VendingMachineText5 + or "{RAM:wStringBuffer}\npopped out!", + { ram = game.data.items[d.id].name })) + end)) + end local items = {} for _, d in ipairs(DRINK_PRICES) do - table.insert(items, { - value = d, label = ("%s ¥%d"):format(game.data.items[d.id].name, d.price), - }) + items[#items + 1] = { + label = game.data.items[d.id].name, + keepOpen = true, + onSelect = function() buy(d) end, + } end - game.stack:push(ListMenu.new(game, "VENDING MACHINE", items, { - onChoose = function(item, list) - local d = item.value - if game.save.money < d.price then - push(game, "Not enough\nmoney.") - return - end - if not require("src.inventory.Bag").add(game.save, d.id, 1) then - push(game, "You have no room\nfor it!") - return - end - game.save.money = game.save.money - d.price - push(game, ("%s\npopped out!"):format(game.data.items[d.id].name)) - end, - onCancel = done, - })) + items[#items + 1] = { label = "CANCEL", onSelect = notThirsty } + push(game, t._VendingMachineText1 or "A vending machine!\nHere's the menu!", + nil, { + money = money, + stay = { prompt = true, onShown = function() + local menu = Menu.new(game, items, { + tx = 0, ty = 3, tw = 14, th = 10, itemY = 2, + noWrap = true, + onCancel = notThirsty, + }) + menu.draw = function(self) + Menu.draw(self) + love.graphics.setColor(0, 0, 0, 1) + for i, d in ipairs(DRINK_PRICES) do + Font.draw(("¥%d"):format(d.price), 9 * 8, (6 + (i - 1) * 2) * 8) + end + love.graphics.setColor(1, 1, 1, 1) + end + game.stack:push(menu) + end }, + }) end -- drink -> TM (CeladonMartRoof.asm .gaveFreshWater/.gaveSodaPop/ diff --git a/docs/architecture.md b/docs/architecture.md index 09bbc60d..92e55b6d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,6 +61,48 @@ the same core data and graphics into the source tree for verification. | ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists | | | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` | +## Online play + +Online play is owned by the launcher, not by a running game. `src/online/` +holds one persistent relay connection for the life of the process +(`main.lua` pumps it every frame, whether the launcher or a game is on +screen), and a battle is run by **arena booting** the game: no splash, no +title, no overworld, just the lockstep battle, then straight back to the +launcher with the room still selected. In-game link (`src/link/LinkState.lua`) +stays as it was and is LAN only. The relay lives in its own repo, +`../pokeserver`; `docs/link-security.md` describes protocol v2 and what it +does and does not guarantee. + +- `src/online/Client.lua` - process singleton: connection, heartbeat, + reconnect with session resume, the inbox, and the local model (presence, + room, match, tournament). `Client.roomSession()` hands `LinkBattle` the + same shape a LAN `Session` does. +- `src/online/Protocol2.lua` - relay protocol v2 message builders; the + matching schemas live with the v1 ones in `src/link/Wire.lua`. +- `src/online/ArenaData.lua` - computes an ArenaProfile (engine, version, + engine/api version, fingerprint, ruleset, vanilla or sealed cart) headless + by mounting a version's cache, and compares two profiles + (`equal`, `describeMismatch`). +- `src/online/ArenaBoot.lua` - the ArenaSpec: profile, role, slot, team, + seed, parties, session, `onDone`; plus the battle options a spec turns + into. +- `src/online/TeamPick.lua` - headless slot read, rule validation and party + packing. `src/online/Convert.lua` - Gen 1 <-> Gen 2 mon conversion with + Time Capsule refusals. `src/online/Trade.lua` - launcher-side trade with a + two-file commit. `src/online/OnlineSprites.lua` - the cached party icons + and front sprites those pickers draw. +- Boot path: `main.lua` `bootGame(version, cartId, { arena = spec })` -> + `Game:load` / `Game2:load` skip the intro and push + `src/ui/ArenaState.lua` or `src/ui/gen2/ArenaState.lua`, which build + `src/link/LinkBattle.lua` (Gen 1) or `src/link/LinkBattle2.lua` (Gen 2), + host, guest or spectator, and return the result. +- Mods in an arena: `Loader:load(data, { mode = ... })` runs `disableAll` + (verified translations only) for a vanilla arena and `cartOnly` for a + sealed-cart one, without touching the player's saved enable state. +- `src/import/OnlinePanel.lua` plus `src/import/online/` are the launcher's + ONLINE tab: a small stack of screens (home, play, setup, room, watch, + tournaments, trade) drawn from `Client`, `ArenaData` and `TeamPick`. + ## Map scripts Map-specific behavior lives in `data/scripts/.lua`, keyed by the diff --git a/docs/link-security.md b/docs/link-security.md index e4daf858..5a2551b3 100644 --- a/docs/link-security.md +++ b/docs/link-security.md @@ -4,6 +4,106 @@ Link play is the only part of this game that reads bytes written by somebody else. This is what it defends against, what it does not, and where each guarantee lives. +## Where link play lives now + +Two separate things, one wire stack: + +- **In-game link is LAN only.** `src/link/LinkState.lua` is the START menu / + Cable Club path over ENet on the local network. It has no relay, no room + codes and no bracket: the in-game ONLINE and TOURNAMENT rows are gone and + `src/link/Tournament.lua` no longer exists. +- **Online play lives in the launcher**, over relay protocol v2 against + `../pokeserver`. `src/online/Client.lua` holds one persistent TCP + connection for the life of the process (`main.lua` pumps it every frame, + whether the launcher or a booted game is on screen). Presence, rooms, + spectators and tournaments are objects addressed inside that one + connection. A tournament is a room of rooms: the relay creates each + bracket match as an ordinary v2 room and attaches every non-playing + entrant to it as a spectator. +- A battle never runs in the launcher. The launcher **arena boots** the + game (`src/online/ArenaBoot.lua` -> `Game:load` / `Game2:load` -> + `src/ui/ArenaState.lua` or `src/ui/gen2/ArenaState.lua`), which skips + splash, title and overworld, runs `LinkBattle` / `LinkBattle2` on the room + session, and returns to the launcher with the room still selected. + +## The arena profile + +The compatibility contract is one table, computed in the launcher without +booting a game (`src/online/ArenaData.lua`), attached to every lobby entry +and room, and checked twice: + +| Field | What it pins | +| --- | --- | +| `engine` | 1 or 2: which battle engine the room runs | +| `version` | which generated cache boots (`red` ... `crystal`) | +| `engineVersion`, `apiVersion` | exact string match, no skew | +| `fingerprint` | `Fingerprint.compute` over the *arena* dataset | +| `rulesetId` | the host-dealt rulebook (see below) | +| `kind` | `vanilla` (all mods off) or `cart` (a sealed cart) | +| `cart` | `{ id, version, hash }`, the `CartManifest.hash` of that cart | +| `rule` | party size and level bounds, not part of the identity | + +**The relay enforces it on join.** `room_join` walks +`PROFILE_MATCH_FIELDS` (`../pokeserver/relay.js:66`) in order -- `engine`, +`version`, `engineVersion`, `fingerprint`, `rulesetId`, `kind`, `cart.hash` +-- and refuses the first field that differs with +`join_error { reason: "profile_mismatch", field, detail }`. `room_ready` runs +the party against `rule` the same way and answers `party_ineligible`. +Spectators are held to the profile too: a spectator runs the same engine to +render the battle. + +**The client checks it before it will join or boot.** The ONLINE tab runs +`ArenaData.equal` (everything except `rule`) against its own profile for +every lobby row and greys out the ones it cannot play, with +`ArenaData.describeMismatch` naming the first differing field so the reason +is *what* differs rather than "incompatible" +(`src/import/OnlinePanel.lua`). The battle it then boots is constructed +with `strict = true` (`ArenaBoot.battleOpts`), so a mon that cannot be +rebuilt from real species data refuses the battle instead of being +approximated into a desync. + +A `vanilla` arena boots the mod loader in `disableAll` mode and a `cart` +arena in `cartOnly` (`Loader.ARENA_MODES`, `src/mods/Loader.lua:618`). +Neither reads or writes `options.safeMode` or `options.modsByVersion`, so an +arena never disturbs the player's own enable state. `disableAll` keeps +verified translations and only those: `_arenaDisableAll` keeps a mod that +declares `language = true`, `affects_link = false` and no permissions, and +`_arenaVerifyTranslations` then re-checks the survivors through +`Handshake.onlineBlockers` and rolls back anything that does not qualify. +`cartOnly` refuses to boot when the cart plan is not `enforced` or the +slot's seal is broken; `sealed+` and `open` carts are refused outright, +because a player-togglable pin set is not a fixed identity. + +## Desync: the ruleset and the RNG counter + +Nearly every reported desync was turn 1 or 2 with **identical engine +versions on both sides**. The cause was local: `BattleState` picked its +ruleset from `game.save.options.ruleset`, an ordinary OPTIONS row, and +`gen1_faithful` spends one RNG draw on the 1/256 miss that `modern_clean` +does not. Both machines share one Park-Miller stream, so a single skipped +draw offsets it permanently and the very next number is the damage factor. + +The fix is three parts, all of them in the wire: + +- The **host deals the ruleset**. `LinkBattle` takes `opts.ruleset` and + overwrites `self.ruleset` from it the way it already overwrote `rng` + (`src/link/LinkBattle.lua:275`, spectator at `:824`). In an arena that id + comes from the profile. +- The hello **carries** it (`Handshake.ruleset`, `src/link/Handshake.lua:180`) + and `checkCompat` refuses a mismatch as `ruleset_skew`, so a LAN pairing + cannot start split either. +- The rulesets registry is part of the **Gen 1 fingerprint surface** + (`src/link/Fingerprint.lua:319`), so a retuned rulebook is caught even when + both sides name the same id. + +The other half of the diagnosis was that the state signature could not see a +draw-count split, so a desync was blamed on the wrong turn. The RNG draw +counter is now hashed into the `actives` component +(`src/link/LinkBattle.lua:196`; Gen 2 stamps it on the battle at +`src/link/LinkBattle2.lua:256` and hashes it in +`src/battle/gen2/Battle.lua:5173`), so the two sides disagree on the exact +turn the streams part. + ## The boundary Everything a peer or the relay sends arrives as one JSON object per line. @@ -21,6 +121,11 @@ right by construction. A message with no schema (a mod's, or a future build's) keeps a bounded, scalar-only copy of its payload instead of being dropped. +The v2 types go through the same door: `SCHEMAS.lobby_*`, `room_*` and +`tour_*` live beside the v1 ones in `Wire.lua`, and `room_msg` sanitizes its +inner lockstep message as well as its envelope, so `Client.roomSession()` +hands `LinkBattle` the same shape a LAN `Session` does. + A message that fails its schema is **dropped and logged**, never fatal. Latching a terminal failure would hand a hostile peer a cheaper disconnect than sending nothing at all. @@ -38,17 +143,22 @@ it without a Session (the mod API, `tests/`). Assume something still gets through: -- `Game:step` pcalls the link pump, and pcalls `stack:update` **only - while a link session is active**. On a throw, `Game:breakLink` closes - the connection, unwinds to the overworld and says "The link was - broken." Outside link play the stack is unguarded on purpose: a blanket - pcall would swallow real engine bugs and leave the game silently wrong - instead of loudly broken. -- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer - that never sends a newline ends as a clean disconnect. -- `Json.decode` refuses documents nested past 64 levels, and takes an - optional length cap that the link path passes and the mod-manifest path - does not. +- `Game:step` pcalls the link pump, and xpcalls `stack:update` **only + while a link session is active** (`src/core/Game.lua:329`). A throw + reaches `Game:breakLink` (`:276`), which closes the connection, unwinds + to the overworld and says so. Transport failure and a caught Lua error are + distinguished on purpose: the player is told "The link was broken." for + one and "Something broke during the link." for the other, and the log + line differs, so an engine bug inside a link battle no longer reads as a + network fault. Outside link play the stack is unguarded on purpose: a + blanket pcall would swallow real engine bugs and leave the game silently + wrong instead of loudly broken. +- `Net` caps a line at 256KB (`Net.MAX_LINE`) and its per-frame read at + 512KB (`Net.MAX_RX_PER_FRAME`), so a peer that never sends a newline ends + as a clean disconnect. +- `Json.decode` refuses documents nested past 64 levels (`Json.MAX_DEPTH`), + and takes an optional length cap that the link path passes and the + mod-manifest path does not. ## The relay (`../pokeserver`) @@ -57,48 +167,106 @@ Assume something still gets through: `server.js` installs `uncaughtException`/`unhandledRejection` handlers: one bad packet must never take every live match down with the process. - Line buffers are capped, lines per second are capped, connections per - IP and in total are capped, and an unbound connection that never hosts - or joins is swept after 30s. -- `SERVER_ONLY` is the set of message types the server is the only - legitimate author of (`peer_gone`, `bracket_update`, `match_start`, - `tournament_over`, `spectate`, ...). A peer that sends one has them - dropped rather than forwarded, so a bracket opponent cannot forge a - tournament result or fake "your opponent left". -- Trainer names are reduced to a printable subset and capped at the same - 10 characters the game enforces, on the way in, because they are + IP and in total are capped, and an unbound connection that never hosts, + joins or binds as a lobby is swept after 30s. +- `SERVER_ONLY` (`relay.js:76`) is the set of message types the server is + the only legitimate author of. It covers the v1 names (`peer_gone`, + `bracket_update`, `match_start`, `tournament_over`, `spectate`, ...) and + every v2 one (`lobby_welcome`, `lobby_list`, `lobby_delta`, `room_state`, + `room_replay`, `room_deadline`, `room_result`, `room_closed`, `tour_state`, + `tour_match`, `tour_match_spectate`, `tour_bye`, `tour_deadline`, + `tour_over`, `tour_closed`). A peer that sends one has it dropped rather + than forwarded, so a bracket opponent cannot forge a result or fake "your + opponent left". +- `room_msg` only carries an inner `msg.type` from a fixed set (`hello`, + `party`, `action`, `hash`, `replace`, `bye`, `forfeit`), only from a + seated player, and only while the room is `battling`. Its `seq` must + rise, so a resumed client's replayed tail is idempotent. +- Names, notes, room codes and profile strings are reduced to a printable + subset and capped on the way in (10 characters for a trainer name, 16 for + a lobby display name, 40 for an advertisement note), because they are rendered by the dashboard and broadcast to every participant. `pokeserver/test/hostile.js` is the regression net for all of that. +### Identity: tickets + +Going online verified takes a ticket, not a credential. +`POST /lobby/ticket` on the HTTPS port (`lobby.js`) authenticates with the +usual sync headers, mints 32 hex characters bound to +`{ account, displayName }`, valid for 60 seconds and **single use**, and the +client presents that on the plaintext relay in `lobby_hello`. The relay +redeems it in process (both modules live in `server.js`) and never sees the +device token: nothing on port 7778 can be replayed into an account. The +ticket store is memory only, so a restart invalidates every outstanding +ticket and clients simply mint another. A client with no ticket still +connects and still plays, as a guest, listed `verified: false`. + +### Heartbeat, resume and the replay bound + +- `ping`/`pong` runs both ways. The relay pings a connection idle for 20s + and drops one idle for 60s, on top of TCP keepalive; the client's own + heartbeat interval arrives in `lobby_welcome`. +- A dropped socket keeps its seat for **2 minutes**. A new connection sends + `resume { session, ack }` and gets its room, its match and the traffic it + missed back. `room_replay` also names `yourSeq`, the highest `seq` the + relay logged from that client, so the client re-sends only its own lost + tail. +- The room's message log is bounded at **512 messages or 256 KiB**, + whichever comes first, since the last `room_ready` pair. A spectator that + arrives while the log is intact gets `room_replay` and then live traffic; + one that arrives after the bound already dropped messages is refused with + `spectate_late` rather than being fed a battle it cannot reconstruct. + ## What is NOT defended -**Party legality is trust-the-client.** Online play meets strangers, and +**Party legality is trust-the-client.** Online rooms, spectating and +tournaments live in the launcher, where play meets strangers, and `Handshake.onlineAllowed` is a Lua function in the same VM the mods load into. It cannot be made tamper-proof in-process, and pretending otherwise would only cost honest mod authors. What lockstep and `Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that a cheater cannot invent stats, moves, or a shiny: every derived value is -rebuilt locally from real species data. They can send a legal party they -farmed or edited. That is the honest boundary. +rebuilt locally from real species data. The relay checks party size and +level bounds against the room's `rule`, and that is the only rule it +enforces. A player can still send a legal party they farmed or edited. +That is the honest boundary. -What the relay does instead is **observe and record**. It already sees -every `hello`, so it keeps each connection's self-reported -`engineVersion`, `fingerprint` and `linkModified`, compares the two sides -of a room or a live tournament match, and logs and surfaces a -`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard. -A patched client can still lie; what it cannot do is lie without the -tournament organizer having a record of it. +**Match results are trust-the-client too**, and this is where the relay +stopped being silent. Both sides report; agreement resolves the match, a +lone report waits out a grace window, a lone forfeit resolves at once, and +a stalled match resolves on its deadline. Two **disagreeing** reports are +counted as a dispute (`totals.disputes` and the room's or tournament's own +counter, both in the dashboard snapshot), logged with both sides' reports +verbatim, and settled **deterministically**, never by coin flip: a forfeit +or a gone player decides it, otherwise the earliest report wins +(`roomTiebreak`, `relay.js:1761`). The outcome carries `how` -- +`reported`, `agreed`, `forfeit`, `timeout`, `disputed`, `disconnect` or +`closed` -- so a tournament organizer can see which matches were argued. + +The same is true of the arena profile. A patched client can lie about its +fingerprint or its cart hash; what it cannot do is lie without the relay +having a record of it, since the relay sees every profile and compares the +two sides of every room. A sealed cart's hash proves the manifest, the +pins' sha256 prove the archives, and the fingerprint proves the merged +data -- against an honest client. Client-side attestation is deliberately not built. This is an open-source Lua game: it would be theater, and it would break honest mods. **The relay has no TLS.** Port 7778 is plaintext, so party contents, -trades and trainer names are visible to anyone on the network path. There -is nothing secret in a Pokemon party, but it is a real property of the -system and not an oversight. Fixing it means a TLS terminator in front of -the relay and a client that speaks it, which is a version break for every -shipped build. +trades and trainer names are visible to anyone on the network path. Tickets +keep account credentials off it (above), and there is nothing secret in a +Pokemon party, but it is a real property of the system and not an +oversight. Fixing it means a TLS terminator in front of the relay and a +client that speaks it, which is a version break for every shipped build. + +**Presence is not durable.** The lobby, every room and every outstanding +ticket live in the relay process. A restart clears them and clients +reconnect and re-advertise within one heartbeat. Nothing about a match is +persisted, so nothing about a match can be audited after the fact beyond +the log lines. **The dashboard has no default password.** `DASHBOARD_PASSWORD` is required; with it unset the relay runs and the dashboard simply does not @@ -107,14 +275,50 @@ IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`). ## Tests - luajit tests/link_hostile.lua every message type x every wrong type - luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode - luajit tests/run_link_tests.lua both of the above, plus the rest - cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile + luajit tests/link_hostile.lua v1 message types x every wrong type + luajit tests/link_desync_fuzz.lua fuzz: baseline, mutation, ruleset split + luajit tests/run_link_tests.lua the above, plus online_client and link2_* + cd ../pokeserver && npm test relay smoke, brackets, lobby, tour -`tests/link_hostile.lua` builds its corpus from a template per message -type, replaces each field (and several nested ones) with every wrong Lua -type, and drives the survivors through the real trade session, a real -lockstep battle, a real spectator battle, and the tournament screen -**including its draw** -- because the two nastiest payloads are -delayed-fuse ones that crash on render rather than on receipt. +What guards which claim: + +- **Ruleset desync**: `run_link_tests.lua` asserts the draw-count difference + between `gen1_faithful` and `modern_clean` directly, then runs a loopback + battle where the guest's own `modern_clean` OPTIONS row is overridden by + the host's dealt ruleset. `link_desync_fuzz.lua`'s ruleset-split mode + deliberately configures the two sides differently and fails if the two + `LinkBattle`s ever end up holding different rulebooks. +- **Wire hostility**: `link_hostile.lua` builds a corpus from a template per + v1 message type, replaces each field (and several nested ones) with every + wrong Lua type, and drives the survivors through the real trade session, a + real lockstep battle and a real spectator battle, **including their draws** + -- because the two nastiest payloads are delayed-fuse ones that crash on + render rather than on receipt. The v2 envelopes are covered on the client + side by `tests/online_client.lua`, which sanitizes oversized `lobby_list`, + `room_replay`, `room_state` and `tour_state` payloads and asserts a stream + of malformed server messages is counted and dropped without throwing or + changing connection state. +- **Client, rooms, resume, tournaments**: `tests/online_client.lua` (run from + `run_link_tests.lua`) covers the handshake, lobby, advertising, rooms, + `match_start`, the room session, reconnect and resume replay, spectating, + results, a full tournament run and the lockstep path, against a fake relay + and, when a socket library is present, a real one. +- **Gen 2 lockstep**: `tests/link2_lockstep.lua` and + `tests/link2_desync_fuzz.lua`. +- **Relay**: `../pokeserver/test/lobby.js` (bind, presence, rooms, + spectating, the lockstep stream, deadlines, resume, a real HTTP ticket + from a real sync account, and a v1 room in the same process), + `test/tour.js` (a 5-player v2 bracket with byes, child rooms, spectator + fan-out, the server-side shot clock, disconnects and creator handoff), + `test/hostile.js`, plus `test/smoke.js`, `test/keepalive.js`, + `test/tournament16.js` and `test/tournament5.js` for the v1 path. +- **End to end**: the LÖVE drivers. `tests/drivers/online_relay_smoke.lua` + runs two `Client`s through a real pokeserver over TCP: room create, join, + ready, a full Gen 1 `LinkBattle` over `roomSession()`, both reports and the + relay's `room_result`. `tests/drivers/online_tour_smoke.lua` runs five + clients through a v2 tournament including a bye, an outside spectator, a + mid-battle socket kill with resume and replay, and a forfeit. + `tests/drivers/arena_boot_loopback.lua` and + `arena_boot_gen2_loopback.lua` (with their `*_spec.lua` payloads) boot + straight into an arena battle, play it against a headless guest and + screenshot the battle and the launcher it returns to. diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index ddc00b15..ceb2950b 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -676,6 +676,17 @@ the same letter raises a second event. | `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list | | `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean | | `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender | +| `battle.enemy_switch_or_item` | `Battle:enemyTrySwitchOrItem` | called `(battle)` | `true` when the foe spent the turn rotating or drinking | + +`battle.enemy_switch_or_item` is the companion to the shared +`battle.enemy_action`: that one rewrites which MOVE the foe picks, this one +decides whether the foe spends the whole turn on a rotation or an item instead +of moving at all. Red has no such branch, which is why the name is new. Return +a boolean to answer "the turn was spent" the way vanilla does, or an action +table -- `{ kind = "switch", index = n }` or `{ kind = "item", item = id }` -- +to have the engine perform it. A link battle supplies both sides' actions +directly (`Battle:takeLinkTurn`) and consults neither this hook nor +`battle.enemy_action`, so a mod cannot desync a lockstep match through either. `held_item.trigger` is one hook over eight call sites, because on the cart those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and @@ -737,7 +748,9 @@ change in the Gen 2 module first and a routing row second: - `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in `data.gen2Maps` and the VM's own tables. - `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers. -- `link_fields`: link play is Gen 1 only. +- `link_fields`: gated until the Gen 2 mon wire format carries mod fields; + Gen 2 link battles exist (launcher arenas over `src/link/LinkBattle2.lua`) + but ship no extra mon fields yet. - `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM pointer, and a Lua row list merged into it is not something `src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in @@ -796,10 +809,10 @@ The list is much shorter than it was. What is outstanding, in descending value: when that selection is required. - `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its own yet. -- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The - Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a - Gen 2 surface and a cross-generation pairing is refused by name), but nothing - in `src/ui/gen2/` opens onto the protocol, so these raise nowhere. +- `link.*` and `trade.completed`: a Gold boot offers no in-game link menu. + Gen 2 battles run as launcher arenas (`src/ui/gen2/ArenaState.lua` over + `src/link/LinkBattle2.lua`), which raise `link.battle_ended`; trades happen + in the launcher, so `trade.completed` still raises nowhere in Gold. Four groups that used to sit here have since landed and moved to the shared table above: the frame seams (`render.compose` / `render.hud` / diff --git a/docs/new-features.md b/docs/new-features.md index f58475cc..e34d7f08 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -7,7 +7,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green * **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency * **Performance presets** and configurable FPS limits -* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow +* **Peer-to-peer LAN link play** for trades and battles between Red, Blue, and Yellow +* **Online lobby** in the launcher for battles, spectating and tournaments * **Persistent custom options** stored separately from game saves * **Optional widescreen battle layout** * **Mobile touch controls** with editable layouts, vibration, and orientation settings @@ -25,3 +26,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver * **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check` * **Followers** for mods, plus Gen 2-only registries and hooks +* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white or black surround) diff --git a/main.lua b/main.lua index 65cf1083..8f2ea95c 100644 --- a/main.lua +++ b/main.lua @@ -126,6 +126,17 @@ local Game, EditorApp, Importer, TouchEditor, Studio local launchedIntoGame = false local RELAUNCH_MARKER = "relaunch_to_launcher.txt" +local onlineClient, onlineClientResolved +local function onlineClientModule() + if onlineClientResolved then return onlineClient end + onlineClientResolved = true + local ok, mod = pcall(require, "src.online.Client") + if ok and type(mod) == "table" and type(mod.update) == "function" then + onlineClient = mod + end + return onlineClient +end + local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a -- coroutine that receives `Game` and yields once per @@ -357,15 +368,16 @@ function closeSkinStudio() end end -local function makeLauncher() +local function makeLauncher(launcherOpts) local RomImporter = require("src.import.RomImporter") local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" - return RomImporter.new(function(version, cartId) + return RomImporter.new(function(version, cartId, opts) Importer = nil - bootGame(version, cartId) + bootGame(version, cartId, opts) end, { launcher = true, forceImport = forceImport, + initialTab = launcherOpts and launcherOpts.initialTab or nil, onEditSave = openEditor, onEditTouchControls = openTouchControlsEditor, -- Skin Studio owns a touch-first layout as well as the desktop workspace. @@ -375,13 +387,14 @@ local function makeLauncher() }) end -local function returnToLauncher() +local function returnToLauncher(opts) if not Game then return end local GameVersion = require("src.core.GameVersion") local currentVersion = GameVersion.get() SessionLifecycle.endGameSession(Game) Game = nil + pcall(function() require("src.online.Trade").hostIsLive = nil end) autopilot = nil driverCo = nil -- Leave the cart's scope behind: the launcher's own settings and slots are @@ -403,10 +416,16 @@ local function returnToLauncher() love.window.setTitle(Version.title("Gen 1 Recompilation Project")) end - Importer = makeLauncher() + Importer = makeLauncher({ initialTab = opts and opts.tab or nil }) end -function bootGame(version, cartId) +local pendingLauncherReturn + +function bootGame(version, cartId, opts) + opts = opts or {} + pcall(function() + require("src.online.Trade").hostIsLive = function() return true end + end) -- The launcher hands us the chosen game (Red / Blue / Yellow / Gold); -- scripted and headless runs fall back to POKEPORT_VERSION, then Red. -- Set the active version and overlay its extracted cache BEFORE anything @@ -453,9 +472,14 @@ function bootGame(version, cartId) -- own service owner, which mounts src/world/gen2 (walk / warps / -- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1 -- wiring. + local arena = opts.arena + local loadOpts = { arena = arena, cartId = cartId } if GameVersion.generation() == 2 then Game = require("src.core.Game2").new() - Game:load() + if arena then + Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end + end + Game:load(loadOpts) else -- Gen1 Game is a module singleton. Always re-require after in-process -- EXIT GAME so a prior session cannot leave a table whose rawget(load) @@ -467,7 +491,10 @@ function bootGame(version, cartId) error("src.core.Game missing load after reload") end Game = gameMod - Game:load() + if arena then + Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end + end + Game:load(loadOpts) if os.getenv("POKEPORT_AUTOPILOT") then autopilot = require("tests.autopilot") end @@ -562,6 +589,18 @@ function love.load(args) local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil + local scriptedOpts = nil + local specPath = os.getenv("POKEPORT_ARENA_SPEC") + if specPath and os.getenv("POKEPORT_DRIVER") then + local chunk, chunkErr = loadfile(specPath) + if not chunk then error("POKEPORT_ARENA_SPEC: " .. tostring(chunkErr)) end + local spec = chunk() + if type(spec) ~= "table" then + error("POKEPORT_ARENA_SPEC must return an ArenaSpec table") + end + scriptedOpts = { arena = spec } + end + if scripted then if forceImport or not ready then -- The importer detects the dropped/loaded ROM's version by SHA-1 and @@ -572,12 +611,12 @@ function love.load(args) return end Importer = nil - bootGame(version or scriptedVersion) + bootGame(version or scriptedVersion, nil, scriptedOpts) end) if importPath then Importer:startPath(importPath) end return end - bootGame(scriptedVersion) + bootGame(scriptedVersion, nil, scriptedOpts) return end @@ -640,6 +679,14 @@ function love.update(dt) if editorMode then return EditorApp.update(dt) end if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end + local client = onlineClientModule() + if client then pcall(client.update, dt) end + if pendingLauncherReturn then + local opts = pendingLauncherReturn + pendingLauncherReturn = nil + returnToLauncher(opts) + return + end if Importer then return Importer:update(dt) end if not Game then return end diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 8490fa50..f41baae0 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -45,6 +45,7 @@ import android.Manifest; import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; +import android.app.UiModeManager; import android.content.Context; import android.content.ClipData; import android.content.DialogInterface; @@ -53,6 +54,7 @@ import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.pm.ApplicationInfo; import android.content.res.AssetManager; +import android.content.res.Configuration; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; @@ -112,6 +114,7 @@ public class GameActivity extends SDLActivity { // basename as its body, so RomImporter:focus can say so in the launcher // instead of leaving the player on "No ROM imported" (issue #442). private static final String PICK_ERROR_FILENAME = "pick_error.flag"; + private static final String PICK_CANCELLED_PREFIX = "cancelled:"; // Written after a direct required-import copy has been fully published. private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag"; // Step bridge (love.system.syncHealthSteps): pending-steps delivery @@ -566,19 +569,37 @@ public class GameActivity extends SDLActivity { * is. The picked file (if any) arrives later in onActivityResult, not * synchronously here. * - * API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT - * chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20 - * the stock DocumentsUI is unreliable -- it launches and then hands back - * RESULT_CANCELED with no data, which onActivityResult cannot tell apart - * from the player cancelling (#584). GET_CONTENT lets any installed file - * manager serve the pick, and both intents return the same content:// or - * file:// URI shapes, so the result path in onActivityResult stays - * picker-agnostic and unchanged. + * API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 and television devices use + * an ACTION_GET_CONTENT chooser instead. Below 19 OPEN_DOCUMENT does not + * exist, and on 19/20 the stock DocumentsUI is unreliable -- it launches + * and then hands back RESULT_CANCELED with no data, which onActivityResult + * cannot tell apart from the player cancelling (#584); Android TV ships no + * DocumentsUI at all and behaves the same way (#1535). GET_CONTENT lets any + * installed file manager serve the pick, and both intents return the same + * content:// or file:// URI shapes, so the result path in onActivityResult + * stays picker-agnostic and unchanged. * * @param destFilename basename under the app save identity (e.g. * picked_rom.gb, picked_mod.zip, picked_save.sav, or * picked_required_import.bin) */ + private static boolean isTelevision(Context context) { + if (context == null) return false; + try { + UiModeManager modes = + (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); + if (modes != null + && modes.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + } catch (Exception e) { + Log.d("GameActivity", "could not read ui mode: " + e.getMessage()); + } + PackageManager packages = context.getPackageManager(); + return packages != null + && packages.hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + private static boolean isDirectRequiredDestination(String relative) { if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false; String normalized = relative.replace('\\', '/'); @@ -635,7 +656,7 @@ public class GameActivity extends SDLActivity { } self.pendingPickFilename = normalizedDest; - if (android.os.Build.VERSION.SDK_INT >= 21) { + if (android.os.Build.VERSION.SDK_INT >= 21 && !isTelevision(self)) { Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); @@ -1499,6 +1520,16 @@ public class GameActivity extends SDLActivity { outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName); } + private static Uri pickedUri(int resultCode, Intent data) { + if (resultCode != RESULT_OK || data == null) return null; + if (data.getData() != null) return data.getData(); + ClipData clip = data.getClipData(); + if (clip != null && clip.getItemCount() > 0) { + return clip.getItemAt(0).getUri(); + } + return null; + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -1523,19 +1554,22 @@ public class GameActivity extends SDLActivity { return; } if (requestCode != FILE_PICKER_REQUEST_CODE) return; - if (resultCode != RESULT_OK || data == null || data.getData() == null) { + final String destName = pendingPickFilename != null + ? pendingPickFilename : PICKED_ROM_FILENAME; + Uri uri = pickedUri(resultCode, data); + if (uri == null) { Log.d("GameActivity", "file picker returned no file (cancelled?)"); + if (isTelevision(this)) { + writeSaveDirFlag(PICK_ERROR_FILENAME, PICK_CANCELLED_PREFIX + destName); + } return; } - - Uri uri = data.getData(); File destDir = saveIdentityDir(); if (!destDir.exists() && !destDir.mkdirs()) { Log.d("GameActivity", "could not create " + destDir); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); return; } - final String destName = pendingPickFilename != null - ? pendingPickFilename : PICKED_ROM_FILENAME; final boolean directRequired = isDirectRequiredDestination(destName); final File destFile; try { diff --git a/poison_flicker_bug1872.png b/poison_flicker_bug1872.png new file mode 100644 index 00000000..6c968f1e Binary files /dev/null and b/poison_flicker_bug1872.png differ diff --git a/src/battle/AnimPlayer.lua b/src/battle/AnimPlayer.lua index 051da07f..5a76e9a7 100644 --- a/src/battle/AnimPlayer.lua +++ b/src/battle/AnimPlayer.lua @@ -401,6 +401,12 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts) self.stepIndex, self.stepLeft = 1, 0 self.elapsed, self.eventCursor = 0, 1 + -- animations.asm:452-473 (#1881) + if not attackerIsPlayer then + if moveId == "AMNESIA" then moveId = "CONF_ANIM" + elseif moveId == "REST" then moveId = "SLP_ANIM" end + end + local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId] if not anim then self:warnOnce("move:" .. tostring(moveId), diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 4a65cec8..0754678e 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1426,6 +1426,14 @@ function BattleState:updateQueue() self.waitFrames = Timing.MOVE_ANIM_PRE return true end + -- animations.asm:431-437 + if item.anim and not item.animOffDelayed + and not self:animationsOn() and not BALL_ANIMS[item.anim] then + item.animOffDelayed = true + table.insert(self.queue, 1, item) + self.waitFrames = Timing.MOVE_ANIM_OFF + return true + end local mdef = item.anim and self.data.moves[item.anim] local anim = mdef and mdef.anim if item.anim == "POOF_ANIM" then @@ -4906,7 +4914,8 @@ function BattleState:openReplacementMenu() end self:restoreMimicked(self.player) local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) + self.player = makeBattler(self.data, mon, true, + self.kind ~= "link" and game.save or nil) clearTrapping(self.enemy) -- SendOutMon clears foe trap self:syncSides() Runtime.emit("battle.battler_switched", { diff --git a/src/battle/gen2/AnimRunner.lua b/src/battle/gen2/AnimRunner.lua index 599eccd5..99c15339 100644 --- a/src/battle/gen2/AnimRunner.lua +++ b/src/battle/gen2/AnimRunner.lua @@ -457,9 +457,14 @@ function Runner:step() if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end end if self.stopped then - -- BattleAnim_ClearOAM: unless the script asked to keep them, every object - -- goes at the end. - if not self.keepSprites then self.objects.oam = {} end + -- engine/battle_anims/anim_commands.asm:213 + if not self.keepSprites then + self.objects.oam = {} + else + for _, obj in ipairs(self.objects.oam) do + obj.palette = "PAL_BATTLE_OB_ENEMY" + end + end return false end return true diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index b3efe15b..4f570c58 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -885,8 +885,13 @@ function Battle:orderOf(playerMove, enemyMove) local playerClaw = playerEffect == "HELD_QUICK_CLAW" local enemyClaw = enemyEffect == "HELD_QUICK_CLAW" if playerClaw and enemyClaw then - if rand(self.random, 256) < enemyParam then return "enemy" end - if rand(self.random, 256) < playerParam then return "player" end + if self.mirrored then + if rand(self.random, 256) < playerParam then return "player" end + if rand(self.random, 256) < enemyParam then return "enemy" end + else + if rand(self.random, 256) < enemyParam then return "enemy" end + if rand(self.random, 256) < playerParam then return "player" end + end elseif playerClaw then if rand(self.random, 256) < playerParam then return "player" end elseif enemyClaw then @@ -897,7 +902,9 @@ function Battle:orderOf(playerMove, enemyMove) if playerSpeed ~= enemySpeed then return playerSpeed > enemySpeed and "player" or "enemy" end - return rand(self.random, 2) == 0 and "player" or "enemy" + local playerFirst = rand(self.random, 2) == 0 + if self.mirrored then playerFirst = not playerFirst end + return playerFirst and "player" or "enemy" end -- Gen 2 priority moves. data/moves/effects_priorities.asm keys off the move @@ -1377,6 +1384,7 @@ Battle.AI_FAIL_STATUSES = { -- engine/battle/effect_commands.asm:3615 function Battle:aiRandomFail(attacker, defender) + if self.linkBattle then return false end if self:sideOf(attacker) ~= "enemy" then return false end if self:volatile(defender).lockOn then return false end return rand(self.random, 256) < 64 @@ -3260,6 +3268,11 @@ function Battle:resolveFaints() end if (self.enemy.hp or 0) <= 0 then + if self.linkBattle and self.enemyFaintAnnounced == self.enemy then + self.faintInterrupt = true + return false + end + self.enemyFaintAnnounced = self.enemy self:emit({ kind = "faint", side = "enemy", text = (self.wild and "Wild " or "") .. self:monName(self.enemy) .. " fainted!" }) @@ -3287,6 +3300,11 @@ function Battle:resolveFaints() self:endBattle("win") return true end + if self.linkBattle then + self.pendingEnemySwitch = true + self.faintInterrupt = true + return false + end local previous = self.enemy self:clearVolatile(self.enemy) self.enemyIndex = nextIndex @@ -3579,6 +3597,7 @@ end -- IsAnyMonHoldingExpShare's `cp EXP_SHARE` does, and a fainted holder gets -- nothing (the pass loop skips fainted mons). function Battle:awardExperience(loser) + if self.linkBattle then return self:resetParticipants() end local def = self:speciesDef(loser) local participants = {} @@ -4183,6 +4202,25 @@ end -- a trainer's class decides how eager it is. Returns true when the turn was -- spent on the switch or the item. function Battle:enemyTrySwitchOrItem() + if not Runtime.wantsHook("battle.enemy_switch_or_item") then + return Battle.vanillaEnemySwitchOrItem(self) + end + local chosen = Runtime.call("battle.enemy_switch_or_item", function(battle) + return Battle.vanillaEnemySwitchOrItem(battle) + end, self) + if type(chosen) == "table" then + if chosen.kind == "switch" then + return self:switchEnemy(tonumber(chosen.index) or 0) + end + if chosen.kind == "item" then + return self:enemyUseItem(chosen.item) + end + return false + end + return chosen and true or false +end + +function Battle.vanillaEnemySwitchOrItem(self) if self.wild or not self.trainer then return false end local attributes = self.trainer.attributes if type(attributes) ~= "table" then return false end @@ -4229,39 +4267,7 @@ function Battle:enemyTrySwitchOrItem() }) if not trapped and target and Ai.shouldSwitch(attributes, score, self.random) then - self:clearVolatile(self.enemy) - -- AI_Switch prints EnemyWithdrewText BEFORE it farcalls EnemySwitch - -- (engine/battle/ai/items.asm:685), so a rotation announces the mon - -- coming OFF the field as well as the one coming on; without it a - -- trainer swapping between two of the same species looked like nothing - -- had happened. The line is skipped only when Pursuit hit the mon on - -- its way out, which this port has no analogue for yet. - local outgoing = self.enemy - self:emit({ kind = "message", - text = (self.trainer.name or "TRAINER") .. " withdrew " - .. self:monName(outgoing) .. "!" }) - self.enemyIndex = target - self.enemy = self.enemyParty[target] - -- AI_Switch (engine/battle/ai/items.asm:697) - self:resetParticipants() - -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove - -- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so - -- the mon coming IN starts from an empty area -- the same pair of clears - -- Battle:switch makes for the player's side. - self:clearVolatile(self.enemy) - self.stages.enemy = Battle.newStages() - self:emit({ kind = "send", side = "enemy", mon = self.enemy, - hp = self.enemy.hp or 0, status = self.enemy.status or false, - level = self.enemy.level, experience = self.enemy.experience, - text = (self.trainer.name or "TRAINER") .. " sent out " - .. self:monName(self.enemy) .. "!" }) - Runtime.emit("battle.battler_switched", { - battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, - previous = outgoing, - }) - self:breakTrapsOnSend(self.enemy) - self:spikesDamage(self.enemy) - return true + return self:switchEnemy(target) end -- AI_TryItem: only the trainer's highest-level mon is worth an item. @@ -4277,9 +4283,42 @@ function Battle:enemyTrySwitchOrItem() status = self.enemy.status, enemyTurns = self:volatile(self.enemy).turnsTaken or 0, }) + return self:enemyUseItem(item) +end + +-- (engine/battle/ai/items.asm:685), so a rotation announces the mon coming +function Battle:switchEnemy(index) + local mon = self.enemyParty and self.enemyParty[index] + if not mon or (mon.hp or 0) <= 0 or mon == self.enemy then return false end + self:clearVolatile(self.enemy) + local outgoing = self.enemy + local trainerName = (self.trainer and self.trainer.name) or "TRAINER" + self:emit({ kind = "message", + text = trainerName .. " withdrew " .. self:monName(outgoing) .. "!" }) + self.enemyIndex = index + self.enemy = mon + -- AI_Switch (engine/battle/ai/items.asm:697) + self:resetParticipants() + -- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove + self:clearVolatile(self.enemy) + self.stages.enemy = Battle.newStages() + self:emit({ kind = "send", side = "enemy", mon = self.enemy, + hp = self.enemy.hp or 0, status = self.enemy.status or false, + level = self.enemy.level, experience = self.enemy.experience, + text = trainerName .. " sent out " .. self:monName(self.enemy) .. "!" }) + Runtime.emit("battle.battler_switched", { + battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, + previous = outgoing, + }) + self:breakTrapsOnSend(self.enemy) + self:spikesDamage(self.enemy) + return true +end + +function Battle:enemyUseItem(item) if not item then return false end -- Consume it, so a trainer with one Potion cannot drink it every turn. - for index, id in ipairs(self.trainer.items or {}) do + for index, id in ipairs((self.trainer and self.trainer.items) or {}) do if id == item then table.remove(self.trainer.items, index) break end end local heal = Ai.HEAL_ITEMS[item] @@ -4294,8 +4333,9 @@ function Battle:enemyTrySwitchOrItem() self.enemy.status = nil self:volatile(self.enemy).confuseCount = nil end - self:emit({ kind = "message", text = (self.trainer.name or "TRAINER") - .. " used " .. item .. "!" }) + self:emit({ kind = "message", + text = ((self.trainer and self.trainer.name) or "TRAINER") + .. " used " .. item .. "!" }) return true end @@ -4409,7 +4449,7 @@ end -- { kind = "item", item = , target = n } (handled by the caller, which -- applies the effect and then calls this with kind = "item" so the enemy -- still gets its turn) -local function runTurn(self, action) +local function runTurn(self, action, enemyAction) if self.over then return self:takeEvents() end self.turn = self.turn + 1 action = action or { kind = "move" } @@ -4458,9 +4498,24 @@ local function runTurn(self, action) if action.kind == "item" then self:cancelBide(self.player) end -- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides - -- to rotate or drink a potion spends its whole turn on it. - local enemyActed = self:enemyTrySwitchOrItem() - local enemyMoveId = (not enemyActed) and self:enemyMove() or nil + local enemyActed, enemyMoveId + if enemyAction then + if enemyAction.kind == "switch" then + self:switchEnemy(tonumber(enemyAction.index) or 0) + enemyActed = true + elseif enemyAction.kind == "item" then + self:enemyUseItem(enemyAction.item) + enemyActed = true + elseif enemyAction.kind == "move" then + enemyActed = false + enemyMoveId = enemyAction.move + else + enemyActed = true + end + else + enemyActed = self:enemyTrySwitchOrItem() + enemyMoveId = (not enemyActed) and self:enemyMove() or nil + end -- battle.turn_started, where BattleState:resolveTurn raises it on Gen 1: -- once both sides have chosen and before either acts. Gen 1's action tables @@ -4483,6 +4538,8 @@ local function runTurn(self, action) local playerFirst if action.kind == "skip" or action.kind == "item" then playerFirst = true + elseif self.linkBattle and enemyActed then + playerFirst = false elseif Runtime.wantsHook("battle.turn_order") then -- battle.turn_order, the same hook BattleState:resolveTurn calls on Gen 1 -- and with the same five arguments: both battlers, both move records, and @@ -4504,6 +4561,7 @@ local function runTurn(self, action) local function playerAttack() if action.kind ~= "move" then return end + if self.linkBattle and (self.player.hp or 0) <= 0 then return end local move = action.move -- An encored mon has no choice, whatever the menu said. local forced = self:forcedMove(self.player) @@ -4564,6 +4622,32 @@ local function runTurn(self, action) -- TryEnemyFlee sits here in both of the cart's turn orders, ahead of the -- enemy's move and behind the faint checks. if self:tryEnemyFlee() then return end + if self.linkBattle then + local forced = self:forcedMove(self.enemy) + if forced then enemyMoveId = forced end + local stored = self:volatile(self.enemy).chargeMove + if stored then enemyMoveId = stored end + if not self:canAct(self.enemy, enemyMoveId) then return end + local charging = stored == enemyMoveId + or self:lockedInMove(self.enemy) == enemyMoveId + local bideLocked = self:fightLockedMove(self.enemy) == enemyMoveId + if not charging and not bideLocked + and not self:hasUsableMoves(self.enemy) then + self:emit({ kind = "message", + text = self:monName(self.enemy) .. " has no moves left!" }) + enemyMoveId = Battle.STRUGGLE + end + if not enemyMoveId then enemyMoveId = Battle.STRUGGLE end + if self:moveDisabled(self.enemy, enemyMoveId) then + local state = self:volatile(self.enemy) + state.chargeMove, state.vanished = nil, nil + self:emit({ kind = "message", text = self:monName(self.enemy) + .. "'s " .. enemyMoveId .. " is DISABLED!" }) + return + end + self:useMove(self.enemy, self.player, enemyMoveId) + return + end if not enemyMoveId then -- `.struggle` (engine/battle/core.asm:5630-5632) sets STRUGGLE and -- finishes silently: BattleText_MonHasNoMovesLeft is text_ram @@ -4640,22 +4724,24 @@ local function runTurn(self, action) -- weather, then status chip and the Leech Seed / Curse residuals, then -- the wrap ticks, then held items, then Future Sight and Perish Song, -- then the screens and the per-turn counters. + local firstMon, secondMon = self.player, self.enemy + if self.mirrored then firstMon, secondMon = self.enemy, self.player end self:tickWeather() - self:tickStatus(self.player) - self:tickSeedAndCurse(self.player) - self:tickStatus(self.enemy) - self:tickSeedAndCurse(self.enemy) - self:tickWrap(self.player) - self:tickWrap(self.enemy) - self:tickHeldItem(self.player) - self:tickHeldItem(self.enemy) - self:tickFutureSight(self.player) - self:tickFutureSight(self.enemy) - self:tickPerish(self.player) - self:tickPerish(self.enemy) + self:tickStatus(firstMon) + self:tickSeedAndCurse(firstMon) + self:tickStatus(secondMon) + self:tickSeedAndCurse(secondMon) + self:tickWrap(firstMon) + self:tickWrap(secondMon) + self:tickHeldItem(firstMon) + self:tickHeldItem(secondMon) + self:tickFutureSight(firstMon) + self:tickFutureSight(secondMon) + self:tickPerish(firstMon) + self:tickPerish(secondMon) self:tickScreens() - self:tickCounters(self.player) - self:tickCounters(self.enemy) + self:tickCounters(firstMon) + self:tickCounters(secondMon) self:resolveFaints() return self:takeEvents() end @@ -4666,7 +4752,15 @@ end -- was already over) opened nothing and so closes nothing, which is what keeps -- the two events paired the way Gen 1's endOfTurn keeps them. function Battle:takeTurn(action) - local events = runTurn(self, action) + return self:closeTurn(runTurn(self, action)) +end + +function Battle:takeLinkTurn(playerAction, enemyAction) + return self:closeTurn(runTurn(self, playerAction, + enemyAction or { kind = "skip" })) +end + +function Battle:closeTurn(events) if self.turnOpen then self.turnOpen = nil if Runtime.wants("battle.turn_ended") then @@ -4941,6 +5035,150 @@ function Battle:tickHeldItem(mon) end end +function Battle:forcedReplacement(side, index) + index = tonumber(index) + if side == "enemy" then + local party = self.enemyParty or {} + local mon = index and party[index] + if not mon or (mon.hp or 0) <= 0 or mon.isEgg then + index = Battle.firstHealthy(party) + mon = index and party[index] + end + if not mon then return false end + self.pendingEnemySwitch = nil + local previous = self.enemy + self:clearVolatile(previous) + self.enemyIndex = index + self.enemy = mon + self:clearVolatile(mon) + self.stages.enemy = Battle.newStages() + self:emit({ kind = "send", side = "enemy", mon = mon, replacement = true, + hp = mon.hp or 0, status = mon.status or false, + level = mon.level, experience = mon.experience, + text = ((self.trainer and self.trainer.name) or "Foe") .. " sent out " + .. self:monName(mon) .. "!" }) + Runtime.emit("battle.battler_switched", { + battle = self, side = self:sideRecord(mon), battler = mon, + previous = previous, + }) + self:breakTrapsOnSend(mon) + self:spikesDamage(mon) + return true + end + local mon = index and self.party[index] + if not mon or (mon.hp or 0) <= 0 or mon.isEgg then + index = Battle.firstHealthy(self.party) + end + if not index then return false end + return self:switch(index) +end + +Battle.LINK_STAGES = { "attack", "defense", "speed", "specialAttack", + "specialDefense", "accuracy", "evasion" } + +Battle.LINK_VOLATILE = { + "bideStored", "bideTurns", "chargeMove", "confuseCount", "curled", "cursed", + "disabled", "disabledTurns", "encore", "encoreTurns", "endure", "flinched", + "focusEnergy", "futureSight", "futureSightDamage", "futureSightSide", + "identified", "lastMove", "leechSeed", "lockOn", "mist", "perish", "protect", + "protectCount", "rage", "rampCount", "rampMove", "rampageMove", + "rampageTurns", "recharge", "rolloutLock", "substitute", "tookThisTurn", + "transformed", "trapsTarget", "turnsTaken", "vanished", "wrapCount", + "wrapMoveId", "xAccuracy", +} + +Battle.LINK_SCREENS = { "lightScreen", "reflect", "safeguard" } + +local function linkOff(v) + return v == nil or v == false or v == 0 +end + +local function linkScalar(v) + if type(v) == "table" then return tostring(v.id or v.move or "?") end + if type(v) == "boolean" then return v and "T" or "F" end + return tostring(v) +end + +local function linkPp(mon) + local out = {} + for i, mv in ipairs((mon and mon.moves) or {}) do + out[i] = ("%s=%s"):format(tostring(mv.id), tostring(mv.pp or 0)) + end + return table.concat(out, ",") +end + +local function linkStages(self, key) + local stages = (self.stages and self.stages[key]) or {} + local out = {} + for i, stat in ipairs(Battle.LINK_STAGES) do + out[i] = tostring(stages[stat] or 0) + end + return table.concat(out, ",") +end + +local function linkActive(self, mon, key) + if not mon then return "-" end + return ("%s:%d:%s:%s:%s:%s"):format(tostring(mon.species), mon.hp or 0, + tostring(mon.status or false), linkStages(self, key), linkPp(mon), + tostring(mon.item or "-")) +end + +local function linkVolatile(mon) + if not mon then return "-" end + local state = mon.volatile or {} + local out = {} + for _, field in ipairs(Battle.LINK_VOLATILE) do + if not linkOff(state[field]) then + out[#out + 1] = field .. "=" .. linkScalar(state[field]) + end + end + if not linkOff(mon.statusTurns) then + out[#out + 1] = "statusTurns=" .. tostring(mon.statusTurns) + end + if not linkOff(mon.toxicCounter) then + out[#out + 1] = "toxicCounter=" .. tostring(mon.toxicCounter) + end + return table.concat(out, ",") +end + +local function linkSide(self, key) + local screens = (self.screens and self.screens[key]) or {} + local out = { ((self.spikes or {})[key] and "spikes" or "-") } + for _, field in ipairs(Battle.LINK_SCREENS) do + out[#out + 1] = field .. "=" .. tostring(screens[field] or 0) + end + return table.concat(out, ",") +end + +local function linkBench(party) + local out = {} + for i, mon in ipairs(party or {}) do + out[i] = ("%s:%d:%s:%s"):format(tostring(mon.species), mon.hp or 0, + tostring(mon.status or false), tostring(mon.item or "-")) + end + return table.concat(out, "|") +end + +function Battle:linkSignature(role) + local hostIsPlayer = role ~= "guest" + local hostKey = hostIsPlayer and "player" or "enemy" + local guestKey = hostIsPlayer and "enemy" or "player" + local hostMon = hostIsPlayer and self.player or self.enemy + local guestMon = hostIsPlayer and self.enemy or self.player + local hostParty = hostIsPlayer and self.party or self.enemyParty + local guestParty = hostIsPlayer and self.enemyParty or self.party + return { + actives = linkActive(self, hostMon, hostKey) .. "|" + .. linkActive(self, guestMon, guestKey) + .. "|r" .. tostring(self.rngDraws or 0), + volatile = linkVolatile(hostMon) .. "|" .. linkVolatile(guestMon) + .. "|" .. linkSide(self, hostKey) .. "|" .. linkSide(self, guestKey) + .. "|w" .. tostring(self.weather or "-") + .. ":" .. tostring(self.weatherTurns or 0), + bench = linkBench(hostParty) .. "|" .. linkBench(guestParty), + } +end + Battle.Damage = Damage Battle.Mon = Mon diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index 56679edb..441820d2 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -410,6 +410,11 @@ end local function locationName(game, mapId) if not mapId then return nil end + local world = game and game.world + if world and world.landmarkName then + local ok, name = pcall(world.landmarkName, world) + if ok and type(name) == "string" and name ~= "" then return name end + end local field = game and game.data and game.data.field local townMap = field and field.townMap local locations = townMap and (townMap.locations or townMap) @@ -602,14 +607,14 @@ local function subscribe(game) end) end --- Advertise (or clear, with code=nil) an online-match/tournament code as a --- Discord join secret. LinkState/Tournament call this once they have a --- real code from the relay, and clear it again once paired/started or the --- hosting screen exits (an invite that's already full or gone is worse --- than no invite). kind is "match" (default) or "tournament"; size/max are --- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a --- tournament passes its live roster count and a generous cap instead, and --- should call this again whenever the roster changes, not just once. +-- Advertise (or clear, with code=nil) a room code as a Discord join +-- secret. The launcher's ONLINE tab calls this once the relay has given it +-- a real code, and clears it again once the room fills or closes (an invite +-- that's already full or gone is worse than no invite). kind is "match" +-- (default) or "tournament"; size/max are the party.size Discord shows +-- (default 1/2, a plain 1v1 room) -- a tournament passes its live roster +-- count and a generous cap instead, and should call this again whenever the +-- roster changes, not just once. function DiscordPresence.setJoinCode(code, kind, size, max) pcall(function() state.joinCode = code @@ -632,23 +637,22 @@ local function handleJoinRequest(secret) if not secret or secret == "" then return end if state.activity == "battle" then return end local game = state.game - if not game or not game.stack then return end - local top = game.stack:top() + local top = game and game.stack and game.stack:top() if top and top.stage and top.net then return end -- already in a link session local kindTag, code = secret:match("^(%a):(.+)$") if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match Runtime.emit("discord.join_requested", { code = code, kind = kindTag }) - if kindTag == "t" then - local ok, Tournament = pcall(require, "src.link.Tournament") - if ok and Tournament.newJoinOnline then - game.stack:push(Tournament.newJoinOnline(game, code)) - end - else - local ok, LinkState = pcall(require, "src.link.LinkState") - if ok and LinkState.newJoinOnline then - game.stack:push(LinkState.newJoinOnline(game, code)) - end + if game and not game.returnToLauncher then + print("[discord] no launcher to return to; ignoring join code " .. tostring(code)) + return end + local ok, Client = pcall(require, "src.online.Client") + if not ok or type(Client) ~= "table" or not Client.joinRoom then + print("[discord] online client unavailable; ignoring join code " .. tostring(code)) + return + end + if game then game.returnToLauncher({ tab = "online", joinCode = code }) end + Client.joinRoom(code, "player") end -- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI) diff --git a/src/core/FilePicker.lua b/src/core/FilePicker.lua index d56cc116..ec087ced 100644 --- a/src/core/FilePicker.lua +++ b/src/core/FilePicker.lua @@ -24,6 +24,7 @@ local function commandOutput(command) if not pipe then return nil end local result = pipe:read("*a") HostShell.pclose(pipe) + HostShell.pumpHostEvents() result = trim(result) return result ~= "" and result or nil end diff --git a/src/core/Game.lua b/src/core/Game.lua index 97a6d117..6c743215 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -32,7 +32,9 @@ local function bootScreens(game) return (boot and boot.screens) or {} end -function Game:load() +function Game:load(opts) + opts = opts or {} + local arena = opts.arena self.data = Data self.sessionStartedAt = os.time() Data:load() @@ -42,7 +44,15 @@ function Game:load() -- the rest of the game consumes. A broken mod is reported and skipped by -- the loader without preventing the base game from booting. self.mods = ModLoader.new() - self.mods:load(Data) + if arena then + self.mods:load(Data, { + mode = (arena.profile and arena.profile.kind == "cart") + and "cartOnly" or "disableAll", + cartId = opts.cartId, + }) + else + self.mods:load(Data) + end self.modStatus = self.mods:status() -- render pipelines dispatch off the merged dataset; point them at the -- one the mods just merged into before anything can draw a frame @@ -102,7 +112,9 @@ function Game:load() -- boot into the title screen (engine/movie/title.asm); NEW GAME runs -- the Oak speech + naming, CONTINUE restores the save. The headless -- autopilot skips straight into the overworld. - if os.getenv("POKEPORT_AUTOPILOT") then + if arena then + self:enterArena(arena) + elseif os.getenv("POKEPORT_AUTOPILOT") then StateStack:push(OverworldState, self.save.player.map, self.save.player.x, self.save.player.y, self.save.player.facing) else @@ -176,6 +188,28 @@ function Game:startNewGame(opts) end end +function Game:enterArena(spec) + local version = require("src.core.GameVersion").get() + if spec.slotId then pcall(SaveData.setActiveSlot, version, spec.slotId) end + local loaded = SaveData.load() + if loaded then + local activeMods = self.modStatus and self.modStatus.loaded + SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) + self.saveReport = SaveData.validate(loaded, self.data) + self.save = loaded + loaded.startMenuIndex = nil + self.startMenuIndex = nil + self:adoptSave(loaded) + self:applyOptions(loaded.options) + local stamp = require("src.battle.BattleState").stampOT + for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end + else + Logger.warn("arena: save slot %s could not be loaded", tostring(spec.slotId)) + end + self.linkSession = true + StateStack:push(require("src.ui.ArenaState").new(self, spec)) +end + -- the title screen with its NEW GAME / CONTINUE wiring; used at boot -- and by the START-menu QUIT confirmation function Game:makeTitleState() @@ -239,8 +273,12 @@ function Game:touchSkinHotkey(action, pressed) end end -function Game:breakLink(err) - Logger.error("link: torn down after an error\n%s", tostring(err)) +function Game:breakLink(err, source) + if source == "engine" then + Logger.error("link: engine error, link torn down\n%s", tostring(err)) + else + Logger.error("link: torn down after an error\n%s", tostring(err)) + end self.linkSession = nil local net = self.linkNet self.linkNet = nil @@ -255,7 +293,9 @@ function Game:breakLink(err) pcall(function() local Strings = require("src.core.Strings") local TextBox = require("src.render.TextBox") - stack:push(TextBox.new(self, Strings("The link was\nbroken."))) + stack:push(TextBox.new(self, source == "engine" + and Strings("Something broke\nduring the link.") + or Strings("The link was\nbroken."))) end) end @@ -288,7 +328,7 @@ function Game:step(dt) if self.linkNet and not self.linkNet.closed then local ok, err = pcall(self.linkNet.update, self.linkNet) if not ok then - self:breakLink(err) + self:breakLink(err, "transport") return end end @@ -296,7 +336,7 @@ function Game:step(dt) local ok, err = xpcall(function() self.stack:update(dt) end, function(e) return debug.traceback(tostring(e), 2) end) if not ok then - self:breakLink(err) + self:breakLink(err, "engine") return end else diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 4e274e86..c59ac7ec 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -173,6 +173,7 @@ function Game2.new() options = Save.loadOptions(), }, Game2) self.save.options = self.options + self.sessionStartedAt = os.time() anchorNewGameClock(self.save) return self end @@ -205,6 +206,12 @@ function Game2:adoptSave(save, seedBuckets) loader.modSave = save.modData end +function Game2:enterArena(spec) + self.phase = "boot" + self.stack:clear() + self.stack:push(require("src.ui.gen2.ArenaState").new(self, spec)) +end + function Game2:startWorld() if self.world and self.world.map then self.phase = "play" @@ -238,6 +245,7 @@ end function Game2:newGame() self.save = Save.newGame({ playerName = self.save.player.name }) self.save.options = self.options + self.sessionStartedAt = os.time() -- InitClock re-anchors this the moment the player answers Oak; the default -- only has to hold for a run that skips the screen. anchorNewGameClock(self.save) @@ -275,6 +283,7 @@ function Game2:continueGame(save) SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods) local modsDiff = SaveData.modsDiff(save, activeMods) self.save = save + self.sessionStartedAt = os.time() self:adoptSave(save) -- Editor species swaps used to leave mon.name on the previous species. -- CONTINUE rewrites party, boxes, and Day-Care copies from the live record. @@ -876,17 +885,41 @@ function Game2:writeSave() return false end local save = self:snapshotSave() - -- The snapshot is complete, so this payload carries exactly the table the - -- file gets; mods stash runtime state into their own keys now. `meta` is - -- the Gen 1 key, absent rather than renamed: a Gold save stamps no meta - -- block yet (see save.loaded in continueGame). + save.meta = SaveData.buildMeta( + self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt) if ModRuntime.wants("save.writing") then ModRuntime.emit("save.writing", { save = save, meta = save.meta }) end - return Save.save(save) + local written, err = Save.save(save) + if written then + local eng = self:syncEngine() + if eng then pcall(eng.noteSaveWritten, eng) end + end + return written, err end -function Game2:load() +function Game2:syncEngine() + if self._syncOff then return nil end + local eng = self._syncEngineRef + if not eng then + local ok, SyncEngine = pcall(require, "src.sync.SyncEngine") + if not ok or type(SyncEngine) ~= "table" then + self._syncOff = true + return nil + end + eng = SyncEngine.shared() + if not eng then + self._syncOff = true + return nil + end + self._syncEngineRef = eng + end + return eng +end + +function Game2:load(opts) + opts = opts or {} + local arena = opts.arena Input:init() -- Before applyOptions, which is what pushes options.touchControls into it: -- init() decides whether the platform wants the overlay at all and loads the @@ -990,13 +1023,18 @@ function Game2:load() -- self.data and the ones without report instead of silently vanishing. The -- whole thing is behind a pcall so a mod problem can never cost Gold its -- boot. + local modOpts = arena and { + mode = (arena.profile and arena.profile.kind == "cart") + and "cartOnly" or "disableAll", + cartId = opts.cartId, + } or nil local ok, loader = pcall(function() local mods = require("src.mods.Loader").new() -- the live service owner, before load: mod.world and mod.input resolve -- through this, and without it the facade would bind to the Gen 1 -- src/core/Game.lua singleton that a Gold boot never loads mods.game = self - mods:load(self.data) + mods:load(self.data, modOpts) return mods end) if ok and loader then @@ -1067,12 +1105,15 @@ function Game2:load() -- a mod to hold). Emitted where Gen 1 emits it -- every service up, the -- stack still empty -- so a listener that pushes a state lands underneath -- the boot cinema rather than being buried by it. + pcall(function() require("src.core.DiscordPresence").init(self) end) ModRuntime.emit("game.ready", { game = self }) -- Drivers that walk the overworld skip boot cinema so smoke stays stable. -- POKEPORT_BOOT_CINEMA=1 opts back in, which is how the boot-chain driver -- exercises copyright -> title -> intro menu -> Oak -> naming. - if os.getenv("POKEPORT_DRIVER") + if arena then + self:enterArena(arena) + elseif os.getenv("POKEPORT_DRIVER") and os.getenv("POKEPORT_BOOT_CINEMA") ~= "1" then self:startWorld() else @@ -1166,6 +1207,7 @@ function Game2:update(dt) -- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265): -- they are presentational, so fast-forward must not speed them up. require("src.render.Pipelines").update(dt) + pcall(function() require("src.core.DiscordPresence").update(dt) end) -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: -- audio runs off its own real-time accumulator, so music and sfx keep their -- tempo at every multiplier. speedOverride is the driver/CLI hook and wins @@ -1569,6 +1611,23 @@ function Game2:drawContained(w, h) if not ok then error(err, 0) end end +local function panelBlit(stack, w, h) + local states = stack and stack.states or {} + for i = #states, 1, -1 do + local state = states[i] + if state then + if state.battlePanelScale then + local scale = state:battlePanelScale(w, h) + if scale then return scale, Chrome.fitOrigin(w, h, scale) end + end + if state.drawsWidescreen and state:drawsWidescreen() then break end + end + end + local scale = Chrome.fitScale(w, h) + local ox, oy = Chrome.fitOrigin(w, h, scale) + return scale, ox, oy +end + -- BATTLE BG (#1709): the surround around the centred GB screen, taken from -- whichever state in the stack owns a battle. function Game2:paintBattleSurround(w, h) @@ -1580,8 +1639,7 @@ function Game2:paintBattleSurround(w, h) end if mode ~= "black" then return end local G = love.graphics - local scale = Chrome.fitScale(w, h) - local ox, oy = Chrome.fitOrigin(w, h, scale) + local scale, ox, oy = panelBlit(self.stack, w, h) local pw, ph = 160 * scale, 144 * scale G.setColor(0, 0, 0, 1) if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end @@ -1663,10 +1721,7 @@ function Game2:drawScene(w, h) self:paintBattleSurround(w, h) self:letterbox(w, h, false) if wide ~= top then - -- The pushed box blits at the same integer fit the widescreen layer - -- used, or it lands on a different grid than the panel underneath it. - local scale = Chrome.fitScale(w, h) - local ox, oy = Chrome.fitOrigin(w, h, scale) + local scale, ox, oy = panelBlit(self.stack, w, h) G.push() G.translate(ox, oy) G.scale(scale, scale) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index f7e5daff..4e6426c1 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -213,6 +213,11 @@ function HostShell.pclose(pipe) withPopenLock(function() pcall(function() pipe:close() end) end) end +function HostShell.pumpHostEvents() + if not (love and love.event and love.event.pump) then return end + pcall(love.event.pump) +end + -- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's -- boot in-process, which calls love.filesystem.init a second time -- and inside -- an AppImage physfs is already initialized, so that second init throws diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua index 6b5caaa1..cbd432c4 100644 --- a/src/core/IssueReport.lua +++ b/src/core/IssueReport.lua @@ -140,7 +140,7 @@ end local function appVersion() local version = clean(Version.engine) - if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end + if not version or version == "0.0.0" or version:match("^0%.0%.0%-dev") then return "" end return version end diff --git a/src/core/Timing.lua b/src/core/Timing.lua index 1a1e05bf..ad6c6f5a 100644 --- a/src/core/Timing.lua +++ b/src/core/Timing.lua @@ -93,6 +93,8 @@ Timing.TRAINER_INTRO_SFX_GAP = 20 Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156 Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation +-- animations.asm:431-433 (pokeyellow animations.asm:445-446) +Timing.MOVE_ANIM_OFF = 30 -- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move -- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index 3df41e2c..7e4e7b86 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -779,19 +779,12 @@ local function drawIcon(img, zone, pressed, alphaMul) zone.cy - img:getHeight() * scale / 2, 0, scale, scale) end -local function drawCovered(img, x, y, w, h, alpha) +local function drawOverlayImage(img, x, y, w, h, alpha) if not img or alpha <= 0 then return end - local iw, ih = img:getWidth(), img:getHeight() - if iw <= 0 or ih <= 0 then return end - -- Cover the assigned box with one uniform scale and crop the excess. The - -- old independent X/Y scale made portrait art visibly squash on wide - -- displays (and vice versa). - local s = math.max(w / iw, h / ih) - local dw, dh = iw * s, ih * s + local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h) + if not sx then return end love.graphics.setColor(1, 1, 1, math.min(1, alpha)) - love.graphics.setScissor(x, y, w, h) - love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s) - love.graphics.setScissor() + love.graphics.draw(img, x, y, 0, sx, sy) end function TouchControls:drawSkin(alphaMul) @@ -803,7 +796,7 @@ function TouchControls:drawSkin(alphaMul) love.graphics.push("all") love.graphics.origin() - drawCovered(page.image, bx, by, bw, bh, opacity) + drawOverlayImage(page.image, bx, by, bw, bh, opacity) local pressed = {} for _, touch in pairs(self.touches or {}) do @@ -818,7 +811,7 @@ function TouchControls:drawSkin(alphaMul) TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy) local alpha = opacity if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end - drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) + drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha) end end diff --git a/src/core/TouchSkin.lua b/src/core/TouchSkin.lua index e378880f..12bd8df9 100644 --- a/src/core/TouchSkin.lua +++ b/src/core/TouchSkin.lua @@ -1352,6 +1352,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy) return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh end +function TouchSkin.imageFit(iw, ih, w, h) + if not iw or not ih or iw <= 0 or ih <= 0 then return nil end + return w / iw, h / ih +end + function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy) local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy) local cx, cy = bx + ctl.x * bw, by + ctl.y * bh diff --git a/src/core/Version.lua b/src/core/Version.lua index 5e632ae6..bc4f7cc1 100644 --- a/src/core/Version.lua +++ b/src/core/Version.lua @@ -22,6 +22,71 @@ local Version = { cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) } +local DEV_PLACEHOLDER = "0.0.0-dev" + +local function readFile(path) + local handle = io.open(path, "rb") + if not handle then return nil end + local text = handle:read("*a") + handle:close() + if type(text) ~= "string" or text == "" then return nil end + return text +end + +local function commitFromBuildInfo() + local fs = rawget(_G, "love") and love.filesystem + if not (fs and fs.read) then return nil end + local ok, text = pcall(fs.read, "build-info.json") + if not ok or type(text) ~= "string" then return nil end + return text:match('"gitCommitFull"%s*:%s*"(%x+)"') + or text:match('"gitCommit"%s*:%s*"(%x+)"') +end + +local function commitFromGit(source, read) + read = read or readFile + if type(source) ~= "string" or source == "" then return nil end + local gitDir = source .. "/.git" + local head = read(gitDir .. "/HEAD") + if not head then + local pointer = read(gitDir) + local target = pointer and pointer:match("^gitdir:%s*(%S+)") + if not target then return nil end + if not target:match("^/") then target = source .. "/" .. target end + gitDir = target + head = read(gitDir .. "/HEAD") + if not head then return nil end + end + local ref = head:match("^ref:%s*(%S+)") + if not ref then return head:match("^(%x+)") end + local hash = read(gitDir .. "/" .. ref) + if hash then return hash:match("^(%x+)") end + local packed = read(gitDir .. "/packed-refs") + if packed then + return packed:match("(%x+)%s+" .. ref:gsub("%p", "%%%0")) + end + return nil +end + +function Version.devEngine(engine, source, read) + engine = tostring(engine or "") + if engine ~= DEV_PLACEHOLDER then return engine end + local commit = commitFromBuildInfo() or commitFromGit(source, read) + if type(commit) ~= "string" or #commit < 7 then return engine end + return engine .. "+" .. commit:sub(1, 12):lower() +end + +do + local ok, derived = pcall(function() + local source + local fs = rawget(_G, "love") and love.filesystem + if fs and fs.getSource then source = fs.getSource() end + return Version.devEngine(Version.engine, source) + end) + if ok and type(derived) == "string" and derived ~= "" then + Version.engine = derived + end +end + -- True for the working-tree placeholder and any stamped "-dev" pre-release. -- Shipped builds get a bare X.Y.Z from CI and are not "dev" here. function Version.isDev() diff --git a/src/core/gen2/Evolution.lua b/src/core/gen2/Evolution.lua index fe18a59d..b2adc240 100644 --- a/src/core/gen2/Evolution.lua +++ b/src/core/gen2/Evolution.lua @@ -382,7 +382,9 @@ function Evolution.apply(data, mon, entry) local level = mon.level or 1 -- CalcMonStats runs through the one builder, so an evolved mon's stats can -- never disagree with a freshly built one's. - local stats = Mon.stats(def.baseStats, mon.dvs, level, mon.statExp) + -- engine/pokemon/evolve.asm:261-264 + local statExp = mon.statExp + local stats = Mon.stats(def.baseStats, mon.dvs, level, statExp) local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or stats.hp local hp = (mon.hp or previousMax) + (stats.hp - previousMax) -- The cart does not clamp; the bound only matters for data where an @@ -398,6 +400,7 @@ function Evolution.apply(data, mon, entry) local evolved = Mon.new(data, species, level, { dvs = mon.dvs, + statExp = statExp, moves = mon.moves, hp = hp, item = heldItem, diff --git a/src/core/gen2/NpcTrade.lua b/src/core/gen2/NpcTrade.lua index 65c7eb30..905d3a86 100644 --- a/src/core/gen2/NpcTrade.lua +++ b/src/core/gen2/NpcTrade.lua @@ -152,6 +152,11 @@ function NpcTrade.perform(data, save, row, index) -- already disagree and a traded mon has to answer both. received.ot, received.otName = row.otName, row.otName received.otId = row.otId + -- engine/events/npc_trade.asm:189-197 + if Mon.hasCaughtData(save.version) then + Mon.setGiftCaughtData(received, + row.dialog == "TRADE_DIALOGSET_GIRL" and "girl" or "unknown") + end table.remove(party, index) -- RemoveMonFromPartyOrBox's "Mail time!" tail. NPCTrade itself has no mail -- check -- unlike the Day-Care and the PC, it will trade a mon holding a diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index 6bee7106..6341f8ac 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -312,6 +312,8 @@ Save.DEFAULT_OPTIONS = { palette = "", videoMode = "windowed", fpsCap = 60, + -- BATTLE SIZE (#1709): fixed | fill + battleFit = "fixed", -- BATTLE BG (#1709): white | black, the surround around the battle screen. battleBg = "white", -- VOID FILL: fade | water | trees | black. fade is each map header's own @@ -409,6 +411,22 @@ local function normalizePokerus(mons) end end +-- constants/pokemon_data_constants.asm:79, :90 +local function normalizeMoves(mons) + for _, mon in ipairs(mons or {}) do + if type(mon) == "table" and type(mon.moves) == "table" then + local rebuilt = false + for i, entry in ipairs(mon.moves) do + if type(entry) ~= "table" then + mon.moves[i] = { id = entry, pp = (mon.pp or {})[i] } + rebuilt = true + end + end + if rebuilt then mon.pp = nil end + end + end +end + -- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte. Save.GS_BALL_STATES = { have = true, given = true, used = true } @@ -558,8 +576,12 @@ function Save.normalize(save) table.remove(save.party) end normalizePokerus(save.party) + normalizeMoves(save.party) for _, box in pairs(save.boxes) do - if type(box) == "table" then normalizePokerus(box) end + if type(box) == "table" then + normalizePokerus(box) + normalizeMoves(box) + end end -- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves -- written before the stamp existed get it here. diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index 3e4c46b4..29224bbc 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -27,6 +27,9 @@ CacheContract.REQUIRED_FILES = { "assets/generated/battle/anims/move_anim_1.png", "assets/generated/audio/programs.bin", "assets/generated/trade/game_boy.png", + -- engine/items/town_map.asm:296, 150 + "assets/generated/townmap/nest.png", + "assets/generated/townmap/up_arrow.png", } CacheContract.VERSION_REQUIRED_FILES = { @@ -83,6 +86,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/pc/mail_item.png", -- engine/events/fishing_gfx.asm:23 "assets/generated/emotes/fishing.png", + -- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390 + "assets/generated/emotes/jump_shadow.png", + "assets/generated/emotes/cut_grass.png", + -- engine/pokegear/pokegear.asm:2298 + "assets/generated/pokegear/nest_icon.png", }, crystal = { "data/generated/constants.lua", @@ -133,6 +141,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = { "assets/generated/battle/trainers/chris.png", -- ../pokecrystal/engine/events/fishing_gfx.asm:38-42 "assets/generated/emotes/fishing.png", + -- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390 + "assets/generated/emotes/jump_shadow.png", + "assets/generated/emotes/cut_grass.png", + -- engine/pokegear/pokegear.asm:2298 + "assets/generated/pokegear/nest_icon.png", }, } CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver = diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 071b60fa..45f8a400 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -675,6 +675,9 @@ local function gen2Rows(opts, hooks, shared) end) end + add(Strings("BATTLE SIZE"), ladder(opts, "battleFit", + { { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed")) + -- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours. add(Strings("BATTLE BG"), ladder(opts, "battleBg", { { "white", "WHITE" }, { "black", "BLACK" } }, "white")) @@ -714,6 +717,23 @@ function LauncherSettings.open(hooks, version) { title = Strings("OPTIONS"), rows = coreRows(opts, hooks) }, } end + sections[#sections + 1] = { + title = Strings("LAUNCHER"), + rows = { + { + label = Strings("REDUCE MOTION"), + value = function() + return opts.reduceMotion == true and Strings("ON") or Strings("OFF") + end, + step = function() + opts.reduceMotion = not (opts.reduceMotion == true) + local okT, Transition = pcall(require, "src.ui.kit.Transition") + if okT then Transition.reduceMotion = opts.reduceMotion end + return true + end, + }, + }, + } -- Mod options are generation-agnostic (the manager's options_schema -- contract), so they ride along either way. for _, mod in ipairs(discoverModSchemas(opts)) do diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index d23badb0..b85d9458 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -31,6 +31,7 @@ local Kit = require("src.ui.kit.Kit") local Theme = require("src.ui.kit.Theme") local Layout = require("src.ui.kit.Layout") local Loader = require("src.ui.kit.Loader") +local Transition = require("src.ui.kit.Transition") local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") local Strings = require("src.core.Strings") @@ -116,6 +117,7 @@ function LauncherView.detach(imp) elseif imp and imp._restoreNxPointerBridge then pcall(imp._restoreNxPointerBridge, imp) end + Transition.reset() if not imp or not imp._flex then return end imp._flex = nil if love.keyboard and love.keyboard.setKeyRepeat then @@ -330,9 +332,15 @@ function LauncherView.keypressed(imp, key) Kit.navigate(key) return true end - if imp._ringArmed and (key == "return" or key == "kpenter" or key == "space") then - Kit.activateFocused() - return true + if key == "return" or key == "kpenter" or key == "space" then + if imp._ringArmed then + Kit.activateFocused() + return true + end + if imp._tradeModal then + require("src.import.OnlinePanel").tradeModalAction(imp, "a") + return true + end end return false end @@ -370,6 +378,10 @@ local function rowHit(imp, x, y, w, h, selected, key, action) return ink end +LauncherView.btn = btn +LauncherView.rowHit = rowHit +LauncherView.queueAction = queueAction + -- ------------------------------------------------------- shared widgets -- Read-only text field. The importer owns the string (its textinput / @@ -405,8 +417,9 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end if (Kit.press(x, y, w, h) or Kit._activateId == key) then + local osk = false if Kit.VirtualKeyboard then - Kit.VirtualKeyboard.open({ + osk = Kit.VirtualKeyboard.open({ text = text, targetId = key, title = placeholder or "Enter Text", @@ -423,14 +436,14 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac elseif imp._settingsText and key:find("settext") then imp._settingsText.text = newText elseif imp.tab == "find" then - imp._findQuery = newText + imp.findQuery = newText if imp._refreshFind then imp:_refreshFind() end end end end - }) + }) and true or false end - if action then + if action and not osk then queueAction(imp, key, action) end end @@ -1292,13 +1305,29 @@ local GAME_TABS = { color = PAL.railCrystal, label = "Crystal" }, } +local function drawOnlineGlyph(x, y, w, h, hot) + local box = math.min(w, h) + local bx = x + (w - box) / 2 + local by = y + (h - box) / 2 + local pad = box * 0.24 + local d = box - 2 * pad + local ink = hot and PAL.inverse or PAL.ink + local lw = math.max(1, math.floor(Kit.scale + 0.5)) + Theme.strokeRounded(bx + pad, by + pad, d, d, ink, 1, lw, d / 2) + Theme.fill(bx + pad, by + pad + d / 2 - lw / 2, d, lw, ink, 1) + Theme.strokeRounded(bx + pad + d * 0.30, by + pad, d * 0.40, d, ink, 0.8, lw, + d * 0.20) +end + local HEADER_TABS = { { id = "mods", key = "tab-mods" }, { id = "find", key = "tab-find" }, + { id = "online", key = "tab-online", glyph = true, beta = true }, { id = "skins", key = "tab-skins", glyph = true, beta = true }, - { id = "bug", key = "tab-bug" }, } +LauncherView.HEADER_TABS = HEADER_TABS + local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse } local function drawBetaTag(x, y, w, h) @@ -1314,7 +1343,7 @@ end for _, t in ipairs(HEADER_TABS) do t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter } if t.glyph then - t.opts.drawFn = drawSkinGlyph + t.opts.drawFn = t.id == "online" and drawOnlineGlyph or drawSkinGlyph end end @@ -1359,6 +1388,7 @@ local function headerChrome(imp) local g = currentGame(imp) if imp.tab == g.id then imp._gamePopup = true + Kit.setFocus("gamepop-" .. g.id) else imp:_switchTab(g.id) end @@ -1467,7 +1497,6 @@ local function buildHeader(imp, m) for _, t in ipairs(tabs) do if t.id == "mods" then t.icon = imp._modsIcon end if t.id == "find" then t.icon = imp._findIcon end - if t.id == "bug" then t.icon = imp._bugIcon end end local tabH = m.chip local tx = m.x + m.pad @@ -1487,10 +1516,8 @@ local function buildHeader(imp, m) chrome0.game.active = imp.tab == game.id local gameHot = Kit.hover(tx, ty, dropW, tabH) local gameDown = gameHot and Kit.mouseDown - -- face "tab" inverts on hover as well as when active, so the caret has to - -- flip with it or it vanishes into the cartridge colour - local gameInvert = chrome0.game.active or gameHot - chrome0.game.ring = gameHot and not chrome0.game.active or nil + local gameInvert = chrome0.game.active + chrome0.game.ring = nil btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game) do local cw = math.floor(7 * m.s) @@ -1519,11 +1546,7 @@ local function buildHeader(imp, m) if t.beta then overlayBeta(tx, ty, w, tabH, m) end tx = tx + w + tabGap end - -- The bug-report chip sits LAST, past the sync chip. - local bugTab - for _, t in ipairs(tabs) do - if t.id == "bug" then bugTab = t else headerTab(t) end - end + for _, t in ipairs(tabs) do headerTab(t) end do local w = tabH @@ -1542,7 +1565,6 @@ local function buildHeader(imp, m) end tx = tx + w + tabGap end - if bugTab then headerTab(bugTab) end -- `ty` has walked down with the wraps, so this stays correct at one row too. y = ty + tabH + math.floor(8 * m.s) @@ -1550,6 +1572,8 @@ local function buildHeader(imp, m) return y + math.floor(10 * m.s) end +LauncherView.textField = textField + -- The state of the self-updater, shown in the launcher footer. -- Returns status, label, action, glow. function LauncherView._updateControl(imp) @@ -3517,23 +3541,58 @@ end -- underneath is inert, then lowers it for its own panel. There is no -- z-ordered hit test, so this ordering IS the z-order. +local modalRect = { x = 0, y = 0, w = 0, h = 0 } +local modalTransform = false + +local function modalAmount() + local L = Transition.get("modal") + if not L then return 1 end + local p = Transition.progress("modal") + if L.kind == "out" then return 1 - p end + return p +end + local function modalPanel(m, w, h) -- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark -- still read through the settings panel and the screen looked like two -- layouts fighting rather than one panel on top ("the settings is covering -- the logo"); at this weight the page behind is present but plainly out of -- play, which is what a modal is supposed to say. - Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93) + local amt = modalAmount() + Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt) Kit.blockClicks = true local pw = math.floor(math.min(w, m.W - 2 * m.pad)) local ph = math.floor(math.min(h, m.H - 2 * m.pad)) local px = math.floor((m.W - pw) / 2) local py = math.floor((m.H - ph) / 2) + modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph + if amt < 1 and love.graphics and love.graphics.push then + local s = 0.96 + 0.04 * amt + love.graphics.push() + love.graphics.translate(m.W / 2, m.H / 2) + love.graphics.scale(s, s) + love.graphics.translate(-m.W / 2, -m.H / 2) + modalTransform = true + end Kit.card(px, py, pw, ph, true) - Kit.blockClicks = false + Kit.blockClicks = Transition.active() return px, py, pw, ph end +local function endModalDraw(m) + if not modalTransform then return end + modalTransform = false + local fade = 1 - modalAmount() + if fade > 0 then + local pad = math.floor(6 * m.s) + Theme.fillRounded(modalRect.x - pad, modalRect.y - pad, + modalRect.w + 2 * pad, modalRect.h + 2 * pad, PAL.bg, fade, 10) + end + love.graphics.pop() +end + +LauncherView.modalPanel = modalPanel + -- Shared prompt: title, read-only field over the importer's text, buttons. local function buildPrompt(imp, m, spec) local pad = math.floor(18 * m.s) @@ -4795,6 +4854,19 @@ local function buildGameManageModal(imp, m) action = function() imp._gameManage = nil end }) end +local function buildBugModal(imp, m) + local pad = math.floor(18 * m.s) + local w = math.floor(560 * m.s) + local btnH = math.max(m.btnH, Kit.tapMin()) + local h = math.floor(math.min(m.H - 2 * m.pad, 420 * m.s)) + local px, py, pw, ph = modalPanel(m, w, h) + buildBugPanel(imp, px + pad, py + pad, pw - 2 * pad, + ph - 2 * pad - btnH - m.gap, m) + btn(imp, px + pad, py + ph - pad - btnH, pw - 2 * pad, btnH, "bug-close", + Strings("Close"), { kind = "primary", font = "small", + action = function() imp:_closeBugPanel() end }) +end + local function buildSettingsModal(imp, m) local model = imp._settings local SaveData = require("src.core.SaveData") @@ -4809,6 +4881,11 @@ local function buildSettingsModal(imp, m) btn(imp, px + pw - pad - cw, cy, cw, m.btnH, "settings-close", Strings("Close"), { font = "small", action = function() imp:_closeSettings() end }) + local bugLabel = Strings("Troubleshooting") + local bw = Kit.textWidth("small", bugLabel) + math.floor(24 * m.s) + btn(imp, px + pw - pad - cw - m.gap - bw, cy, bw, m.btnH, "settings-bug", + bugLabel, { kind = "ghost", font = "small", + action = function() imp:_openBugPanel() end }) cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s) -- WRAPPED, not printed flat: on a portrait panel this line ran straight off -- the right edge and the sentence ended mid-word at the card border. @@ -5645,6 +5722,19 @@ end -- panels underneath a modal must run with Kit.blockClicks already raised or -- a click on the scrim lands on whatever button happens to be behind it. -- Keep this list in sync with buildModals below. +local MODAL_KEYS = { + "_profileRenamePrompt", "_profileSavePrompt", "_settingsText", + "_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt", + "_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails", + "_modVersions", "_modDepResolver", "_modImports", "_singleProfileActions", + "_profilesPopup", "_modHeaderActionsPopup", "_sortPopup", "_gamePopup", + "_cartPopup", "_modScopePopup", "_filterPopup", "_indexManage", + "_syncModal", "_pcPicker", "_tradeModal", "_skinActions", "_modActions", + "_findEntry", "_gameManage", +} + +LauncherView.MODAL_KEYS = MODAL_KEYS + local function modalUp(imp) if Kit.FileBrowser and Kit.FileBrowser.active then return true end if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end @@ -5656,9 +5746,22 @@ local function modalUp(imp) or imp._gamePopup or imp._cartPopup or imp._cartSave or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt - or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil + or imp._profileRenamePrompt or imp._findEntry or imp._gameManage + or imp._tradeModal or imp._bugModal or imp._pcPicker) ~= nil end +local function modalKey(imp) + if not modalUp(imp) then return nil end + if Kit.FileBrowser and Kit.FileBrowser.active then return "filebrowser" end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return "vkeyboard" end + for i = 1, #MODAL_KEYS do + if imp[MODAL_KEYS[i]] ~= nil then return MODAL_KEYS[i] end + end + return nil +end + +LauncherView.modalKey = modalKey + local function buildModals(imp, m) if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then Kit.VirtualKeyboard.draw(m) @@ -5730,6 +5833,7 @@ local function buildModals(imp, m) return true end if imp._cartSave then buildCartSaveModal(imp, m) return true end + if imp._bugModal then buildBugModal(imp, m) return true end if imp._settings then buildSettingsModal(imp, m) return true end if imp._rename then buildPrompt(imp, m, { @@ -5788,9 +5892,15 @@ local function buildModals(imp, m) local ModUpdate = require("src.mods.ModUpdate") local d = imp._findDetails local body = ModUpdate.cleanBody(d.body or "", 0) - if body == "" then body = Strings("(No description.)") end + if body == "" then + body = d.loading and Strings("Loading description...") + or Strings("(No description.)") + end buildTextModal(imp, m, "find-details", d.title, body, - function() imp._findDetails = nil end) + function() + imp._findDetails = nil + if imp._cancelFindDetails then imp:_cancelFindDetails() end + end) return true end if imp._modVersions then buildVersionsModal(imp, m) return true end @@ -5810,6 +5920,12 @@ local function buildModals(imp, m) if imp._filterPopup then buildFilterModal(imp, m) return true end if imp._indexManage then buildIndexesModal(imp, m) return true end if imp._syncModal then buildSyncModal(imp, m) return true end + if imp._pcPicker then + return require("src.import.online.PcPicker").draw(imp, m) == true + end + if imp._tradeModal then + return require("src.import.online.TradeScreen").drawModal(imp, m) == true + end if imp._skinActions then buildSkinActionsModal(imp, m) return true end if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end @@ -5843,10 +5959,8 @@ local function drawPadCursor(imp) or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then return end - -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch - -- framebuffer when the stick advances by fractional pixels each frame. local x, y = imp._padCursor.x, imp._padCursor.y - if imp.isNX then + if imp._consolePointerHost and imp:_consolePointerHost() then x, y = math.floor(x + 0.5), math.floor(y + 0.5) end love.graphics.push("all") @@ -5894,6 +6008,43 @@ local function minPanelHeight(m) return math.floor((m.twoCol and 340 or 470) * m.s) end +local function buildTabPanel(imp, x, y, w, availH, budgetH, m) + if imp.tab == "mods" then + return buildModsPanel(imp, x, y, w, budgetH, m) + elseif imp.tab == "find" then + return buildFindPanel(imp, x, y, w, budgetH, m) + elseif imp.tab == "skins" then + return buildSkinsPanel(imp, x, y, w, budgetH, m) + elseif imp.tab == "online" then + return require("src.import.OnlinePanel") + .buildOnlinePanel(imp, x, y, w, budgetH, m) + end + return buildGamePanel(imp, x, y, w, availH, m, imp.tab, budgetH) +end + +local function drawTabLayer(imp, tabId, x, contentY, w, viewH, availH, m, dx) + local prevTab = imp.tab + imp.tab = tabId + local at = tabScrollAt(imp) + local maxAt = tabScrollMax(imp) + if dx ~= 0 then + love.graphics.push() + love.graphics.translate(dx, 0) + end + local py = Kit.scrollBegin(x, contentY, w, viewH, at, maxAt) + local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN)) + local panelW = math.max(0, w - Kit.scrollGutter(m.s)) + local contentH = buildTabPanel(imp, x, py, panelW, availH, budgetH, m) + contentH = contentH or availH + imp._tabContentH[tabId] = contentH + imp._tabScrollMax[tabId] = Kit.scrollExtent(contentH, viewH) + at = clamp(at, 0, tabScrollMax(imp)) + imp._tabScroll[tabId] = at + Kit.scrollEnd(x, contentY, w, viewH, at, maxAt) + if dx ~= 0 then love.graphics.pop() end + imp.tab = prevTab +end + function LauncherView.draw(imp) ensureState(imp) local m = Layout.metrics(1200) @@ -5929,9 +6080,28 @@ function LauncherView.draw(imp) -- Everything from here to buildModals sits UNDER any open modal, so the -- whole stage draws shielded (no clicks, no hover, no focus ring) while -- one is up; buildModals lowers the shield for the modal's own controls. - imp._modalUpNow = modalUp(imp) + local mkey = modalKey(imp) + if mkey ~= imp._modalKey then + if mkey then + if imp._modalHeld then + Transition.clear("modal") + imp._modalHeld = nil + end + Transition.start("modal", "in") + elseif imp._modalKey and imp._modalLastValue ~= nil then + if Transition.start("modal", "out") then + imp._modalHeld = { key = imp._modalKey, value = imp._modalLastValue } + end + end + imp._modalKey = mkey + end + imp._modalLastValue = mkey and imp[mkey] or nil + if imp._modalHeld and not Transition.active("modal") then + imp._modalHeld = nil + end + imp._modalUpNow = mkey ~= nil if imp._modalUpNow then imp:_blurPanelFields() end - Kit.blockClicks = imp._modalUpNow + Kit.blockClicks = imp._modalUpNow or Transition.active() local step = Kit.scrollStep(m.s) do @@ -5983,32 +6153,25 @@ function LauncherView.draw(imp) if not rect then rect = {}; imp._tabRegionRect = rect end rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH - local at = tabScrollAt(imp) - local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp)) - local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN)) - local panelW = math.max(0, w - Kit.scrollGutter(m.s)) - local contentH - if imp.tab == "mods" then - contentH = buildModsPanel(imp, x, py, panelW, budgetH, m) - elseif imp.tab == "find" then - contentH = buildFindPanel(imp, x, py, panelW, budgetH, m) - elseif imp.tab == "skins" then - contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m) - elseif imp.tab == "bug" then - contentH = buildBugPanel(imp, x, py, panelW, budgetH, m) + local tabTr = Transition.get("tabs") + if tabTr and tabTr.from and tabTr.from ~= tabKeyOf(imp) then + local p = Transition.progress("tabs") + local dir = tabTr.dir >= 0 and 1 or -1 + pcall(drawTabLayer, imp, tabTr.from, x, contentY, w, viewH, availH, m, + -dir * p * w) + drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, + dir * (1 - p) * w) else - contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH) + drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0) end - contentH = contentH or availH - imp._tabContentH[tabKeyOf(imp)] = contentH - imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH) - at = clamp(at, 0, tabScrollMax(imp)) - imp._tabScroll[tabKeyOf(imp)] = at - Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp)) buildFooter(imp, m, footY) - Kit.blockClicks = false + Kit.blockClicks = Transition.active() + local held = imp._modalHeld + if held then imp[held.key] = held.value end buildModals(imp, m) + endModalDraw(m) + if held then imp[held.key] = nil end -- The loader sits above everything, including modals: it is the one thing -- that must never be clicked around. diff --git a/src/import/OnlinePanel.lua b/src/import/OnlinePanel.lua new file mode 100644 index 00000000..a346063f --- /dev/null +++ b/src/import/OnlinePanel.lua @@ -0,0 +1,2927 @@ + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Transition = require("src.ui.kit.Transition") +local Strings = require("src.core.Strings") +local GameVersion = require("src.core.GameVersion") + +local PAL = Theme.PAL + +local EMPTY = {} + +local OnlinePanel = {} + +OnlinePanel.NAME_MIN = 3 +OnlinePanel.NAME_MAX = 16 +OnlinePanel.NOTE_MAX = 48 +OnlinePanel.CODE_LEN = 6 + +OnlinePanel.SIZES = { 1, 2, 3, 4, 5, 6 } +OnlinePanel.FORCE_LEVELS = { false, 50, 100 } + +local LEVELS = { false } +for level = 5, 100, 5 do LEVELS[#LEVELS + 1] = level end +OnlinePanel.LEVELS = LEVELS + +local STAGE_TEXT = { + waiting = "Waiting for a challenger", + ready = "Both trainers are picking a team", + battling = "Battle in progress", + ended = "Match over", +} + +local TOUR_STAGE_TEXT = { + registering = "Waiting for trainers", + running = "Bracket in progress", + finished = "Tournament over", +} + +OnlinePanel.SHOT_CLOCKS = { 3, 6, 9 } +OnlinePanel.TOUR_MAX_PLAYERS = 16 + +local function LV() + return require("src.import.LauncherView") +end + +local BANNED = { [60] = true, [62] = true, [38] = true, [34] = true, [39] = true } + +function OnlinePanel.sanitizeName(text) + text = tostring(text or "") + local out = {} + for i = 1, #text do + local b = text:byte(i) + if b >= 32 and b <= 126 and not BANNED[b] then + out[#out + 1] = string.char(b) + if #out >= OnlinePanel.NAME_MAX then break end + end + end + return (table.concat(out):gsub("^%s+", ""):gsub("%s+$", "")) +end + +function OnlinePanel.nameValid(name) + if type(name) ~= "string" then return false end + local n = #name + return n >= OnlinePanel.NAME_MIN and n <= OnlinePanel.NAME_MAX + and name == OnlinePanel.sanitizeName(name) +end + +function OnlinePanel.threeDigits(random) + random = random or math.random + return ("%03d"):format(random(0, 999)) +end + +function OnlinePanel.defaultName(trainerName, digits) + local base = OnlinePanel.sanitizeName(trainerName) + if base == "" then base = "PLAYER" end + local suffix = "#" .. tostring(digits or "000") + if #base + #suffix > OnlinePanel.NAME_MAX then + base = base:sub(1, OnlinePanel.NAME_MAX - #suffix) + end + return base .. suffix +end + +function OnlinePanel.sanitizeCode(text) + local CodeEntry = require("src.link.CodeEntry") + local charset = CodeEntry.CHARSET + local out = {} + for char in tostring(text or ""):upper():gmatch(".") do + if charset:find(char, 1, true) then + out[#out + 1] = char + if #out >= OnlinePanel.CODE_LEN then break end + end + end + return table.concat(out) +end + +function OnlinePanel.state(imp) + local st = imp._online + if not st then + st = { + version = nil, slotId = nil, cartId = nil, kind = "vanilla", + rule = { partySize = 1, minLevel = nil, maxLevel = nil, forceLevel = nil }, + ruleEdited = false, + team = {}, + note = "", joinCode = "", public = true, + name = nil, nameDraft = nil, + profiles = {}, profileWant = nil, profileBusy = nil, + slotRead = nil, + section = "battle", + engine = nil, converted = nil, convertWant = nil, + trade = nil, roomCart = nil, roomCartKey = nil, cartInstall = nil, + status = nil, statusOk = false, + job = nil, ticket = nil, ticketState = "idle", + connectWanted = false, + ready = false, + pending = nil, + tourPlaying = true, + tourShotClock = 6, + tourSpectators = 8, + wizard = nil, + filter = "all", + } + imp._online = st + end + return st +end + +function OnlinePanel.readyVersions(imp) + local out = {} + for _, version in ipairs(GameVersion.ORDER) do + if imp.ready and imp.ready[version] then out[#out + 1] = version end + end + return out +end + +function OnlinePanel.selectedVersion(imp) + local st = OnlinePanel.state(imp) + if st.version and imp.ready and imp.ready[st.version] then return st.version end + local list = OnlinePanel.readyVersions(imp) + st.version = list[1] + return st.version +end + +function OnlinePanel.isGen2(version) + return GameVersion.generation(version) == 2 +end + +function OnlinePanel.scopeOf(imp) + local st = OnlinePanel.state(imp) + if st.cartId then return "cart:" .. st.cartId end + return OnlinePanel.selectedVersion(imp) +end + +local function cartOf(imp) + return OnlinePanel.state(imp).cartId +end + +function OnlinePanel.scopeFor(version, cartId) + if cartId then return "cart_" .. tostring(cartId) end + return version +end + +function OnlinePanel.slotsIn(imp, version, cartId) + if not version then return {} end + local scope = OnlinePanel.scopeFor(version, cartId) + if type(imp) == "table" and type(imp.slots) == "table" then + if imp.slots[scope] == nil and type(imp._ensureSlots) == "function" then + pcall(imp._ensureSlots, imp, scope) + end + local rows = imp.slots[scope] + return type(rows) == "table" and rows or {} + end + local SaveData = require("src.core.SaveData") + local ok, rows + if cartId then + ok, rows = pcall(SaveData.listCartSlots, cartId) + else + ok, rows = pcall(SaveData.listSlots, version) + end + if not ok or type(rows) ~= "table" then return {} end + return rows +end + +function OnlinePanel.cartsIn(imp, version) + if not version then return {} end + if type(imp) == "table" and type(imp._ensureCarts) == "function" then + local ok, rows = pcall(imp._ensureCarts, imp, version) + if ok and type(rows) == "table" then return rows end + return {} + end + local ok, rows = pcall(require("src.carts.CartStore").listFor, version) + return (ok and type(rows) == "table") and rows or {} +end + +function OnlinePanel.sealedCarts(imp, version) + local out = {} + for _, row in ipairs(OnlinePanel.cartsIn(imp, version)) do + if row.seal == "sealed" then out[#out + 1] = row end + end + return out +end + +function OnlinePanel.slotRows(imp) + local version = OnlinePanel.selectedVersion(imp) + if not version then return {} end + return OnlinePanel.slotsIn(imp, version, cartOf(imp)) +end + +function OnlinePanel.trainerNameFor(imp, version) + if not version then return nil end + local SaveData = require("src.core.SaveData") + local ok, rows = pcall(SaveData.listSlots, version) + if not ok or type(rows) ~= "table" then return nil end + local active = imp.activeSlot and imp.activeSlot[version] or nil + for _, row in ipairs(rows) do + if row.exists and (active == nil or row.id == active) then + if row.name and row.name ~= "" then return row.name end + end + end + for _, row in ipairs(rows) do + if row.name and row.name ~= "" then return row.name end + end + return nil +end + +function OnlinePanel.storedName() + local ok, SyncState = pcall(require, "src.sync.SyncState") + if not ok then return nil end + local loaded, state = pcall(SyncState.load) + if not loaded or type(state) ~= "table" then return nil end + return state.displayName +end + +function OnlinePanel.persistName(name) + local ok, SyncState = pcall(require, "src.sync.SyncState") + if not ok then return false end + return (pcall(SyncState.update, function(state) state.displayName = name end)) +end + +function OnlinePanel.ensureName(imp) + local st = OnlinePanel.state(imp) + if st.name then return st.name end + local stored = OnlinePanel.storedName() + if OnlinePanel.nameValid(stored) then + st.name = stored + return st.name + end + local version = OnlinePanel.selectedVersion(imp) + local name = OnlinePanel.defaultName(OnlinePanel.trainerNameFor(imp, version), + OnlinePanel.threeDigits()) + st.name = name + OnlinePanel.persistName(name) + return name +end + +function OnlinePanel.setName(imp, text) + local st = OnlinePanel.state(imp) + local name = OnlinePanel.sanitizeName(text) + if not OnlinePanel.nameValid(name) then + st.status = Strings("Names are 3 to 16 characters.") + st.statusOk = false + return false, "name too short" + end + st.name = name + OnlinePanel.persistName(name) + if OnlinePanel.linked() then + OnlinePanel.startJob(imp, "displayName", function(client) + return client:setDisplayName(name) + end) + end + return true +end + +function OnlinePanel.linked() + local ok, SyncState = pcall(require, "src.sync.SyncState") + if not ok then return false end + local loaded, state = pcall(SyncState.load) + if not loaded then return false end + return SyncState.linked(state) == true +end + +function OnlinePanel.syncClient(imp) + local engine = imp._syncEngine and imp:_syncEngine() or nil + if engine and engine.client then return engine.client end + local ok, SyncClient = pcall(require, "src.sync.SyncClient") + if not ok then return nil end + local made, client = pcall(SyncClient.new, {}) + if not made or type(client) ~= "table" then return nil end + local loadedState, SyncState = pcall(require, "src.sync.SyncState") + if loadedState then + local got, state = pcall(SyncState.load) + if got and type(state) == "table" then + client:setAuth(state.account, state.deviceToken) + end + end + return client +end + +function OnlinePanel.startJob(imp, kind, begin) + local st = OnlinePanel.state(imp) + if st.job then return false, "busy" end + local client = OnlinePanel.syncClient(imp) + if not client then return false, "no network transport" end + local ok, handle = pcall(begin, client) + if not ok or handle == nil then return false, "no network transport" end + st.job = { kind = kind, handle = handle, client = client } + return true +end + +local function jobFinished(imp, res) + local st = OnlinePanel.state(imp) + local job = st.job + st.job = nil + if job then pcall(job.client.release, job.client, job.handle) end + if not job then return end + if job.kind == "ticket" then + if res.status == "ok" and type(res.data) == "table" + and type(res.data.ticket) == "string" then + st.ticket = res.data.ticket + st.ticketExpiresAt = tonumber(res.data.expiresAt) + st.ticketState = "ok" + else + st.ticket = nil + st.ticketState = (res.code == 404) and "unsupported" or "failed" + if res.code ~= 404 and res.err then + st.status = Strings("Sign-in didn't work: %s", tostring(res.err)) + st.statusOk = false + end + end + if st.connectWanted then OnlinePanel.doConnect(imp) end + elseif job.kind == "displayName" then + if res.status ~= "ok" and res.code ~= 404 then + st.status = Strings("The server kept your old name.") + st.statusOk = false + end + end +end + +function OnlinePanel.profileKey(version, kind, cartId) + return tostring(version) .. "|" .. tostring(kind or "vanilla") + .. "|" .. tostring(cartId or "-") +end + +local function copyRule(rule) + return { partySize = rule.partySize, minLevel = rule.minLevel, + maxLevel = rule.maxLevel, forceLevel = rule.forceLevel } +end + +-- ------------------------------------------------- room engine (Time Capsule) + +function OnlinePanel.installedGenerations(imp) + local out, order = {}, {} + for _, version in ipairs(OnlinePanel.readyVersions(imp)) do + local generation = GameVersion.generation(version) + if not out[generation] then + out[generation] = true + order[#order + 1] = generation + end + end + table.sort(order) + return order, out +end + +function OnlinePanel.engineVersionFor(imp, generation) + for _, version in ipairs(OnlinePanel.readyVersions(imp)) do + if GameVersion.generation(version) == generation then return version end + end + return nil +end + +function OnlinePanel.saveGeneration(imp) + local version = OnlinePanel.selectedVersion(imp) + if not version then return 1 end + return GameVersion.generation(version) +end + +function OnlinePanel.roomEngine(imp) + local st = OnlinePanel.state(imp) + local want = tonumber(st.engine) + if want ~= 1 and want ~= 2 then return OnlinePanel.saveGeneration(imp) end + if not OnlinePanel.engineVersionFor(imp, want) then + return OnlinePanel.saveGeneration(imp) + end + return want +end + +function OnlinePanel.engineVersion(imp) + local engine = OnlinePanel.roomEngine(imp) + local version = OnlinePanel.selectedVersion(imp) + if version and GameVersion.generation(version) == engine then return version end + return OnlinePanel.engineVersionFor(imp, engine) +end + +function OnlinePanel.crossGen(imp) + local version = OnlinePanel.selectedVersion(imp) + if not version then return false end + return GameVersion.generation(version) ~= OnlinePanel.roomEngine(imp) +end + +function OnlinePanel.setEngine(imp, generation) + local st = OnlinePanel.state(imp) + if generation ~= nil and not OnlinePanel.engineVersionFor(imp, generation) then + st.status, st.statusOk = + Strings("That generation is not imported yet."), false + return false + end + st.engine = generation + st.team, st.ready = {}, false + st.converted, st.convertWant = nil, nil + return true +end + +function OnlinePanel.myProfile(imp) + local st = OnlinePanel.state(imp) + local version = OnlinePanel.engineVersion(imp) + if not version then return nil, Strings("Import a game first.") end + local cross = OnlinePanel.crossGen(imp) + local kind = cross and "vanilla" or st.kind + local cartId = st.cartId + if cross then cartId = nil end + local key = OnlinePanel.profileKey(version, kind, cartId) + local hit = st.profiles[key] + if not hit then + st.profileWant = key + return nil, nil + end + if not hit.profile then return nil, hit.reason end + hit.profile.rule = copyRule(st.rule) + return hit.profile +end + +function OnlinePanel.computeProfile(imp, key) + local st = OnlinePanel.state(imp) + local version, kind, cartId = key:match("^([^|]*)|([^|]*)|(.*)$") + if cartId == "-" then cartId = nil end + local ArenaData = require("src.online.ArenaData") + local ok, profile, reason = pcall(ArenaData.profile, version, kind, cartId, + st.rule) + if not ok then + st.profiles[key] = { profile = nil, reason = tostring(profile) } + else + st.profiles[key] = { profile = profile, reason = reason } + end + st.profileWant = nil + return st.profiles[key] +end + +function OnlinePanel.readTeamSlot(imp) + local st = OnlinePanel.state(imp) + local version = OnlinePanel.selectedVersion(imp) + if not version or not st.slotId then return nil, Strings("Pick a save first.") end + local key = table.concat({ version, st.slotId, st.cartId or "-" }, "|") + if st.slotRead and st.slotRead.key == key then + return st.slotRead.data, st.slotRead.reason + end + local TeamPick = require("src.online.TeamPick") + local ok, data, reason = pcall(TeamPick.readSlot, version, st.slotId, st.cartId) + if not ok then data, reason = nil, tostring(data) end + st.slotRead = { key = key, data = data, reason = reason } + return data, reason +end + +OnlinePanel.TEAM_MAX = 6 + +local function TeamPickMod() + return require("src.online.TeamPick") +end + +function OnlinePanel.refKey(ref) + return TeamPickMod().refKey(ref) +end + +local function asRef(ref) + if type(ref) == "number" then return { where = "party", index = ref } end + return ref +end + +OnlinePanel.asRef = asRef + +function OnlinePanel.toggleTeam(team, ref, maxSize) + if type(team) ~= "table" then return {} end + ref = asRef(ref) + if type(ref) ~= "table" then return team end + local key = OnlinePanel.refKey(ref) + for i = 1, #team do + if OnlinePanel.refKey(team[i]) == key then + table.remove(team, i) + return team + end + end + local cap = tonumber(maxSize) or OnlinePanel.TEAM_MAX + if cap > OnlinePanel.TEAM_MAX then cap = OnlinePanel.TEAM_MAX end + if #team >= cap then return team end + team[#team + 1] = ref + return team +end + +function OnlinePanel.teamOrder(team, ref) + local key = OnlinePanel.refKey(asRef(ref)) + for i = 1, #(team or {}) do + if OnlinePanel.refKey(team[i]) == key then return i end + end + return nil +end + +function OnlinePanel.teamHasBox(team) + for _, ref in ipairs(team or {}) do + if type(ref) == "table" and ref.where == "box" then return true end + end + return false +end + +function OnlinePanel.teamIndices(team) + local out = {} + for _, ref in ipairs(team or {}) do + local r = asRef(ref) + if type(r) ~= "table" or r.where == "box" then return nil end + out[#out + 1] = tonumber(r.index) + end + if #out == 0 then return nil end + return out +end + +function OnlinePanel.validateTeam(imp) + local st = OnlinePanel.state(imp) + local pick, reason = OnlinePanel.readTeamSlot(imp) + if not pick then return false, reason or Strings("Pick a save first.") end + local TeamPick = TeamPickMod() + local free = { minLevel = nil, maxLevel = nil, forceLevel = st.rule.forceLevel, + partySize = #(st.team or {}) } + local ok, why = TeamPick.validate(pick, st.team, free) + if not ok then return false, why end + local conv = OnlinePanel.convertedTeam(imp) + if not conv then return true end + for _, ref in ipairs(st.team) do + if not conv.byKey[OnlinePanel.refKey(ref)] then + return false, Strings("One of those can't cross generations.") + end + end + return true +end + +function OnlinePanel.ruleFor(imp) + local st = OnlinePanel.state(imp) + if not st.ruleEdited then + st.rule.partySize = math.max(1, #(st.team or {})) + end + return st.rule +end + +function OnlinePanel.editRule(imp) + local st = OnlinePanel.state(imp) + st.ruleEdited = true + return st.rule +end + +function OnlinePanel.ruleMismatch(imp, rule, what) + local st = OnlinePanel.state(imp) + rule = type(rule) == "table" and rule or {} + local team = st.team or {} + local want = tonumber(rule.partySize) + if want and #team ~= want then + return Strings("%s needs %d Pokemon, you picked %d.", + what or Strings("This room"), want, #team) + end + local pick = OnlinePanel.readTeamSlot(imp) + if not pick then return nil end + local ok, why = TeamPickMod().validate(pick, team, rule) + if ok then return nil end + return tostring(why):gsub("\n", " ") +end + +-- ------- Time Capsule team preview + +function OnlinePanel.convertParty(party, fromVersion, toVersion) + local Trade = require("src.online.Trade") + local TeamPick = require("src.online.TeamPick") + local fromGen = GameVersion.generation(fromVersion) + local toGen = GameVersion.generation(toVersion) + if fromGen == toGen then return nil, "same generation" end + local gen1Version = (fromGen == 1) and fromVersion or toVersion + local gen2Version = (fromGen == 2) and fromVersion or toVersion + local gen2Data, err = Trade.withDataset(gen2Version, function(d) return d end) + if not gen2Data then return nil, tostring(err) end + local out + local ok, why = Trade.withDataset(gen1Version, function(gen1Data) + local fromData = (fromGen == 1) and gen1Data or gen2Data + local toData = (toGen == 1) and gen1Data or gen2Data + local byKey, rows = TeamPick.convert(party, toGen, fromData, toData) + out = { byKey = byKey, rows = rows, generation = toGen } + return true + end) + if not ok then return nil, tostring(why) end + return out +end + +function OnlinePanel.convertKey(imp) + local st = OnlinePanel.state(imp) + if not OnlinePanel.crossGen(imp) then return nil end + if not st.slotId then return nil end + return table.concat({ OnlinePanel.selectedVersion(imp) or "-", st.slotId, + st.cartId or "-", OnlinePanel.engineVersion(imp) or "-" }, "|") +end + +function OnlinePanel.convertedTeam(imp) + local st = OnlinePanel.state(imp) + local key = OnlinePanel.convertKey(imp) + if not key then + st.converted, st.convertWant = nil, nil + return nil + end + local hit = st.converted + if hit and hit.key == key then return hit.data, hit.reason end + st.convertWant = key + return nil, nil +end + +function OnlinePanel.computeConverted(imp, key) + local st = OnlinePanel.state(imp) + local pick, reason = OnlinePanel.readTeamSlot(imp) + if not pick then + st.converted = { key = key, data = nil, reason = reason } + st.convertWant = nil + return st.converted + end + local data, why = OnlinePanel.convertParty(pick, + OnlinePanel.selectedVersion(imp), OnlinePanel.engineVersion(imp)) + st.converted = { key = key, data = data, reason = why } + st.convertWant = nil + return st.converted +end + +local function convRow(imp, ref) + local conv = OnlinePanel.convertedTeam(imp) + if not conv then return nil end + return (conv.rows or {})[OnlinePanel.refKey(asRef(ref))] +end + +function OnlinePanel.monRefusal(imp, ref) + local row = convRow(imp, ref) + if not row or row.ok then return nil end + return row.reason or "refused", row.preview +end + +function OnlinePanel.monPreview(imp, ref) + local row = convRow(imp, ref) + return row and row.preview or nil +end + +function OnlinePanel.packForRoom(imp, source, team, generation) + local TeamPick = require("src.online.TeamPick") + local conv = OnlinePanel.convertedTeam(imp) + if not conv then return TeamPick.pack(source, team, generation) end + return TeamPick.packConverted(conv.byKey, team, conv.generation) +end + +local function serialise(value, out) + local kind = type(value) + if kind == "table" then + local keys = {} + for key in pairs(value) do keys[#keys + 1] = key end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + out[#out + 1] = "{" + for _, key in ipairs(keys) do + out[#out + 1] = tostring(key) .. "=" + serialise(value[key], out) + out[#out + 1] = ";" + end + out[#out + 1] = "}" + else + out[#out + 1] = tostring(value) + end +end + +function OnlinePanel.partyDigest(packed) + local Fingerprint = require("src.link.Fingerprint") + local out = {} + serialise(packed or {}, out) + return Fingerprint.digest(table.concat(out)) +end + +function OnlinePanel.joinReason(entry, myProfile) + if type(entry) ~= "table" then return "no listing" end + if type(myProfile) ~= "table" then return "pick a game first" end + local ArenaData = require("src.online.ArenaData") + if ArenaData.equal(entry.profile, myProfile) then return nil end + return ArenaData.describeMismatch(entry.profile, myProfile) or "that game differs" +end + +function OnlinePanel.ruleText(rule) + rule = type(rule) == "table" and rule or {} + local parts = { ("%d v %d"):format(rule.partySize or 6, rule.partySize or 6) } + if rule.forceLevel then + parts[#parts + 1] = ("all Lv%d"):format(rule.forceLevel) + else + if rule.minLevel then parts[#parts + 1] = ("Lv%d+"):format(rule.minLevel) end + if rule.maxLevel then parts[#parts + 1] = ("Lv%d-"):format(rule.maxLevel) end + end + return table.concat(parts, ", ") +end + +function OnlinePanel.arenaText(profile) + if type(profile) ~= "table" then return "" end + if profile.kind == "cart" and profile.cart then + return tostring(profile.cart.id) + end + return "vanilla" +end + +local function Client() + return require("src.online.Client") +end + +local ensureHooks +local finishTo + +function OnlinePanel.mySeatId() + local you = Client().you() + return type(you) == "table" and you.id or nil +end + +function OnlinePanel.resultText(msg) + if type(msg) ~= "table" then return nil end + if msg.youWon == true then return "win" end + local winner = msg.winnerId + if winner == nil then winner = msg.winner end + if winner == nil or winner == false then return "draw" end + local me = OnlinePanel.mySeatId() + if me == nil then return "ended" end + if winner == me then return "win" end + return "lose" +end + +OnlinePanel.RESULT_TEXT = { + win = "You won.", lose = "You lost.", draw = "It was a draw.", + ended = "The match ended.", error = "The match ended early.", +} + +function OnlinePanel.recordResult(result) + if type(result) ~= "string" then return end + OnlinePanel.lastResult = result + local client = Client() + if type(client.report) ~= "function" then return end + if client.role() == "spectator" then return end + pcall(client.report, result) +end + +-- -------------------------------------------------------------------- trade + +local function Sprites() + return require("src.online.OnlineSprites") +end + +function OnlinePanel.primeSprites(imp) + local st = OnlinePanel.state(imp) + local version = OnlinePanel.selectedVersion(imp) + local key = table.concat({ version or "-", st.slotId or "-", + st.cartId or "-", imp._pcPicker and "pc" or "-" }, "|") + if st.spriteKey == key then return false end + st.spriteKey = key + local pick = st.slotId and OnlinePanel.readTeamSlot(imp) or nil + local keep = {} + if version then keep[#keep + 1] = version end + local tr = st.trade + for _, side in ipairs({ "a", "b" }) do + local entry = tr and tr.sides and tr.sides[side] + if entry then keep[#keep + 1] = entry.version end + end + Sprites().keepOnly(keep) + if pick then + Sprites().prime(version, pick.party) + if imp._pcPicker then + local TeamPick = require("src.online.TeamPick") + local mons = {} + for _, row in ipairs(TeamPick.candidates(pick)) do + if row.where == "box" then mons[#mons + 1] = row.mon end + end + Sprites().prime(version, mons) + end + end + return true +end + +local function pretty(id) + local text = tostring(id or "?"):gsub("_", " ") + return text +end + +local function monLabel(mon) + if type(mon) ~= "table" then return "?" end + local name = mon.nickname + if type(name) ~= "string" or name == "" then name = mon.species or mon.name end + return ("%s Lv%d"):format(pretty(name), tonumber(mon.level) or 0) +end + +OnlinePanel.monLabel = monLabel + +OnlinePanel.TRADE_STAGE_TEXT = { + waitRecords = "Comparing POKeMON with the other game", + waitParty = "Waiting for the other party", + picking = "Tap the POKeMON you want to trade", + waitPick = "Waiting for the other trainer to pick", + confirming = "Confirm the trade", + done = "Trade complete", + cancelled = "The trade was called off", +} + +function OnlinePanel.remoteStageText(stage) + local text = OnlinePanel.TRADE_STAGE_TEXT[tostring(stage)] + return Strings(text or tostring(stage)) +end + +function OnlinePanel.tradeState(imp) + local st = OnlinePanel.state(imp) + if not st.trade then + st.trade = { + mode = "local", + sides = {}, picks = {}, handles = {}, + plan = nil, lines = nil, convertLines = nil, + status = nil, statusOk = false, + code = "", remote = nil, remoteError = nil, remoteResult = nil, + } + end + return st.trade +end + +function OnlinePanel.sameSlot(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + return a.version == b.version and a.slotId == b.slotId + and a.cartId == b.cartId +end + +function OnlinePanel.tradeSlots(imp) + local out = {} + for _, version in ipairs(OnlinePanel.readyVersions(imp)) do + local info = GameVersion.info(version) + local gameName = (info and (info.launcherName or info.label)) or version + for _, row in ipairs(OnlinePanel.slotsIn(imp, version, nil)) do + if row.exists then + out[#out + 1] = { version = version, slotId = row.id, cartId = nil, + generation = GameVersion.generation(version), + label = ("%s %s"):format(gameName, + tostring(row.label or row.name or row.id)) } + end + end + for _, cart in ipairs(OnlinePanel.sealedCarts(imp, version)) do + for _, row in ipairs(OnlinePanel.slotsIn(imp, version, cart.id)) do + if row.exists and not row.sealBroken then + out[#out + 1] = { version = version, slotId = row.id, + cartId = cart.id, generation = GameVersion.generation(version), + label = ("%s %s"):format(tostring(cart.title or cart.id), + tostring(row.label or row.name or row.id)) } + end + end + end + end + return out +end + +function OnlinePanel.tradeSideView(imp, side) + local tr = OnlinePanel.tradeState(imp) + local hit = tr.handles[side] + if hit and OnlinePanel.sameSlot(hit.entry, tr.sides[side]) then return hit end + return nil +end + +function OnlinePanel.tradeOpen(imp, side) + local tr = OnlinePanel.tradeState(imp) + local entry = tr.sides[side] + if type(entry) ~= "table" then + tr.handles[side] = nil + return nil, Strings("Pick a save on both sides.") + end + local Trade = require("src.online.Trade") + local ok, handle, reason = pcall(Trade.openSlot, entry.version, entry.slotId, + entry.cartId) + if not ok then handle, reason = nil, tostring(handle) end + tr.handles[side] = { entry = entry, handle = handle, reason = reason } + if handle then Sprites().prime(entry.version, handle.party) end + return handle, reason +end + +function OnlinePanel.tradeSetSide(imp, side, entry) + local tr = OnlinePanel.tradeState(imp) + if side ~= "a" and side ~= "b" then return false, "unknown side" end + local other = tr.sides[side == "a" and "b" or "a"] + if entry and OnlinePanel.sameSlot(entry, other) then + tr.status, tr.statusOk = Strings("Pick two different saves."), false + return false, "same slot" + end + tr.sides[side] = entry + tr.picks[side] = nil + tr.plan, tr.lines, tr.convertLines = nil, nil, nil + tr.handles[side] = nil + if entry then OnlinePanel.tradeOpen(imp, side) end + return true +end + +function OnlinePanel.tradeCycleSide(imp, side, delta) + local tr = OnlinePanel.tradeState(imp) + local rows = OnlinePanel.tradeSlots(imp) + if #rows == 0 then return false end + local at = 0 + for i, row in ipairs(rows) do + if OnlinePanel.sameSlot(row, tr.sides[side]) then at = i end + end + for _ = 1, #rows do + at = ((at - 1 + (delta or 1)) % #rows) + 1 + local row = rows[at] + if not OnlinePanel.sameSlot(row, tr.sides[side == "a" and "b" or "a"]) then + return OnlinePanel.tradeSetSide(imp, side, row) + end + end + return false +end + +function OnlinePanel.tradePick(imp, side, ref) + local tr = OnlinePanel.tradeState(imp) + local as = asRef(ref) + if type(as) == "table" and as.where ~= "box" then ref = tonumber(as.index) end + local key = type(as) == "table" and OnlinePanel.refKey(as) or nil + local had = tr.picks[side] + if key and had and OnlinePanel.refKey(asRef(had)) == key then + tr.picks[side] = nil + else + tr.picks[side] = ref + end + tr.plan, tr.lines, tr.convertLines = nil, nil, nil + return tr.picks[side] +end + +function OnlinePanel.tradePickKey(imp, side) + local pick = OnlinePanel.tradeState(imp).picks[side] + if pick == nil then return nil end + return OnlinePanel.refKey(asRef(pick)) +end + +function OnlinePanel.tradeBoxName(handle, box) + return TeamPickMod().boxName(handle.save, handle.generation, box) +end + +local function boxRow(handle, entry, order) + local mon = entry.mon + local maxHp = tonumber(mon.maxHp or (mon.stats and mon.stats.hp)) or 0 + return { + ref = { where = "box", box = entry.box, index = entry.index }, + key = OnlinePanel.refKey(entry), + mon = mon, version = handle.version, where = "box", + source = entry.source or OnlinePanel.tradeBoxName(handle, entry.box), + order = order, + name = tostring(mon.nickname or mon.species or mon.name or "?"), + label = ("%s Lv%d %d/%d HP"):format( + tostring(mon.species or mon.name or "?"), tonumber(mon.level) or 0, + tonumber(mon.hp) or 0, maxHp), + } +end + +function OnlinePanel.tradeBoxRow(handle, ref) + if type(handle) ~= "table" or type(ref) ~= "table" then return nil end + local mon = TeamPickMod().monAt(handle, ref) + if type(mon) ~= "table" then return nil end + local row = boxRow(handle, { box = ref.box, index = ref.index, mon = mon, + where = "box" }, 1) + row.label = ("%s %s"):format(OnlinePanel.monLabel(mon), row.source) + row.picked, row.pickable = true, not mon.isEgg + return row +end + +function OnlinePanel.tradeBoxRows(imp, side) + local view = OnlinePanel.tradeSideView(imp, side) + local handle = view and view.handle or nil + if not handle then return {} end + local picked = OnlinePanel.tradePickKey(imp, side) + local out = {} + for _, entry in ipairs(TeamPickMod().candidates(handle)) do + if entry.where == "box" then + local row = boxRow(handle, entry, nil) + if row.key == picked then row.order = 1 end + out[#out + 1] = row + end + end + return out +end + +function OnlinePanel.tradePcAllowed(imp, side) + local tr = OnlinePanel.tradeState(imp) + if tr.mode ~= "local" or tr.remote then return false end + local view = OnlinePanel.tradeSideView(imp, side) + return (view and view.handle) ~= nil +end + +function OnlinePanel.tradeMode(imp, mode) + local tr = OnlinePanel.tradeState(imp) + if mode ~= "local" and mode ~= "remote" then return false end + if tr.mode == mode then return true end + tr.mode = mode + tr.plan, tr.lines, tr.convertLines = nil, nil, nil + tr.status, tr.statusOk = nil, false + return true +end + +function OnlinePanel.tradeRun(imp, fn) + local tr = OnlinePanel.tradeState(imp) + local a = OnlinePanel.tradeSideView(imp, "a") + local b = OnlinePanel.tradeSideView(imp, "b") + local A, B = a and a.handle, b and b.handle + if not A or not B then + return nil, (a and a.reason) or (b and b.reason) + or Strings("Pick a save on both sides.") + end + tr.convertLines = {} + if A.generation == B.generation then + local ok, out, why = pcall(fn, nil) + if not ok then return nil, tostring(out) end + return out, why + end + local Convert = require("src.online.Convert") + local Trade = require("src.online.Trade") + local g1 = (A.generation == 1) and A or B + local g2 = (A.generation == 2) and A or B + if g1.generation ~= 1 or g2.generation ~= 2 then + return nil, Strings("Those two games can't trade.") + end + local gen2Data, err = Trade.withDataset(g2.version, function(d) return d end) + if not gen2Data then return nil, tostring(err) end + local out, why + local ran, e = Trade.withDataset(g1.version, function(gen1Data) + g1.data, g2.data = gen1Data, gen2Data + out, why = fn(function(packed, fromGen, toGen) + local fromData = (fromGen == 1) and gen1Data or gen2Data + local toData = (toGen == 1) and gen1Data or gen2Data + local lines, legal = Convert.preview(packed, fromGen, toGen, fromData, + toData) + tr.convertLines[#tr.convertLines + 1] = + { toGen = toGen, lines = lines, ok = legal ~= false } + if toGen == 2 then + local mon, report = Convert.toGen2(packed, gen1Data, gen2Data) + if mon then return mon end + return nil, report + end + local mon, reason = Convert.toGen1(packed, gen2Data, gen1Data) + if mon then return mon end + return nil, reason + end) + return true + end) + g1.data, g2.data = nil, nil + if not ran then return nil, tostring(e) end + return out, why +end + +function OnlinePanel.tradeLines(plan, labels, convertLines) + local out = {} + if type(plan) ~= "table" then return out end + labels = type(labels) == "table" and labels or {} + for _, side in ipairs(plan.sides or {}) do + local who = labels[side.role or "a"] or Strings("You") + out[#out + 1] = Strings("%s gives %s and gets %s", who, + monLabel(side.sent), monLabel(side.received or side.record)) + if side.evolveTo then + out[#out + 1] = Strings("%s evolves into %s", + pretty((side.received or {}).species), pretty(side.evolveTo)) + end + end + for _, row in ipairs(plan.warnings or {}) do + if row.code == "item_used" then + out[#out + 1] = Strings("%s is used up.", pretty(row.item)) + end + end + for _, row in ipairs(convertLines or {}) do + for _, line in ipairs(row.lines or {}) do out[#out + 1] = line end + end + return out +end + +function OnlinePanel.tradePreview(imp) + local tr = OnlinePanel.tradeState(imp) + tr.plan, tr.lines = nil, nil + if not (tr.picks.a and tr.picks.b) then + tr.status, tr.statusOk = Strings("Tap a POKeMON on each side."), false + return false + end + local Trade = require("src.online.Trade") + local a, b = OnlinePanel.tradeSideView(imp, "a"), OnlinePanel.tradeSideView(imp, "b") + local plan, reason = OnlinePanel.tradeRun(imp, function(convert) + return Trade.plan({ from = a.handle, to = b.handle, + fromIndex = tr.picks.a, toIndex = tr.picks.b, convert = convert }) + end) + if not plan then + local said = tostring(reason or "that trade can't be made") + for _, row in ipairs(tr.convertLines or {}) do + if row.ok == false and row.lines and row.lines[1] then + said = row.lines[1] + break + end + end + tr.status, tr.statusOk = said, false + return false + end + tr.plan = plan + for _, side in ipairs(plan.sides or {}) do + local version = side.handle and side.handle.version + Sprites().prime(version, { side.sent, side.received or side.record }) + end + tr.lines = OnlinePanel.tradeLines(plan, + { a = (tr.sides.a or {}).label, b = (tr.sides.b or {}).label }, + tr.convertLines) + tr.status, tr.statusOk = Strings("Check the trade, then confirm."), true + return true +end + +function OnlinePanel.tradeConfirm(imp) + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + if not tr.plan then + tr.status, tr.statusOk = Strings("Preview the trade first."), false + return false + end + local Trade = require("src.online.Trade") + local plan = tr.plan + local res, why = OnlinePanel.tradeRun(imp, function() + local ok, result, backups = Trade.commit(plan) + return { ok = ok, result = result, backups = backups } + end) + if type(res) ~= "table" then + tr.status, tr.statusOk = tostring(why or "that trade didn't go through"), false + return false + end + if not res.ok then + tr.status, tr.statusOk = tostring(res.result), false + return false + end + for _, side in ipairs(plan.sides or {}) do + pcall(Trade.pruneBackups, side.handle.path, 3) + end + tr.plan, tr.lines, tr.convertLines = nil, nil, nil + tr.picks = {} + tr.handles = {} + st.slotRead, st.converted, st.convertWant = nil, nil, nil + OnlinePanel.tradeOpen(imp, "a") + OnlinePanel.tradeOpen(imp, "b") + if type(imp) == "table" and type(imp.savesChanged) == "function" then + for _, side in ipairs({ tr.sides.a, tr.sides.b }) do + if side then + pcall(imp.savesChanged, imp, + OnlinePanel.scopeFor(side.version, side.cartId)) + end + end + end + tr.status, tr.statusOk = Strings("Trade complete."), true + return true +end + +-- ------- the preview / confirm modal + +function OnlinePanel.tradeChangeLines(plan, convertLines) + local out = {} + if type(plan) ~= "table" then return out end + for _, side in ipairs(plan.sides or {}) do + if side.evolveTo then + out[#out + 1] = Strings("%s evolves into %s", + pretty((side.received or {}).species), pretty(side.evolveTo)) + end + end + for _, row in ipairs(plan.warnings or {}) do + if row.code == "item_used" then + out[#out + 1] = Strings("%s is used up.", pretty(row.item)) + end + end + for _, row in ipairs(convertLines or {}) do + for _, line in ipairs(row.lines or {}) do out[#out + 1] = line end + end + return out +end + +function OnlinePanel.tradeResultLines(plan, labels) + local out = {} + if type(plan) ~= "table" then return out end + labels = type(labels) == "table" and labels or {} + for _, side in ipairs(plan.sides or {}) do + local who = labels[side.role or "a"] or Strings("You") + out[#out + 1] = Strings("%s now holds %s", who, + monLabel(side.record or side.received)) + end + return out +end + +local function modalSide(plan, role) + for _, side in ipairs((plan or {}).sides or {}) do + if side.role == role then return side end + end + return nil +end + +function OnlinePanel.tradeModal(imp) + return type(imp) == "table" and imp._tradeModal or nil +end + +OnlinePanel.TRADE_MODAL_CANCEL = "online-trade-modal-cancel" +OnlinePanel.TRADE_MODAL_CONFIRM = "online-trade-modal-confirm" +OnlinePanel.TRADE_MODAL_DONE = "online-trade-modal-done" + +function OnlinePanel.tradeModalOpen(imp) + local tr = OnlinePanel.tradeState(imp) + if not tr.plan then return false end + local side = modalSide(tr.plan, "a") or (tr.plan.sides or {})[1] + if not side then return false end + local version = side.handle and side.handle.version + local give, get = side.sent, side.received or side.record + for _, row in ipairs(tr.plan.sides or {}) do + Sprites().prime(row.handle and row.handle.version, + { row.sent, row.received or row.record }) + end + imp._tradeModal = { + view = "preview", + give = { mon = give, version = version, label = monLabel(give) }, + get = { mon = get, version = version, label = monLabel(get) }, + lines = OnlinePanel.tradeChangeLines(tr.plan, tr.convertLines), + labels = { a = (tr.sides.a or {}).label, b = (tr.sides.b or {}).label }, + ok = false, message = nil, resultLines = nil, + } + Kit.setFocus(OnlinePanel.TRADE_MODAL_CANCEL) + return true +end + +function OnlinePanel.tradeModalPreview(imp) + if not OnlinePanel.tradePreview(imp) then return false end + return OnlinePanel.tradeModalOpen(imp) +end + +function OnlinePanel.tradeModalClose(imp) + if type(imp) ~= "table" or not imp._tradeModal then return false end + imp._tradeModal = nil + Kit.setFocus(nil) + return true +end + +function OnlinePanel.tradeModalConfirm(imp) + local mo = OnlinePanel.tradeModal(imp) + if not mo or mo.view ~= "preview" then return false end + local tr = OnlinePanel.tradeState(imp) + local plan = tr.plan + local ok = OnlinePanel.tradeConfirm(imp) + mo.view = "result" + mo.ok = ok == true + mo.message = ok and Strings("Traded.") or tostring(tr.status or "") + mo.resultLines = ok and OnlinePanel.tradeResultLines(plan, mo.labels) or {} + Kit.setFocus(OnlinePanel.TRADE_MODAL_DONE) + return mo.ok +end + +function OnlinePanel.tradeModalAction(imp, action) + local mo = OnlinePanel.tradeModal(imp) + if not mo then return false end + if action == "b" then return OnlinePanel.tradeModalClose(imp) end + if action ~= "a" then return false end + if mo.view == "result" then return OnlinePanel.tradeModalClose(imp) end + OnlinePanel.tradeModalConfirm(imp) + return true +end + +-- ------- remote trade + +function OnlinePanel.remoteTradeRefusal(imp) + if OnlinePanel.crossGen(imp) then + return Strings("Both players need the same game generation for now.") + end + local st = OnlinePanel.state(imp) + if not st.slotId then return Strings("Pick a save first.") end + return nil +end + +function OnlinePanel.hostTrade(imp) + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + local why = OnlinePanel.remoteTradeRefusal(imp) + if why then + tr.status, tr.statusOk = why, false + st.status, st.statusOk = why, false + return false + end + ensureHooks() + local profile, reason = OnlinePanel.myProfile(imp) + if not profile then + tr.status, tr.statusOk = reason or Strings("Reading your game..."), false + st.status, st.statusOk = tr.status, false + return false + end + st.pending = Client().createRoom({ intent = "trade", profile = profile, + playing = true, maxSpectators = 0, public = st.public ~= false, + note = (st.note ~= "" and st.note) or nil }) + tr.status, tr.statusOk = Strings("Waiting for the other trainer."), true + return true +end + +function OnlinePanel.joinTrade(imp, code) + local tr = OnlinePanel.tradeState(imp) + local why = OnlinePanel.remoteTradeRefusal(imp) + if why then + tr.status, tr.statusOk = why, false + return false + end + return OnlinePanel.joinByCode(imp, code or tr.code, "player") +end + +function OnlinePanel.beginRemoteTrade(imp, payload) + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + tr.mode, tr.chosen = "remote", true + OnlinePanel.go(imp, "trade") + tr.remote, tr.remoteError, tr.remoteResult = nil, nil, nil + tr.remoteDone, tr.peerPrimed = false, false + local why = OnlinePanel.remoteTradeRefusal(imp) + if why then + tr.remoteError = why + pcall(function() Client().leaveRoom() end) + return false + end + local Trade = require("src.online.Trade") + local ok, handle, reason = pcall(Trade.openSlot, + OnlinePanel.selectedVersion(imp), st.slotId, st.cartId) + if not ok then handle, reason = nil, tostring(handle) end + if not handle then + tr.remoteError = tostring(reason) + pcall(function() Client().leaveRoom() end) + return false + end + local remote, err = Trade.remote(handle, Client().roomSession(), + { peerName = payload and payload.peerName, strict = true }) + if not remote then + tr.remoteError = tostring(err) + pcall(function() Client().leaveRoom() end) + return false + end + tr.remote = remote + tr.peerName = (payload and payload.peerName) or Strings("The other trainer") + Sprites().prime(handle.version, handle.party) + local started = pcall(function() remote:start() end) + if not started then + tr.remoteError = Strings("The trade didn't start.") + OnlinePanel.endRemoteTrade(imp) + return false + end + return true +end + +function OnlinePanel.endRemoteTrade(imp) + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + local remote = tr.remote + tr.remote = nil + tr.handles = {} + st.ready, st.slotRead = false, nil + if remote then pcall(function() remote:close() end) end + pcall(function() Client().leaveRoom() end) + OnlinePanel.clearPresence() +end + +function OnlinePanel.remoteRows(remote) + local mine, theirs = {}, {} + if type(remote) ~= "table" then return mine, theirs end + local session = remote.session + if type(session) ~= "table" then return mine, theirs end + local party = (type(remote.handle) == "table" and remote.handle.party) or {} + for index, mon in ipairs(party) do + local pickable = true + if type(session.canPick) == "function" then + local ok, allowed = pcall(session.canPick, session, index) + pickable = ok and allowed == true + end + mine[#mine + 1] = { index = index, ref = index, + key = "party|" .. index, label = monLabel(mon), mon = mon, + version = remote.handle and remote.handle.version, + pickable = pickable, picked = session.myPick == index } + end + for index, mon in ipairs(session.theirParty or {}) do + theirs[#theirs + 1] = { index = index, ref = index, + key = "party|" .. index, label = monLabel(mon), mon = mon, + version = remote.handle and remote.handle.version, + picked = session.theirPick == index } + end + return mine, theirs +end + +function OnlinePanel.remotePick(imp, index) + local tr = OnlinePanel.tradeState(imp) + if not tr.remote then return false end + local ok, done, reason = pcall(function() return tr.remote:pick(index) end) + if not ok then + tr.remoteError = tostring(done) + return false + end + if not done then + tr.status, tr.statusOk = tostring(reason), false + return false + end + return true +end + +function OnlinePanel.remoteConfirm(imp, yes) + local tr = OnlinePanel.tradeState(imp) + if not tr.remote then return false end + return (pcall(function() tr.remote:confirm(yes and true or false) end)) +end + +function OnlinePanel.pumpRemoteTrade(imp) + local tr = OnlinePanel.state(imp).trade + if not tr or not tr.remote then return end + local ok, stage = pcall(function() return tr.remote:update() end) + if not ok then + tr.remoteError = tostring(stage) + OnlinePanel.endRemoteTrade(imp) + return + end + local session = tr.remote.session + if session and session.theirParty and not tr.peerPrimed then + tr.peerPrimed = true + Sprites().prime(tr.remote.handle.version, session.theirParty) + end + if tr.remoteDone then return end + if stage == "done" then + tr.remoteDone = true + local committed, result = tr.remote:commit() + if committed then + pcall(require("src.online.Trade").pruneBackups, + tr.remote.handle.path, 3) + tr.remoteResult = Strings("Trade complete.") + else + tr.remoteResult = tostring(result) + end + OnlinePanel.endRemoteTrade(imp) + elseif stage == "cancelled" then + tr.remoteDone = true + tr.remoteResult = tostring((tr.remote.session or {}).error + or Strings("The trade was called off.")) + OnlinePanel.endRemoteTrade(imp) + end +end + +-- ------- installing a room's cart + +function OnlinePanel.cartNeed(profile) + if type(profile) ~= "table" or profile.kind ~= "cart" then return nil end + local cart = profile.cart + if type(cart) ~= "table" or type(cart.id) ~= "string" then return nil end + local CartStore = require("src.carts.CartStore") + local ok, got, hash = pcall(CartStore.get, cart.id) + if not ok or not got then + return { id = cart.id, version = cart.version, base = profile.version, + reason = "missing" } + end + if type(cart.hash) == "string" and hash ~= cart.hash then + return { id = cart.id, version = cart.version, base = profile.version, + reason = "hash" } + end + return nil +end + +function OnlinePanel.installCart(imp, need) + local st = OnlinePanel.state(imp) + if type(need) ~= "table" then return false end + if type(imp.installCartForOnline) ~= "function" then + st.status, st.statusOk = Strings("This build can't install carts."), false + return false + end + st.cartInstall = { id = need.id } + local started = imp:installCartForOnline(need.id, need.base, + function(ok, text) + st.cartInstall = nil + st.status, st.statusOk = tostring(text), ok == true + st.profiles, st.profileWant = {}, nil + st.roomCart, st.roomCartKey = nil, nil + end) + if not started then st.cartInstall = nil end + return started and true or false +end + +-- ------------------------------------------------------------- tournaments + +function OnlinePanel.tourPlayerMap(tour) + local out = {} + for _, player in ipairs(type(tour) == "table" and tour.players or {}) do + if player.id then out[player.id] = player end + end + return out +end + +function OnlinePanel.tourName(tour, id) + if id == nil then return nil end + local player = OnlinePanel.tourPlayerMap(tour)[id] + if player and player.name then return player.name end + return tostring(id) +end + +function OnlinePanel.liveMatch(tour) + if type(tour) ~= "table" then return nil end + for _, round in ipairs(tour.bracket or {}) do + for _, entry in ipairs(round.matches or {}) do + if entry.state == "live" + or (tour.live ~= nil and entry.match == tour.live) then + return entry, round.round + end + end + end + return nil +end + +function OnlinePanel.bracketColumns(tour) + local columns = {} + if type(tour) ~= "table" then return columns end + local live = OnlinePanel.liveMatch(tour) + for _, round in ipairs(tour.bracket or {}) do + local column = { round = round.round or (#columns + 1), matches = {} } + for _, entry in ipairs(round.matches or {}) do + column.matches[#column.matches + 1] = { + match = entry.match, + a = entry.a, b = entry.b, + aName = OnlinePanel.tourName(tour, entry.a), + bName = OnlinePanel.tourName(tour, entry.b), + winner = entry.winner, + winnerName = OnlinePanel.tourName(tour, entry.winner), + how = entry.how, + state = entry.state or "pending", + bye = entry.state == "bye" or (entry.a ~= nil and entry.b == nil), + live = live ~= nil and entry.match ~= nil and entry.match == live.match, + } + end + columns[#columns + 1] = column + end + table.sort(columns, function(a, b) return (a.round or 0) < (b.round or 0) end) + return columns +end + +function OnlinePanel.matchText(entry) + if type(entry) ~= "table" then return "" end + if entry.bye and not entry.b then + return ("%s bye"):format(tostring(entry.aName or "?")) + end + return ("%s vs %s"):format(tostring(entry.aName or "?"), + tostring(entry.bName or "?")) +end + +function OnlinePanel.bannerText(tour, myId) + if type(tour) ~= "table" then return nil end + local live = OnlinePanel.liveMatch(tour) + if live then + if myId ~= nil and (live.a == myId or live.b == myId) then + return Strings("You play next") + end + local a = OnlinePanel.tourName(tour, live.a) or "?" + local b = OnlinePanel.tourName(tour, live.b) or "?" + return Strings("Watching: %s vs %s", a, b) + end + if tour.stage == "finished" then + local champion = tour.championName + or OnlinePanel.tourName(tour, tour.champion) + if champion then return Strings("Champion: %s", champion) end + return Strings("Tournament over") + end + return nil +end + +function OnlinePanel.creatorControls(tour, myId) + local out = { isCreator = false, canStart = false, canKick = false, + canClose = false, players = 0 } + if type(tour) ~= "table" then return out end + out.players = #(tour.players or {}) + out.isCreator = myId ~= nil and tour.creator == myId + if not out.isCreator then return out end + out.canStart = tour.stage == "registering" and out.players >= 2 + out.canKick = tour.stage ~= "finished" + out.canClose = true + return out +end + +function OnlinePanel.countdown(at, nowMs) + if type(at) ~= "number" or type(nowMs) ~= "number" then return nil end + return math.max(0, math.floor((at - nowMs) / 1000 + 0.5)) +end + +function OnlinePanel.tourDeadline(tour) + if type(tour) ~= "table" then return nil end + local deadlines = tour.deadlines or {} + local at = deadlines.shot or deadlines.result or deadlines.ready + if type(at) ~= "number" then return nil end + return OnlinePanel.countdown(at, Client().serverTime() or 0) +end + +function OnlinePanel.packTeam(imp) + local st = OnlinePanel.state(imp) + local pick, reason = OnlinePanel.readTeamSlot(imp) + if not pick then return nil, nil, reason or Strings("Pick a save first.") end + local TeamPick = require("src.online.TeamPick") + local ok, why = TeamPick.validate(pick, st.team, OnlinePanel.ruleFor(imp)) + if not ok then return nil, nil, why end + local packed, packErr = + OnlinePanel.packForRoom(imp, pick, st.team, pick.generation) + if not packed then return nil, nil, packErr end + return packed, OnlinePanel.partyDigest(packed) +end + +function OnlinePanel.hostTournament(imp) + local st = OnlinePanel.state(imp) + ensureHooks() + local rule = OnlinePanel.ruleFor(imp) + local profile, reason = OnlinePanel.myProfile(imp) + if not profile then + st.status, st.statusOk = reason or Strings("Reading your game..."), false + return false + end + local packed, digest + if st.tourPlaying ~= false then + local why = OnlinePanel.ruleMismatch(imp, rule, Strings("Your rule")) + if why then + st.status, st.statusOk = why, false + return false + end + packed, digest, why = OnlinePanel.packTeam(imp) + if not packed then + st.status, st.statusOk = why, false + return false + end + end + st.setupDone = true + st.pending = Client().createTournament({ + profile = profile, + rule = copyRule(rule), + playing = st.tourPlaying ~= false, + shotClock = st.tourShotClock, + maxSpectators = tonumber(st.tourSpectators) or 0, + party = packed, + partyDigest = digest, + }) + finishTo(imp, "tournament") + return true +end + +function OnlinePanel.joinTournamentByCode(imp, code, as) + local st = OnlinePanel.state(imp) + code = OnlinePanel.sanitizeCode(code) + if #code ~= OnlinePanel.CODE_LEN then + st.status, st.statusOk = Strings("Tournament codes are 6 characters."), false + return false + end + ensureHooks() + local packed, digest + if as ~= "spectator" then + local why + packed, digest, why = OnlinePanel.packTeam(imp) + if not packed then + st.status, st.statusOk = why, false + return false + end + end + st.pending = Client().joinTournament(code, as or "player", packed, digest, + OnlinePanel.myProfile(imp)) + return true +end + +function OnlinePanel.leaveTournament(imp) + local st = OnlinePanel.state(imp) + st.ready = false + Client().leaveTournament() + OnlinePanel.clearPresence() + return true +end + +ensureHooks = function() + if OnlinePanel._hooked then return end + OnlinePanel._hooked = true + local client = Client() + OnlinePanel._lobbyRev = 0 + client.on("lobby", function() + OnlinePanel._lobbyRev = (OnlinePanel._lobbyRev or 0) + 1 + end) + client.on("state", function() + OnlinePanel._lobbyRev = (OnlinePanel._lobbyRev or 0) + 1 + end) + client.on("match_start", function(payload) + OnlinePanel._pendingStart = payload + end) + client.on("match_end", function(msg) + OnlinePanel.lastResult = OnlinePanel.resultText(msg) + end) + client.on("tour_match", function(payload) + OnlinePanel.tourNotice = Strings("You play next") + OnlinePanel._tourMatch = payload + end) + client.on("tour_spectate", function(payload) + OnlinePanel.tourNotice = nil + OnlinePanel._tourMatch = payload + end) + client.on("tour_bye", function() + OnlinePanel.tourNotice = Strings("You have a bye this round.") + end) + client.on("error", function(e) + if type(e) ~= "table" then return end + if e.scope == "tournament" then + OnlinePanel.tourNotice = e.text + OnlinePanel._tourClosed = e.text + elseif e.scope == "room" and e.reason == "resume_incomplete" then + OnlinePanel._roomLost = Strings("Connection lost, left the room.") + end + end) + client.on("tour_over", function(payload) + OnlinePanel.tourNotice = payload.champion + and Strings("Champion: %s", tostring(payload.champion)) + or Strings("Tournament over") + end) +end + +function OnlinePanel.doConnect(imp) + local st = OnlinePanel.state(imp) + st.connectWanted = false + local client = Client() + local known, address = pcall(client.configure, {}) + if not known or address == nil then + local Net = require("src.link.Net") + pcall(client.configure, + { relayAddress = OnlinePanel.relayAddress or Net.defaultRelayAddress() }) + end + local profile = OnlinePanel.myProfile(imp) + local ok, err = client.connect({ + name = OnlinePanel.ensureName(imp), + ticket = st.ticket, + profiles = profile and { profile } or {}, + }) + if not ok then + st.status = tostring(err or "the relay didn't answer") + st.statusOk = false + end + return ok +end + +function OnlinePanel.connect(imp) + local st = OnlinePanel.state(imp) + ensureHooks() + OnlinePanel.ensureName(imp) + if OnlinePanel.linked() and st.ticketState ~= "ok" + and st.ticketState ~= "unsupported" then + st.connectWanted = true + st.ticketState = "pending" + local started = OnlinePanel.startJob(imp, "ticket", function(client) + return client:lobbyTicket() + end) + if started then return true end + st.ticketState = "unsupported" + end + return OnlinePanel.doConnect(imp) +end + +function OnlinePanel.disconnect(imp) + local st = OnlinePanel.state(imp) + st.ready = false + Client().disconnect() + OnlinePanel.clearPresence() +end + +function OnlinePanel.clearPresence() + OnlinePanel._presenceKey = nil + local ok, Presence = pcall(require, "src.core.DiscordPresence") + if ok and type(Presence.setJoinCode) == "function" then + pcall(Presence.setJoinCode, nil, "match", 1, 2) + end +end + +function OnlinePanel.pushTourPresence(tour) + local players = #(tour.players or {}) + local key = "T" .. tostring(tour.code) .. "/" .. players + if OnlinePanel._presenceKey == key then return end + local ok, Presence = pcall(require, "src.core.DiscordPresence") + if not ok or type(Presence.setJoinCode) ~= "function" then return end + OnlinePanel._presenceKey = key + pcall(Presence.setJoinCode, tour.code, "tournament", players, + OnlinePanel.TOUR_MAX_PLAYERS) +end + +function OnlinePanel.pushPresence(room) + local key = tostring(room.code) .. "/" .. #(room.players or {}) + if OnlinePanel._presenceKey == key then return end + local ok, Presence = pcall(require, "src.core.DiscordPresence") + if not ok or type(Presence.setJoinCode) ~= "function" then return end + OnlinePanel._presenceKey = key + pcall(Presence.setJoinCode, room.code, "match", #(room.players or {}), 2) +end + +function OnlinePanel.sendReady(imp) + local st = OnlinePanel.state(imp) + local pick, reason = OnlinePanel.readTeamSlot(imp) + if not pick then + st.status, st.statusOk = reason or Strings("Pick a save first."), false + return false, reason + end + local TeamPick = require("src.online.TeamPick") + local packed, why + local room = Client().room() + if room and room.intent == "trade" then + local all = {} + for index = 1, #pick.party do all[index] = index end + packed = TeamPick.pack(pick, all, pick.generation) + else + local ok + local room2 = Client().room() + local rule = (room2 and room2.profile and room2.profile.rule) + or OnlinePanel.ruleFor(imp) + ok, why = TeamPick.validate(pick, st.team, rule) + if not ok then + st.status, st.statusOk = why, false + return false, why + end + packed, why = OnlinePanel.packForRoom(imp, pick, st.team, + pick.generation) + end + if not packed then + st.status, st.statusOk = tostring(why), false + return false, why + end + Client().ready(packed, OnlinePanel.partyDigest(packed)) + st.ready = true + st.status, st.statusOk = Strings("Ready. Waiting for the other trainer."), true + return true +end + +function OnlinePanel.unready(imp) + local st = OnlinePanel.state(imp) + st.ready = false + Client().ready({}, nil) +end + +function OnlinePanel.buildSpec(imp, payload, opts) + opts = opts or {} + local st = OnlinePanel.state(imp) + local client = opts.client or Client() + local ArenaBoot = require("src.online.ArenaBoot") + local profile = payload.profile or OnlinePanel.myProfile(imp) + local refs = nil + if payload.role ~= "spectator" and #st.team > 0 then refs = st.team end + local team, myParty = nil, nil + if refs then + team = OnlinePanel.teamIndices(refs) + local cross = OnlinePanel.crossGen(imp) + if cross or team == nil then + local pick = OnlinePanel.readTeamSlot(imp) + local packed, packErr = OnlinePanel.packForRoom(imp, pick or {}, refs, + cross and nil or (pick and pick.generation)) + if not packed then + return nil, packErr or "that team can't cross generations" + end + myParty = packed + end + end + return ArenaBoot.spec({ + profile = profile, + role = payload.role, + slotId = st.slotId, + team = team, + myParty = myParty, + seed = payload.seed, + peerName = payload.peerName, + hostName = payload.hostName, + guestName = payload.guestName, + theirParty = payload.theirParty, + hostParty = payload.hostParty, + guestParty = payload.guestParty, + session = opts.session or client.roomSession(), + onDone = OnlinePanel.recordResult, + }) +end +-- ------------------------------------------------------------- navigation + +OnlinePanel.SCREENS = { + home = true, play = true, wizard = true, room = true, watch = true, + tournament = true, trade = true, +} + +OnlinePanel.WIZARDS = { + hostBattle = { title = "Host a battle", confirm = "Host the battle", + steps = { "game", "save", "team", "rules", "visibility", "summary" } }, + hostTournament = { title = "Host a tournament", + confirm = "Host the tournament", + steps = { "game", "save", "playing", "team", "rules", "shotclock", + "spectators", "summary" } }, + join = { title = "Join a battle", confirm = "Join", resume = true, + steps = { "game", "save", "team", "summary" } }, + tradeRemote = { title = "Trade online", confirm = "Go", + steps = { "game", "save", "role", "summary" } }, +} + +OnlinePanel.STEP_TITLE = { + game = "Which game?", + save = "Which save?", + team = "Pick your team", + rules = "Battle rules", + visibility = "Who can find this lobby?", + playing = "Are you playing?", + shotclock = "Shot clock", + spectators = "Spectators", + role = "Host or join?", + summary = "Check this over", +} + +OnlinePanel.STEP_LABEL = { + game = "Game", save = "Save", team = "Team", rules = "Rules", + visibility = "Visibility", playing = "Playing", shotclock = "Shot clock", + spectators = "Spectators", role = "Trade", +} + +function OnlinePanel.nav(imp) + local st = OnlinePanel.state(imp) + if type(st.stack) ~= "table" or #st.stack == 0 then st.stack = { "home" } end + return st.stack +end + +function OnlinePanel.screen(imp) + local stack = OnlinePanel.nav(imp) + return stack[#stack] +end + +local function entered(imp, id) + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + imp._tradeModal = nil + imp._pcPicker = nil + st.status, st.statusOk = nil, false + st.confirmLeave = nil + if id == "play" or id == "watch" then + c.dirty.lobby = true + elseif id == "trade" then + c.dirty.trade = true + elseif id == "wizard" then + c.dirty.slots, c.dirty.party = true, true + end +end + +function OnlinePanel.go(imp, id) + if not OnlinePanel.SCREENS[id] then return false end + local stack = OnlinePanel.nav(imp) + if stack[#stack] == id then return true end + local from = stack[#stack] + for i = 1, #stack do + if stack[i] == id then + for _ = #stack, i + 1, -1 do table.remove(stack) end + Transition.start("online", "pop", { dir = -1, from = from, to = id }) + entered(imp, id) + return true + end + end + stack[#stack + 1] = id + Transition.start("online", "push", { dir = 1, from = from, to = id }) + entered(imp, id) + return true +end + +function OnlinePanel.back(imp) + local st = imp and imp._online + if not st then return false end + if imp._pcPicker then return OnlinePanel.pcClose(imp) end + if imp._tradeModal then return OnlinePanel.tradeModalClose(imp) end + if OnlinePanel.screen(imp) == "wizard" and st.wizard + and (st.wizard.at or 1) > 1 then + return OnlinePanel.wizardBack(imp) + end + local stack = OnlinePanel.nav(imp) + if #stack <= 1 then return false end + local from = table.remove(stack) + st.wizard = nil + Transition.start("online", "pop", { dir = -1, from = from, + to = stack[#stack] }) + entered(imp, stack[#stack]) + return true +end + +function OnlinePanel.home(imp) + local st = OnlinePanel.state(imp) + local stack = OnlinePanel.nav(imp) + local from = stack[#stack] + for _ = #stack, 2, -1 do table.remove(stack) end + st.wizard = nil + if from ~= "home" then + Transition.start("online", "pop", { dir = -1, from = from, to = "home" }) + end + entered(imp, "home") + return true +end + +function OnlinePanel.setupComplete(imp) + local st = OnlinePanel.state(imp) + if not OnlinePanel.selectedVersion(imp) then return false end + if not st.slotId then return false end + if #(st.team or {}) == 0 then return false end + return st.setupDone == true +end + +-- ---------------------------------------------------------------- wizards + +function OnlinePanel.startWizard(imp, kind, opts) + local st = OnlinePanel.state(imp) + if not OnlinePanel.WIZARDS[kind] then return false end + st.wizard = { kind = kind, at = 1, show = {}, opts = opts or {} } + st.status, st.statusOk = nil, false + OnlinePanel.go(imp, "wizard") + return true +end + +function OnlinePanel.wizard(imp) + local st = imp and imp._online + return st and st.wizard or nil +end + +function OnlinePanel.wizardDef(imp) + local w = OnlinePanel.wizard(imp) + return w and OnlinePanel.WIZARDS[w.kind] or nil +end + +function OnlinePanel.wizardSteps(imp) + local st = OnlinePanel.state(imp) + local w, def = st.wizard, OnlinePanel.wizardDef(imp) + if not w or not def then return {} end + local out = {} + for _, id in ipairs(def.steps) do + local skip = false + if id == "team" and w.kind == "hostTournament" + and st.tourPlaying == false then + skip = true + elseif def.resume and id ~= "summary" and not w.show[id] + and st.setupDone == true then + if id == "game" then skip = OnlinePanel.selectedVersion(imp) ~= nil end + if id == "save" then skip = st.slotId ~= nil end + if id == "team" then skip = #(st.team or {}) > 0 end + end + if not skip then out[#out + 1] = id end + end + if #out == 0 then out[1] = "summary" end + return out +end + +function OnlinePanel.wizardStep(imp) + local w = OnlinePanel.wizard(imp) + if not w then return nil end + local steps = OnlinePanel.wizardSteps(imp) + local at = math.max(1, math.min(tonumber(w.at) or 1, #steps)) + w.at = at + return steps[at], at, #steps +end + +function OnlinePanel.wizardReady(imp) + local st = OnlinePanel.state(imp) + local id = OnlinePanel.wizardStep(imp) + if id == "game" then return OnlinePanel.selectedVersion(imp) ~= nil end + if id == "save" then return st.slotId ~= nil end + if id == "team" then return #(st.team or {}) > 0 end + if id == "role" then + if st.tradeRole == "join" then + return #OnlinePanel.sanitizeCode(OnlinePanel.tradeState(imp).code or "") + == OnlinePanel.CODE_LEN + end + return st.tradeRole == "host" + end + return true +end + +local function enterStep(imp, id) + local st = OnlinePanel.state(imp) + if id == "rules" and not st.ruleEdited then + st.rule.partySize = math.max(1, #(st.team or {})) + end +end + +function OnlinePanel.wizardNext(imp) + local st = OnlinePanel.state(imp) + local w = st.wizard + if not w then return false end + if not OnlinePanel.wizardReady(imp) then return false end + local steps = OnlinePanel.wizardSteps(imp) + if w.at >= #steps then return OnlinePanel.wizardFinish(imp) end + local fromAt = w.at + w.at = w.at + 1 + Transition.start("online", "push", { dir = 1, from = "wizard", + fromAt = fromAt, to = "wizard" }) + enterStep(imp, steps[w.at]) + st.status, st.statusOk = nil, false + return true +end + +function OnlinePanel.wizardBack(imp) + local st = OnlinePanel.state(imp) + local w = st.wizard + if not w then return false end + if (w.at or 1) <= 1 then + st.wizard = nil + local stack = OnlinePanel.nav(imp) + if #stack <= 1 then return false end + local from = table.remove(stack) + Transition.start("online", "pop", { dir = -1, from = from, + to = stack[#stack] }) + entered(imp, stack[#stack]) + return true + end + local fromAt = w.at + w.at = w.at - 1 + Transition.start("online", "pop", { dir = -1, from = "wizard", + fromAt = fromAt, to = "wizard" }) + st.status, st.statusOk = nil, false + return true +end + +function OnlinePanel.wizardTo(imp, id) + local w = OnlinePanel.wizard(imp) + if not w then return false end + w.show[id] = true + local steps = OnlinePanel.wizardSteps(imp) + for i, step in ipairs(steps) do + if step == id then + local fromAt = w.at or 1 + w.at = i + if i ~= fromAt then + Transition.start("online", i > fromAt and "push" or "pop", + { dir = i > fromAt and 1 or -1, from = "wizard", fromAt = fromAt, + to = "wizard" }) + end + enterStep(imp, id) + return true + end + end + return false +end + +function OnlinePanel.wizardAnswers(imp) + local st = OnlinePanel.state(imp) + local def = OnlinePanel.wizardDef(imp) + if not def then return {} end + local c = OnlinePanel.cache(imp) + local out = {} + for _, id in ipairs(def.steps) do + local value + if id == "game" then + local version = OnlinePanel.selectedVersion(imp) + local info = version and GameVersion.info(version) + value = (info and (info.launcherName or info.name)) or version + or Strings("none") + if st.cartId then value = tostring(value) .. " / " .. tostring(st.cartId) end + elseif id == "save" then + value = st.slotId or Strings("none") + for _, row in ipairs(c.slots or {}) do + if row.id == st.slotId then value = row.label end + end + elseif id == "team" then + local names = {} + for _, row in ipairs(c.team or {}) do + names[#names + 1] = row and row.name or "?" + end + value = (#names > 0) and table.concat(names, ", ") or Strings("none") + elseif id == "rules" then + value = OnlinePanel.ruleText(OnlinePanel.ruleFor(imp)) + elseif id == "visibility" then + value = (st.public == false) and Strings("Code only") + or Strings("Public") + if st.note and st.note ~= "" then + value = tostring(value) .. ' - "' .. tostring(st.note) .. '"' + end + elseif id == "playing" then + value = (st.tourPlaying == false) and Strings("Organize and watch") + or Strings("Play in it") + elseif id == "shotclock" then + value = Strings("%d seconds a move", st.tourShotClock or 6) + elseif id == "spectators" then + value = (tonumber(st.tourSpectators) or 0) > 0 + and Strings("Up to %d", st.tourSpectators) + or Strings("No spectators") + elseif id == "role" then + value = (st.tradeRole == "join") + and Strings("Join code %s", tostring(OnlinePanel.tradeState(imp).code + or "")) or Strings("Host a trade") + end + if value ~= nil then + out[#out + 1] = { step = id, + label = Strings(OnlinePanel.STEP_LABEL[id] or id), + value = tostring(value) } + end + end + return out +end + +finishTo = function(imp, screen) + local st = OnlinePanel.state(imp) + st.wizard = nil + local stack = OnlinePanel.nav(imp) + while #stack > 1 and stack[#stack] == "wizard" do table.remove(stack) end + return OnlinePanel.go(imp, screen) +end + +function OnlinePanel.hostBattle(imp) + local st = OnlinePanel.state(imp) + ensureHooks() + local rule = OnlinePanel.ruleFor(imp) + local why = OnlinePanel.ruleMismatch(imp, rule, Strings("Your rule")) + if why then + st.status, st.statusOk = why, false + return false, why + end + local profile, reason = OnlinePanel.myProfile(imp) + if not profile then + st.status, st.statusOk = reason or Strings("Reading your game..."), false + return false + end + st.setupDone = true + st.pending = Client().createRoom({ + intent = "battle", profile = profile, playing = true, maxSpectators = 8, + public = st.public ~= false, + note = (st.note ~= "" and st.note) or nil, + }) + finishTo(imp, "room") + return true +end + +function OnlinePanel.startJoin(imp, code, rule, as, tournament) + local st = OnlinePanel.state(imp) + code = OnlinePanel.sanitizeCode(code) + if #code ~= OnlinePanel.CODE_LEN then + st.status, st.statusOk = Strings("Room codes are 6 characters."), false + return false + end + st.joinTarget = { code = code, rule = rule, as = as or "player", + tournament = tournament == true } + return OnlinePanel.startWizard(imp, "join") +end + +function OnlinePanel.startJoinTournament(imp, code, rule) + return OnlinePanel.startJoin(imp, code, rule, "player", true) +end + +function OnlinePanel.joinFromWizard(imp) + local st = OnlinePanel.state(imp) + local target = st.joinTarget + if type(target) ~= "table" then return false end + if target.as ~= "spectator" then + local why = OnlinePanel.ruleMismatch(imp, target.rule, Strings("This room")) + if why then + st.status, st.statusOk = why, false + st.ruleBlock = true + return false, why + end + end + st.ruleBlock = nil + st.setupDone = true + if target.tournament then + local ok = OnlinePanel.joinTournamentByCode(imp, target.code, target.as) + if ok then finishTo(imp, "tournament") end + return ok + end + local ok = OnlinePanel.joinByCode(imp, target.code, target.as) + if ok then finishTo(imp, "room") end + return ok +end + +function OnlinePanel.wizardFinish(imp) + local st = OnlinePanel.state(imp) + local w = st.wizard + if not w then return false end + if w.kind == "hostBattle" then return OnlinePanel.hostBattle(imp) end + if w.kind == "hostTournament" then return OnlinePanel.hostTournament(imp) end + if w.kind == "join" then return OnlinePanel.joinFromWizard(imp) end + if w.kind == "tradeRemote" then + local tr = OnlinePanel.tradeState(imp) + st.setupDone = true + if st.tradeRole == "join" then + if not OnlinePanel.joinTrade(imp, tr.code) then return false end + finishTo(imp, "room") + return true + end + if not OnlinePanel.hostTrade(imp) then return false end + finishTo(imp, "room") + return true + end + return false +end + +-- --------------------------------------------------------- the PC picker + +OnlinePanel.PC_FIELD = "online-pc-filter" +OnlinePanel.PC_CLOSE = "online-pc-close" + +function OnlinePanel.pcPicker(imp) + return type(imp) == "table" and imp._pcPicker or nil +end + +function OnlinePanel.pcOpen(imp, opts) + opts = opts or {} + local side = opts.side + if side and not OnlinePanel.tradePcAllowed(imp, side) then return false end + imp._pcPicker = { query = "", page = 1, side = side } + OnlinePanel.invalidate(imp, "party") + Kit.setFocus(OnlinePanel.PC_CLOSE) + return true +end + +function OnlinePanel.pcClose(imp) + if type(imp) ~= "table" or not imp._pcPicker then return false end + imp._pcPicker = nil + if imp._onlineFocus == OnlinePanel.PC_FIELD then + imp._onlineFocus = nil + if type(imp._disarmTextInput) == "function" then + pcall(imp._disarmTextInput, imp) + end + end + Kit.setFocus(nil) + return true +end + +function OnlinePanel.pcQuery(imp, text) + local pc = OnlinePanel.pcPicker(imp) + if not pc then return false end + pc.query = tostring(text or ""):sub(1, 24) + pc.page = 1 + return true +end + +local function matches(row, needle) + if needle == "" then return true end + local mon = row.mon or {} + local hay = table.concat({ tostring(row.name or ""), + tostring(mon.species or ""), tostring(row.source or "") }, " "):upper() + return hay:find(needle, 1, true) ~= nil +end + +function OnlinePanel.pcRows(imp) + local pc = OnlinePanel.pcPicker(imp) + local c = OnlinePanel.cache(imp) + if not pc then return EMPTY end + local needle = tostring(pc.query or ""):upper() + local out = {} + for _, row in ipairs((pc.side and c.tradePc or c.pc) or EMPTY) do + if matches(row, needle) then out[#out + 1] = row end + end + return out +end + +function OnlinePanel.pcPick(imp, row) + local st = OnlinePanel.state(imp) + if type(row) ~= "table" then return false end + local pc = OnlinePanel.pcPicker(imp) + if pc and pc.side then + OnlinePanel.tradePick(imp, pc.side, row.ref or row) + OnlinePanel.pcClose(imp) + return true + end + OnlinePanel.toggleTeam(st.team, row.ref or row, OnlinePanel.TEAM_MAX) + st.ready = false + OnlinePanel.invalidate(imp, "party", "summary") + return true +end + +function OnlinePanel.pcAction(imp, action) + if not OnlinePanel.pcPicker(imp) then return false end + if action == "b" then return OnlinePanel.pcClose(imp) end + return false +end + +function OnlinePanel.deepLink(imp, code, as) + local st = OnlinePanel.state(imp) + st.joinCode = OnlinePanel.sanitizeCode(code) + if #st.joinCode ~= OnlinePanel.CODE_LEN then return false end + if as == "spectator" then + ensureHooks() + if OnlinePanel.joinByCode(imp, st.joinCode, as) then + OnlinePanel.go(imp, "room") + return true + end + return false + end + return OnlinePanel.startJoin(imp, st.joinCode, nil, as or "player") +end + +-- ------------------------------------------------------------- gen 2 gate + +function OnlinePanel.gen2Battles() + local hit = OnlinePanel._gen2Battles + if hit ~= nil then return hit end + local ok, mod = pcall(require, "src.link.LinkBattle2") + hit = ok and type(mod) == "table" and type(mod.newHost) == "function" + OnlinePanel._gen2Battles = hit + return hit +end + +function OnlinePanel.canBattleWith(version) + if not version then return false end + if OnlinePanel.isGen2(version) then return OnlinePanel.gen2Battles() end + return true +end + +-- --------------------------------------------------------------- filters + +OnlinePanel.FILTERS = { + { id = "all", label = "All" }, + { id = "gen1", label = "Gen 1" }, + { id = "gen2", label = "Gen 2" }, + { id = "vanilla", label = "Vanilla" }, + { id = "carts", label = "Carts" }, +} + +OnlinePanel.FILTER_LABEL = "Show:" +OnlinePanel.FILTER_AT = 8 + +function OnlinePanel.filter(imp) + local st = OnlinePanel.state(imp) + for _, row in ipairs(OnlinePanel.FILTERS) do + if row.id == st.filter then return st.filter end + end + st.filter = "all" + return st.filter +end + +function OnlinePanel.setFilter(imp, id) + local st = OnlinePanel.state(imp) + for _, row in ipairs(OnlinePanel.FILTERS) do + if row.id == id then + st.filter = id + OnlinePanel.cache(imp).dirty.lobby = true + return true + end + end + return false +end + +function OnlinePanel.entryPasses(filter, entry) + if type(filter) == "table" then filter = filter.id or "all" end + if filter == nil or filter == "all" then return true end + local profile = type(entry) == "table" and entry.profile or nil + if type(profile) ~= "table" then return true end + local generation = tonumber(profile.engine) + or GameVersion.generation(profile.version) or 1 + if filter == "gen1" then return generation ~= 2 end + if filter == "gen2" then return generation == 2 end + if filter == "carts" then return profile.kind == "cart" end + if filter == "vanilla" then return profile.kind ~= "cart" end + return true +end + +-- ----------------------------------------------------------------- caches + +local function newCache() + return { + dirty = { slots = true, carts = true, party = true, lobby = true, + trade = true, summary = true }, + slots = {}, carts = {}, party = {}, pc = {}, team = {}, + rooms = {}, watch = {}, mine = nil, + tradeSlots = {}, tradeRows = { a = {}, b = {} }, + tradePc = EMPTY, tradePcKey = nil, + counts = { players = 0, lobbies = 0 }, + summary = "", teamNote = nil, teamOk = false, partyReason = nil, + selKey = nil, slotsRef = nil, slotsScope = nil, + lobbyRev = -1, teamKey = nil, tradeKey = nil, pcKey = nil, + } +end + +function OnlinePanel.cache(imp) + local st = OnlinePanel.state(imp) + if not st.cache then st.cache = newCache() end + return st.cache +end + +function OnlinePanel.invalidate(imp, ...) + local c = OnlinePanel.cache(imp) + local n = select("#", ...) + if n == 0 then + for key in pairs(c.dirty) do c.dirty[key] = true end + return c + end + for i = 1, n do c.dirty[select(i, ...)] = true end + return c +end + +function OnlinePanel.selectionKey(imp) + local st = OnlinePanel.state(imp) + return table.concat({ tostring(OnlinePanel.selectedVersion(imp)), + tostring(st.cartId), tostring(st.kind), tostring(st.slotId), + tostring(st.engine) }, "|") +end + +local function refreshCarts(imp, c) + if not c.dirty.carts then return end + c.dirty.carts = false + local version = OnlinePanel.selectedVersion(imp) + local rows = OnlinePanel.sealedCarts(imp, version) + c.carts = {} + for _, row in ipairs(rows) do + c.carts[#c.carts + 1] = { id = row.id, + title = tostring(row.title or row.id) } + end +end + +local function refreshSlots(imp, c) + local st = OnlinePanel.state(imp) + local version = OnlinePanel.selectedVersion(imp) + local scope = OnlinePanel.scopeFor(version, st.cartId) + local ref = (type(imp.slots) == "table") and imp.slots[scope] or nil + if scope ~= c.slotsScope or ref ~= c.slotsRef then c.dirty.slots = true end + if not c.dirty.slots then return end + c.dirty.slots = false + local rows = OnlinePanel.slotsIn(imp, version, st.cartId) + c.slotsScope = scope + c.slotsRef = (type(imp.slots) == "table") and imp.slots[scope] or nil + c.slots = {} + for _, row in ipairs(rows) do + if row.exists and not (st.cartId and row.sealBroken) then + c.slots[#c.slots + 1] = { + id = row.id, + label = tostring(row.label or row.name or row.id), + sub = ("%d badges"):format((row.meta and row.meta.badges) or 0), + } + end + end + local found = false + for _, row in ipairs(c.slots) do + if row.id == st.slotId then found = true end + end + if not found and st.slotId and #c.slots > 0 then + st.slotId, st.team, st.slotRead = nil, {}, nil + c.dirty.party = true + end +end + +local function teamKeyOf(team) + local out = {} + for i, ref in ipairs(team or {}) do out[i] = OnlinePanel.refKey(ref) end + return table.concat(out, ",") +end + +OnlinePanel.teamKey = teamKeyOf + +local function partyKey(imp) + local st = OnlinePanel.state(imp) + return table.concat({ OnlinePanel.selectionKey(imp), teamKeyOf(st.team), + tostring(st.rule.partySize), tostring(st.rule.minLevel), + tostring(st.rule.maxLevel), tostring(st.rule.forceLevel), + tostring(st.converted and st.converted.key) }, "|") +end + +local function monRow(imp, entry, version) + local st = OnlinePanel.state(imp) + local mon = entry.mon or {} + local refused, preview = OnlinePanel.monRefusal(imp, entry) + local maxHp = tonumber(mon.maxHp or (mon.stats and mon.stats.hp)) or 0 + return { + ref = { where = entry.where, box = entry.box, index = entry.index }, + key = OnlinePanel.refKey(entry), + mon = mon, + version = version, + where = entry.where, + source = entry.source, + order = OnlinePanel.teamOrder(st.team, entry), + name = tostring(mon.nickname or mon.species or mon.name or "?"), + label = ("%s Lv%d %d/%d HP"):format( + tostring(mon.species or mon.name or "?"), tonumber(mon.level) or 0, + tonumber(mon.hp) or 0, maxHp), + note = preview and preview[1] or nil, + refused = refused, + } +end + +local function refreshParty(imp, c) + local st = OnlinePanel.state(imp) + local key = partyKey(imp) + if key == c.teamKey and not c.dirty.party then return end + c.dirty.party = false + c.teamKey = key + local version = OnlinePanel.selectedVersion(imp) + c.party, c.pc, c.team = {}, {}, {} + c.partyReason = nil + c.teamOk, c.teamNote = true, nil + if not st.slotId then + c.partyReason = Strings("Pick a save first.") + return + end + local pick, reason = OnlinePanel.readTeamSlot(imp) + if not pick then + c.partyReason = reason or Strings("Pick a save first.") + return + end + local TeamPick = require("src.online.TeamPick") + local byKey = {} + for _, entry in ipairs(TeamPick.candidates(pick)) do + local row = monRow(imp, entry, version) + byKey[row.key] = row + if entry.where == "party" then + c.party[#c.party + 1] = row + else + c.pc[#c.pc + 1] = row + end + end + for i, ref in ipairs(st.team or {}) do + c.team[i] = byKey[OnlinePanel.refKey(ref)] + end + local ok, why = OnlinePanel.validateTeam(imp) + c.teamOk, c.teamNote = ok, why +end + +local function refreshSummary(imp, c) + local st = OnlinePanel.state(imp) + local key = OnlinePanel.selectionKey(imp) .. "|" .. #(st.team or {}) + if key == c.summaryKey then return end + c.summaryKey = key + local version = OnlinePanel.selectedVersion(imp) + if not version then + c.summary = Strings("No game imported yet.") + return + end + local info = GameVersion.info(version) + local parts = { tostring((info and (info.launcherName or info.name)) or version) } + parts[#parts + 1] = st.slotId and tostring(st.slotId) or Strings("no save") + parts[#parts + 1] = ("%d mons"):format(#(st.team or {})) + local cart = nil + for _, row in ipairs(c.carts) do + if row.id == st.cartId then cart = row.title end + end + parts[#parts + 1] = cart or Strings("Vanilla") + c.summary = table.concat(parts, " - ") +end + +local function roomRow(imp, entry, profile, mine) + local ep = entry.profile or EMPTY + local reason = OnlinePanel.joinReason(entry, profile) + return { + id = tostring(entry.id), + code = entry.code or entry.room, + name = tostring(entry.name or "?"), + verified = entry.verified == true, + intent = tostring(entry.intent or "battle"), + game = tostring(ep.version or "?"), + arena = OnlinePanel.arenaText(ep), + rule = OnlinePanel.ruleText(ep.rule), + ruleTable = ep.rule, + spectators = tonumber(entry.spectators) or 0, + stage = tostring(entry.stage or "waiting"), + sub = ("%s %s %s"):format(tostring(ep.version or "?"), + OnlinePanel.arenaText(ep), OnlinePanel.ruleText(ep.rule)), + note = (entry.note ~= nil and entry.note ~= "") and tostring(entry.note) + or nil, + mine = mine == true, + reason = reason, + } +end + +local function lobbyKey(imp, open, watch, profile) + return table.concat({ tostring(OnlinePanel._lobbyRev or 0), + tostring(#open), tostring(#watch), + tostring(profile and profile.fingerprint), + tostring(profile and profile.version), tostring(profile and profile.kind), + OnlinePanel.filter(imp) }, "|") +end + +local function refreshLobby(imp, c) + local client = Client() + local online = client.state() == "online" + local open = (online and type(client.openRooms) == "function") + and client.openRooms() or EMPTY + local watch = (online and type(client.watchable) == "function") + and client.watchable() or EMPTY + local profile = OnlinePanel.myProfile(imp) + local key = lobbyKey(imp, open, watch, profile) + if key == c.lobbyKey and not c.dirty.lobby then return end + c.dirty.lobby = false + c.lobbyKey = key + local filter = OnlinePanel.filter(imp) + local me = OnlinePanel.mySeatId() + c.rooms, c.watch = {}, {} + for _, entry in ipairs(open) do + if entry.id ~= me and entry.intent ~= "tournament" + and OnlinePanel.entryPasses(filter, entry) then + c.rooms[#c.rooms + 1] = roomRow(imp, entry, profile) + end + end + for _, entry in ipairs(watch) do + if entry.id ~= me and OnlinePanel.entryPasses(filter, entry) then + c.watch[#c.watch + 1] = roomRow(imp, entry, profile) + end + end + local counts = (type(client.counts) == "function") and client.counts() or nil + c.counts.players = (counts and tonumber(counts.players)) or 0 + c.counts.lobbies = (counts and tonumber(counts.openRooms)) or #c.rooms + local room = client.room() + if room and room.intent ~= "tournament" then + c.mine = { + code = room.code, + stage = tostring(room.stage or "waiting"), + intent = tostring(room.intent or "battle"), + players = #(room.players or {}), + hosting = me ~= nil and room.host == me, + } + else + c.mine = nil + end +end + +local function refreshTradePc(imp, c) + local pc = OnlinePanel.pcPicker(imp) + local side = pc and pc.side or nil + if not side then + c.tradePc, c.tradePcKey = EMPTY, nil + return + end + local entry = OnlinePanel.tradeState(imp).sides[side] + local key = table.concat({ side, tostring(entry and entry.version), + tostring(entry and entry.slotId), tostring(entry and entry.cartId), + tostring(OnlinePanel.tradePickKey(imp, side)) }, "|") + if key == c.tradePcKey then return end + c.tradePcKey = key + c.tradePc = OnlinePanel.tradeBoxRows(imp, side) + local mons = {} + for _, row in ipairs(c.tradePc) do mons[#mons + 1] = row.mon end + local view = OnlinePanel.tradeSideView(imp, side) + if view and view.handle then Sprites().prime(view.handle.version, mons) end +end + +local function refreshTrade(imp, c) + local tr = imp._online and imp._online.trade + if c.dirty.trade then + c.dirty.trade = false + c.tradeSlots = OnlinePanel.tradeSlots(imp) + end + if not tr then return end + for _, side in ipairs({ "a", "b" }) do + local view = OnlinePanel.tradeSideView(imp, side) + local handle = view and view.handle or nil + local picked = OnlinePanel.tradePickKey(imp, side) + local rows = {} + if handle then + for index, mon in ipairs(handle.party or {}) do + local key = OnlinePanel.refKey({ where = "party", index = index }) + rows[#rows + 1] = { ref = index, key = key, + label = OnlinePanel.monLabel(mon), + mon = mon, version = handle.version, source = Strings("Party"), + picked = picked == key, pickable = not mon.isEgg } + end + local pick = tr.picks[side] + if type(pick) == "table" and pick.where == "box" then + local row = OnlinePanel.tradeBoxRow(handle, pick) + if row then rows[#rows + 1] = row end + end + end + c.tradeRows[side] = rows + end + refreshTradePc(imp, c) +end + +function OnlinePanel.refresh(imp) + local c = OnlinePanel.cache(imp) + local key = OnlinePanel.selectionKey(imp) + if key ~= c.selKey then + c.selKey = key + c.dirty.slots, c.dirty.carts = true, true + c.dirty.party, c.dirty.summary, c.dirty.lobby = true, true, true + end + refreshCarts(imp, c) + refreshSlots(imp, c) + refreshParty(imp, c) + refreshSummary(imp, c) + refreshLobby(imp, c) + refreshTrade(imp, c) + return c +end + +-- ------------------------------------------------------------------ route + +local function route(imp) + local st = OnlinePanel.state(imp) + local room = Client().room() + local tour = Client().tournament() + local key = (tour and ("T" .. tostring(tour.code))) + or (room and ("R" .. tostring(room.code))) + or nil + if key == st.routeKey then return end + st.routeKey = key + if tour then + OnlinePanel.go(imp, "tournament") + elseif room then + local tr = st.trade + if room.intent == "trade" and tr and tr.remote then + OnlinePanel.go(imp, "trade") + else + OnlinePanel.go(imp, "room") + end + else + local stack = OnlinePanel.nav(imp) + while #stack > 1 and (stack[#stack] == "room" + or stack[#stack] == "tournament") do + table.remove(stack) + end + end +end + +function OnlinePanel.update(imp, dt) + local st = imp._online + if not st then return end + if st.job then + local res = st.job.client:poll(st.job.handle) + if res.status ~= "pending" then jobFinished(imp, res) end + end + if st.profileWant and not st.profiles[st.profileWant] then + OnlinePanel.computeProfile(imp, st.profileWant) + end + if st.convertWant then + OnlinePanel.computeConverted(imp, st.convertWant) + end + OnlinePanel.primeSprites(imp) + if type(imp.pumpOnlineCartInstall) == "function" then + pcall(imp.pumpOnlineCartInstall, imp) + end + OnlinePanel.pumpRemoteTrade(imp) + if OnlinePanel._tourClosed then + st.status, st.statusOk = OnlinePanel._tourClosed, false + OnlinePanel._tourClosed = nil + end + if OnlinePanel._roomLost then + local notice = OnlinePanel._roomLost + OnlinePanel._roomLost = nil + st.ready, st.confirmLeave = false, nil + OnlinePanel.go(imp, "play") + st.status, st.statusOk = notice, false + end + if st.pending and st.pending.done then + if st.pending.error then + st.status, st.statusOk = tostring(st.pending.error), false + end + st.pending = nil + end + local room = Client().room() + local tour = Client().tournament() + if tour then + OnlinePanel.pushTourPresence(tour) + elseif room then + OnlinePanel.pushPresence(room) + elseif st.hadRoom then + OnlinePanel.clearPresence() + end + st.hadRoom = room ~= nil or tour ~= nil + local cartKey = room and tostring(room.code) or nil + if cartKey ~= st.roomCartKey then + st.roomCartKey = cartKey + st.roomCart = room and OnlinePanel.cartNeed(room.profile) or nil + end + route(imp) + OnlinePanel.refresh(imp) + local start = OnlinePanel._pendingStart + if start then + if room and room.intent == "trade" then + OnlinePanel._pendingStart = nil + OnlinePanel.beginRemoteTrade(imp, start) + elseif imp.playArena then + OnlinePanel._pendingStart = nil + local spec, err = OnlinePanel.buildSpec(imp, start) + if spec then + OnlinePanel.lastResult = nil + local cartId = st.cartId + if OnlinePanel.crossGen(imp) then cartId = nil end + imp:playArena(OnlinePanel.engineVersion(imp), cartId, spec) + else + st.status, st.statusOk = tostring(err), false + end + end + end +end + +function OnlinePanel.joinByCode(imp, code, as) + local st = OnlinePanel.state(imp) + code = OnlinePanel.sanitizeCode(code) + if #code ~= OnlinePanel.CODE_LEN then + st.status, st.statusOk = Strings("Room codes are 6 characters."), false + return false + end + ensureHooks() + st.pending = Client().joinRoom(code, as or "player") + return true +end + +-- ---------------------------------------------------------------- screens + +local SCREEN_MODULES = { + home = "src.import.online.Home", + play = "src.import.online.Play", + wizard = "src.import.online.Wizard", + room = "src.import.online.Room", + watch = "src.import.online.Watch", + tournament = "src.import.online.Tournaments", + trade = "src.import.online.TradeScreen", +} + +local function shotDemo(imp) + if OnlinePanel._shotArmed then return end + local want = os.getenv("POKEPORT_ONLINE_SHOT") + if not want or want == "" then return end + OnlinePanel._shotArmed = true + local ok, demo = pcall(require, "tests.drivers.online_shot") + if ok and type(demo) == "function" then pcall(demo, OnlinePanel, imp, want) end +end + +local function drawScreen(imp, id, x, y, w, availH, m, dx, atStep) + local screen = require(SCREEN_MODULES[id] or SCREEN_MODULES.home) + local wiz = atStep and OnlinePanel.wizard(imp) or nil + local wasAt = wiz and wiz.at or nil + if wiz then wiz.at = atStep end + if dx ~= 0 then + love.graphics.push() + love.graphics.translate(dx, 0) + end + local h = screen.draw(imp, x, y, w, availH, m) + if dx ~= 0 then love.graphics.pop() end + if wiz then wiz.at = wasAt end + return h +end + +function OnlinePanel.buildOnlinePanel(imp, x, y, w, availH, m) + OnlinePanel.state(imp) + ensureHooks() + shotDemo(imp) + local id = OnlinePanel.screen(imp) + local tr = Transition.get("online") + if not tr then return drawScreen(imp, id, x, y, w, availH, m, 0, nil) end + local p = Transition.progress("online") + local dir = tr.dir >= 0 and 1 or -1 + if tr.from then + pcall(drawScreen, imp, tr.from, x, y, w, availH, m, -dir * p * w, + tr.fromAt) + end + return drawScreen(imp, id, x, y, w, availH, m, dir * (1 - p) * w, nil) +end + +return OnlinePanel diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 0ab57381..492826d6 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1871,7 +1871,7 @@ end function RomExtractor:extractField() self:beginStage("Interface artwork") - local done, total = 0, 51 + local done, total = 0, 53 local function tick() done = done + 1 self:tick("Interface artwork", math.min(done, total), total) @@ -2199,8 +2199,26 @@ function RomExtractor:extractField() "townmap/tiles.png"); tick() self:raw1bpp("TownMapCursor", 16, 16, "townmap/cursor.png", true); tick() + -- engine/items/town_map.asm:296, 150 + local nestArt, upArrowArt + if self.symbols["MonNestIcon"] then + self:raw1bpp("MonNestIcon", 8, 8, "townmap/nest.png", true); tick() + nestArt = { + path = "assets/generated/townmap/nest.png", width = 8, height = 8, + } + end + if self.symbols["TownMapUpArrow"] then + self:raw1bpp("TownMapUpArrow", 8, 8, "townmap/up_arrow.png", true); tick() + upArrowArt = { + path = "assets/generated/townmap/up_arrow.png", width = 8, height = 8, + } + end local data = copy(self.manifest.field) + if type(data.townMap) == "table" then + data.townMap.nest = nestArt + data.townMap.upArrow = upArrowArt + end local adjacency = data.hiddenExtras.trashCans.adjacent local converted = {} for index, values in pairs(adjacency) do converted[tonumber(index)] = values end diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 4ab3ba84..f1224967 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -5875,6 +5875,20 @@ function RomExtractorGen2:extractMenuGfx() 8, 8, "emotes/grass_rustle.png", true) emotes.grassRustle = "assets/generated/emotes/grass_rustle.png" end + -- data/sprites/emotes.asm:19, engine/overworld/map_objects.asm:1995 + local jumpShadow = self.symbols["JumpShadowGFX"] + if jumpShadow then + self:write2bpp(self.rom:bytes(jumpShadow[1], jumpShadow[2], 16), + 8, 8, "emotes/jump_shadow.png", true) + emotes.jumpShadow = "assets/generated/emotes/jump_shadow.png" + end + -- engine/events/field_moves.asm:390-407 + local cutGrass = self.symbols["CutGrassGFX"] + if cutGrass then + self:write2bpp(self.rom:bytes(cutGrass[1], cutGrass[2], 4 * 16), + 32, 8, "emotes/cut_grass.png", true) + emotes.cutGrass = "assets/generated/emotes/cut_grass.png" + end -- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows -- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708). local fishing = self.symbols["FishingGFX"] @@ -6492,6 +6506,14 @@ function RomExtractorGen2:pokegearGfx() gear.sprites = "assets/generated/pokegear/sprites.png" gear.spritesWide = 2 + -- engine/pokegear/pokegear.asm:2298 + if self.symbols["PokedexNestIconGFX"] then + local nest = self:symbol("PokedexNestIconGFX") + self:write2bpp(self.rom:bytes(nest.bank, nest.address, 16), 8, 8, + "pokegear/nest_icon.png", true) + gear.nestIcon = "assets/generated/pokegear/nest_icon.png" + end + local cells = SCREEN_AREA gear.cards = { clock = self:readTilemapRLE("ClockTilemapRLE", cells), diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index e1dcd808..451a8a70 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -39,7 +39,25 @@ local function cartOfScope(scope) end local CacheContract = require("src.import.CacheContract") +local Transition = require("src.ui.kit.Transition") local COMMUNITY_URL = "https://bois.icu" + +local TAB_ORDER, TAB_AT = {}, {} +local function tabOrder() + if #TAB_ORDER == 0 then + for _, version in ipairs(GameVersion.ORDER) do + TAB_ORDER[#TAB_ORDER + 1] = version + end + local okView, View = pcall(require, "src.import.LauncherView") + if okView then + for _, t in ipairs(View.HEADER_TABS or {}) do + TAB_ORDER[#TAB_ORDER + 1] = t.id + end + end + for i, id in ipairs(TAB_ORDER) do TAB_AT[id] = i end + end + return TAB_AT +end local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. "or a link from the discord that bryanthaboi himself posted, just know " .. "it might have been tampered with. go to the discord to verify " .. @@ -420,6 +438,7 @@ local function commandOutput(command) -- lock can free a FILE while a worker thread's popen is walking the stream -- list, which deadlocks that thread for good (see HostShell). HostShell.pclose(pipe) + HostShell.pumpHostEvents() result = trim(result) return result ~= "" and result or nil end @@ -1447,7 +1466,24 @@ function RomImporter.new(onComplete, opts) and ".gbc" or ".gb") end RomImporter.syncAndroidShortcuts() + Transition.reset() + Transition.armed = false + local okMotion, motionOpts = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + Transition.reduceMotion = os.getenv("POKEPORT_REDUCE_MOTION") == "1" + or opts.reduceMotion == true + or (okMotion and type(motionOpts) == "table" + and motionOpts.reduceMotion == true) or false self:_applyLastVersionTab() + if type(opts.initialTab) == "string" and opts.initialTab ~= "" then + self:_switchTab(opts.initialTab) + end + if type(opts.joinCode) == "string" and opts.joinCode ~= "" then + self:_switchTab("online") + local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") + if okOnline then pcall(OnlinePanel.deepLink, self, opts.joinCode, "player") end + end self:_queueBaseRomScan() -- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a @@ -1585,9 +1621,15 @@ function RomImporter:focus(f) and love.filesystem.read("pick_error.flag") if pickError then love.filesystem.remove("pick_error.flag") - local text = "Could not read the picked file. Reopen the picker and choose " - .. "it with the Files (Documents) app, or copy it into: " - .. love.filesystem.getSaveDirectory() + local text + if pickError:find("cancelled:", 1, true) == 1 then + text = "The file manager did not return a file. Try a different file " + .. "manager, or copy it into: " .. love.filesystem.getSaveDirectory() + else + text = "Could not read the picked file. Reopen the picker and choose " + .. "it with the Files (Documents) app, or copy it into: " + .. love.filesystem.getSaveDirectory() + end local legacyRequiredPick = self.requiredImportLegacyRomPick and self.pickerPendingKind == "required_import" if self.pickerPendingKind == "required_import" @@ -2819,6 +2861,10 @@ end function RomImporter:update(dt) self.pulse = self.pulse + dt + if not Transition.armed then + self._motionFrames = (self._motionFrames or 0) + 1 + if self._motionFrames > 1 then Transition.armed = true end + end if self._launchFade then self._launchFade.elapsed = self._launchFade.elapsed + dt if self._launchFade.elapsed >= self._launchFade.duration then @@ -2842,12 +2888,14 @@ function RomImporter:update(dt) -- nothing. They run whether or not the view is up, so a refresh started -- before a tab switch still completes. self:_pumpFindFetch() + self:_pumpFindDetails() self:_pumpModInfoFetch() self:_queueFindEnrichment() self:_pumpFindStats() self:_pumpFindThumbs() self:_pumpSkinFetch() self:_pumpSync(dt) + self:_pumpOnline(dt) self:_pumpModCheck() self:_pumpModInstall() self:_pumpCartInstall() @@ -2861,6 +2909,9 @@ function RomImporter:update(dt) if shot and not self._shotDone then if not self._shotSized then self._shotSized = true + if os.getenv("POKEPORT_REDUCE_MOTION") ~= "0" then + Transition.reduceMotion = true + end local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$") if w and love.window and love.window.setMode then pcall(love.window.setMode, tonumber(w), tonumber(h), @@ -2886,6 +2937,9 @@ function RomImporter:update(dt) if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then self:_openSettings() end + if os.getenv("POKEPORT_LAUNCHER_BUG") == "1" then + self:_openBugPanel() + end -- POKEPORT_LAUNCHER_FIND_KIND=mods|carts picks which half of the feed -- the FIND tab is browsing; the switch is otherwise only a click. local findKind = os.getenv("POKEPORT_LAUNCHER_FIND_KIND") @@ -2927,7 +2981,48 @@ function RomImporter:update(dt) { resizable = true }) end end - if self._shotTimer > 1.2 then + local motion = os.getenv("POKEPORT_LAUNCHER_MOTION") + if motion and motion ~= "" and not self._shotMotionAt + and self._shotTimer > 0.9 then + self._shotMotionAt = 0 + local ms = tonumber(os.getenv("POKEPORT_MOTION_MS") or "") + if ms and ms > 0 then + for kind in pairs(Transition.DURATIONS) do + Transition.DURATIONS[kind] = ms / 1000 + end + end + if motion:match("^go:") then + local okOP, OP = pcall(require, "src.import.OnlinePanel") + if okOP then OP.go(self, motion:sub(4)) end + elseif motion == "modal-close" then + self._modConfirm = nil + elseif motion == "modal" then + self._modConfirm = { + kind = "update", title = "Install mod", yesLabel = "Install", + lines = { "JP GREEN - Poketto Monsuta Midori v0.4.4", + "by bryanthaboi", + "Mods are not reviewed - trust the author." }, + } + else + self:_switchTab(motion) + end + end + if self._shotMotionAt then + self._shotMotionAt = self._shotMotionAt + 1 + local want = tonumber(os.getenv("POKEPORT_LAUNCHER_SHOT_SEQ") or "4") or 4 + if self._shotMotionAt <= want then + local path = (shot:gsub("%.png$", "")) + .. "-" .. self._shotMotionAt .. ".png" + love.graphics.captureScreenshot(function(imagedata) + local fd = imagedata:encode("png") + local f = io.open(path, "wb") + if f then f:write(fd:getString()) f:close() end + end) + else + self._shotDone = true + love.event.quit() + end + elseif self._shotTimer > 1.2 then self._shotDone = true love.graphics.captureScreenshot(function(imagedata) local fd = imagedata:encode("png") @@ -3013,6 +3108,10 @@ local PAD_DEAD = 0.28 local PAD_SPEED = 560 -- px/s at full stick deflection local PAD_DPAD_SPEED = 420 +function RomImporter:_consolePointerHost() + return (self.isNX or Platform.isUWP()) and true or false +end + function RomImporter:_activatePadCursor() if self._padCursorActive then return end local ox, oy, w, h = SafeArea.rect() @@ -3029,7 +3128,7 @@ end -- and makes the virtual cursor lag. Expose the pad pointer through a getPosition -- shim instead; desktop keeps the setPosition path unchanged. function RomImporter:_ensureNxPointerBridge() - if not self.isNX or self._nxPointerBridge then return end + if not self:_consolePointerHost() or self._nxPointerBridge then return end if not (love and love.mouse and love.mouse.getPosition) then return end self._nxRealGetPosition = love.mouse.getPosition local importer = self @@ -3054,7 +3153,7 @@ end -- NX only: drop the getPosition shim + hide the virtual cursor before a host -- takes over input (embedded save editor). Desktop is a no-op. function RomImporter:parkNxPointerForHost() - if not self.isNX then return end + if not self:_consolePointerHost() then return end self._padCursorActive = false self:_restoreNxPointerBridge() end @@ -3090,7 +3189,7 @@ function RomImporter:resumeAfterOverlay() end function RomImporter:_cycleTab(delta) - local order = { "mods", "find", "skins", "bug" } + local order = { "mods", "find", "skins" } for i = #GameVersion.ORDER, 1, -1 do table.insert(order, 1, GameVersion.ORDER[i]) end @@ -3101,6 +3200,48 @@ function RomImporter:_cycleTab(delta) self:_switchTab(order[((idx - 1 + delta) % #order) + 1]) end +local STICK_NAV_ON = 0.5 +local STICK_NAV_OFF = 0.3 +local STICK_NAV_DELAY = 0.28 +local STICK_NAV_REPEAT = 0.12 + +function RomImporter:_stickNavDir() + local nav = self._stickNav + local on = (nav and nav.dir) and STICK_NAV_OFF or STICK_NAV_ON + local ax = self._padAxis.leftx or 0 + local ay = self._padAxis.lefty or 0 + if math.abs(ax) >= math.abs(ay) then + if math.abs(ax) > on then return ax > 0 and "right" or "left" end + else + if math.abs(ay) > on then return ay > 0 and "down" or "up" end + end + if self._padDir.dpleft then return "left" end + if self._padDir.dpright then return "right" end + if self._padDir.dpup then return "up" end + if self._padDir.dpdown then return "down" end + return nil +end + +function RomImporter:_navigateWithStick(dt, Kit) + local nav = self._stickNav + if not nav then nav = {}; self._stickNav = nav end + local dir = self:_stickNavDir() + if not dir then + nav.dir, nav.t, nav.fired = nil, 0, false + return + end + if nav.dir ~= dir then + nav.dir, nav.t, nav.fired = dir, 0, false + if Kit then Kit.navigate(dir) end + return + end + nav.t = (nav.t or 0) + dt + if nav.t >= (nav.fired and STICK_NAV_REPEAT or STICK_NAV_DELAY) then + nav.t, nav.fired = 0, true + if Kit then Kit.navigate(dir) end + end +end + function RomImporter:_updatePadCursor(dt) local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then @@ -3110,7 +3251,7 @@ function RomImporter:_updatePadCursor(dt) end end - if self.isNX then + if self:_consolePointerHost() then self:_ensureNxPointerBridge() -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling -- the cursor; desktop keeps raw dt (setPosition path already smooth there). @@ -3121,7 +3262,7 @@ function RomImporter:_updatePadCursor(dt) -- pointer after bumping a stick once. On NX this must stay off: love-nx / -- SDL often drifts the system mouse with the stick (or touch), and axis -- events are not every frame, so yield+reactivate flickers the overlay. - if not self.isNX then + if not self:_consolePointerHost() then local mx, my = love.mouse.getPosition() if self._lastMouseX and self._padCursorActive then if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then @@ -3131,6 +3272,17 @@ function RomImporter:_updatePadCursor(dt) self._lastMouseX, self._lastMouseY = mx, my end + if not self._padCursorActive and not self.isNX + and (Platform.isUWP() or self._padNavChosen) then + self:_navigateWithStick(dt, okKit and Kit or nil) + local scrollY = self._padAxis.righty or 0 + if math.abs(scrollY) > PAD_DEAD and self._flex then + require("src.import.LauncherView").wheelmoved(self, 0, -scrollY * 8 * dt) + end + return + end + self._stickNav = nil + local ax = self._padAxis.leftx or 0 local ay = self._padAxis.lefty or 0 local dx, dy = 0, 0 @@ -3159,7 +3311,7 @@ function RomImporter:_updatePadCursor(dt) if overY ~= 0 and self._flex then require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) end - if not self.isNX and love.mouse.setPosition then + if not self:_consolePointerHost() and love.mouse.setPosition then pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y end @@ -3173,7 +3325,8 @@ function RomImporter:_updatePadCursor(dt) end function RomImporter:gamepadpressed(_, button) - local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button + if Transition.active() then return end + local action =(GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then @@ -3187,6 +3340,7 @@ function RomImporter:gamepadpressed(_, button) -- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor: if action == "y" or button == "y" then self._padCursorActive = not self._padCursorActive + self._padNavChosen = not self._padCursorActive if okKit then Kit._ringShown = not self._padCursorActive end self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]" self._cursorModeToastTime = love.timer.getTime() @@ -3252,11 +3406,11 @@ function RomImporter:gamepadpressed(_, button) return elseif self.tab == "find" then Kit.VirtualKeyboard.open({ - text = self._findQuery or "", + text = self.findQuery or "", title = "Search Mods", onDone = function(newText, confirmed) if confirmed then - self._findQuery = newText + self.findQuery = newText if self._refreshFind then self:_refreshFind() end end end @@ -3314,6 +3468,11 @@ function RomImporter:gamepadpressed(_, button) end return elseif action == "b" then + if self._bugModal then self:_closeBugPanel(); return end + if self.tab == "online" then + local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") + if okOnline and OnlinePanel.back(self) then return end + end if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return elseif self._rename then self._rename = nil; self:_disarmTextInput(); return elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return @@ -3341,6 +3500,11 @@ function RomImporter:gamepadpressed(_, button) self._padCursor.x, self._padCursor.y) end elseif action == "b" then + if self._bugModal then self:_closeBugPanel(); return end + if self.tab == "online" then + local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") + if okOnline and OnlinePanel.back(self) then return end + end if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return elseif self._rename then self._rename = nil; self:_disarmTextInput(); return elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return @@ -3448,7 +3612,6 @@ function RomImporter:joystickhat(joystick, hat, direction) })[direction] or {} for _, dir in ipairs(dirs) do self._padDir[dir] = true end self._rawHatDirs[hat] = dirs - if #dirs > 0 then self:_activatePadCursor() end end -- Player pressed Play on a game whose ROM is imported: hand off to boot. @@ -3665,10 +3828,19 @@ end -- the soft keyboard drop with the panel they belonged to; each tab's scroll -- offset persists inside the view's per-tab scroll container. function RomImporter:_switchTab(id) + if id == "bug" then return self:_openBugPanel() end + if self.tab and self.tab ~= id then + local at = tabOrder() + Transition.start("tabs", "tab", { + dir = ((at[id] or 0) >= (at[self.tab] or 0)) and 1 or -1, + from = self.tab, to = id, + }) + end self.tab = id if id ~= "find" then self._findVisibleEntries = nil end self._findSearchFocus = false self._skinUrlFocus = false + self._onlineFocus = nil self:_disarmTextInput() -- the skins list is cheap and can change behind the launcher's back -- (an export, a hand-dropped folder), so re-read it on every visit @@ -3985,6 +4157,13 @@ function RomImporter:_syncSupported() return self._syncTransportOk end +function RomImporter:_pumpOnline(dt) + if not self._online then return end + local ok, OnlinePanel = pcall(require, "src.import.OnlinePanel") + if not ok then return end + pcall(OnlinePanel.update, self, dt) +end + function RomImporter:_pumpSync(dt) if self._sync == nil then if not self.launcher or self._syncBooted then return end @@ -4199,6 +4378,17 @@ end -- ------- settings gear (options.lua + enabled mods' option schemas) +function RomImporter:_openBugPanel() + self._settings = nil + self._bugModal = true + return true +end + +function RomImporter:_closeBugPanel() + self._bugModal = nil + return true +end + function RomImporter:_openSettings() -- The touch-overlay editor is a host screen, so the model gets it as a -- hook rather than reaching for main.lua's handler itself. Closing the @@ -4305,6 +4495,13 @@ end -- notice line used. -- Desktop picks a .cart file; everywhere else CartStore's stray scan already -- adopts anything dropped in the folder, so we just point at it. +function RomImporter:_resyncPointerAfterDialog() + self._mouseAt = nil + self._clickPt = nil + self._prevMouseDown = (love and love.mouse and love.mouse.isDown + and love.mouse.isDown(1)) and true or false +end + function RomImporter:importCartFile(version) local CartStore = require("src.carts.CartStore") local FilePicker = require("src.core.FilePicker") @@ -4317,6 +4514,7 @@ function RomImporter:importCartFile(version) end local path = FilePicker.open("Choose a cart", { label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } }) + self:_resyncPointerAfterDialog() if not path then return false end local bytes = FilePicker.read(path) if not bytes then @@ -4352,6 +4550,7 @@ function RomImporter:fileUrl(path) end function RomImporter:keypressed(key) + if Transition.active() then return end local okKit, Kit = pcall(require, "src.ui.kit.Kit") if okKit then if Kit.FileBrowser and Kit.FileBrowser.active then @@ -4483,6 +4682,7 @@ function RomImporter:keypressed(key) if key == "escape" then if self._findDetails then self._findDetails = nil + self:_cancelFindDetails() elseif self._modReleaseNotes then self._modReleaseNotes = nil elseif self._appPatchNotes then @@ -4501,6 +4701,41 @@ function RomImporter:keypressed(key) if key == "escape" then self._cartPopup = nil end return end + if self._bugModal and key == "escape" then + self:_closeBugPanel() + return + end + if self._pcPicker and key == "escape" and not self._onlineFocus then + require("src.import.OnlinePanel").pcClose(self) + return + end + if self._onlineFocus then + local OnlinePanel = require("src.import.OnlinePanel") + local st = OnlinePanel.state(self) + local field = self._onlineFocus + if key == "backspace" then + if field == "online-name" then + st.nameDraft = utf8Back(st.nameDraft or "") + elseif field == "online-note" then + st.note = utf8Back(st.note or "") + elseif field == "online-code" then + st.joinCode = utf8Back(st.joinCode or "") + elseif field == "online-trade-code" then + local tr = OnlinePanel.tradeState(self) + tr.code = utf8Back(tr.code or "") + elseif field == OnlinePanel.PC_FIELD then + local pc = OnlinePanel.pcPicker(self) + OnlinePanel.pcQuery(self, utf8Back((pc and pc.query) or "")) + end + elseif key == "return" or key == "kpenter" then + self:_commitOnlineField() + elseif key == "escape" then + self._onlineFocus = nil + st.nameDraft = nil + self:_disarmTextInput() + end + return + end if self._skinUrlFocus then if key == "backspace" then self.skinUrl = utf8Back(self.skinUrl or "") @@ -4526,6 +4761,10 @@ function RomImporter:keypressed(key) end return end + if key == "escape" and self.tab == "online" then + local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel") + if okOnline and OnlinePanel.back(self) then return end + end if self.workState == "working" then return end -- Keyboard focus ring: arrows move it, Enter activates it -- but only once -- the arrows have been used, so the long-standing "Enter plays the visible @@ -4612,7 +4851,7 @@ function RomImporter:_cartSealSlot(version) return nil, scope end -function RomImporter:cartPlan(version) +function RomImporter:cartPlan(version, listed) local id = self.activeCart and self.activeCart[version] or nil if not id then return nil, nil end local slot = self:_cartSealSlot(version) @@ -4623,7 +4862,7 @@ function RomImporter:cartPlan(version) if cached and cached.key == key then return cached.report, slot end local installed = {} pcall(function() - local rows = require("src.mods.LauncherMods").list(version) or {} + local rows = listed or require("src.mods.LauncherMods").list(version) or {} for _, row in ipairs(rows) do local manifest = type(row.manifest) == "table" and row.manifest or row if type(manifest.id) == "string" then @@ -5124,7 +5363,40 @@ function RomImporter:_disarmTextInput() end end +function RomImporter:_focusOnlineField(key) + self._onlineFocus = key + local OnlinePanel = require("src.import.OnlinePanel") + local st = OnlinePanel.state(self) + if key == "online-name" then st.nameDraft = st.name or "" end + self:_armTextInput() +end + +function RomImporter:_commitOnlineField() + local key = self._onlineFocus + self._onlineFocus = nil + self:_disarmTextInput() + if key ~= "online-name" then return end + local OnlinePanel = require("src.import.OnlinePanel") + local st = OnlinePanel.state(self) + OnlinePanel.setName(self, st.nameDraft or "") + st.nameDraft = nil +end + +function RomImporter:playArena(version, cartId, spec) + if not version or not spec then return false end + self._handedOff = true + resetPointerCursor(self) + if self._flex then require("src.import.LauncherView").detach(self) end + if self.onComplete then self.onComplete(version, cartId, { arena = spec }) end + return true +end + function RomImporter:_blurPanelFields() + if self._pcPicker then return end + if self._onlineFocus then + self:_commitOnlineField() + return + end if not (self._findSearchFocus or self._skinUrlFocus) then return end if self._indexPrompt or self._rename or self._settingsText or self._profileSavePrompt or self._profileRenamePrompt @@ -5193,6 +5465,24 @@ function RomImporter:textinput(text) utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) return end + if self._onlineFocus then + local OnlinePanel = require("src.import.OnlinePanel") + local st = OnlinePanel.state(self) + if self._onlineFocus == "online-name" then + st.nameDraft = OnlinePanel.sanitizeName((st.nameDraft or "") .. text) + elseif self._onlineFocus == "online-note" then + st.note = utf8Cap((st.note or "") .. text, OnlinePanel.NOTE_MAX) + elseif self._onlineFocus == "online-code" then + st.joinCode = OnlinePanel.sanitizeCode((st.joinCode or "") .. text) + elseif self._onlineFocus == "online-trade-code" then + local tr = OnlinePanel.tradeState(self) + tr.code = OnlinePanel.sanitizeCode((tr.code or "") .. text) + elseif self._onlineFocus == OnlinePanel.PC_FIELD then + local pc = OnlinePanel.pcPicker(self) + OnlinePanel.pcQuery(self, ((pc and pc.query) or "") .. text) + end + return + end if self._skinUrlFocus then self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""), MAX_SKIN_URL) @@ -5294,18 +5584,18 @@ function RomImporter:_refreshMods() end -- a pin is judged against the whole listing: the cart named it, so it is -- listed even where the game filter above would have dropped it - local cartId, report = self:modCartPlan() + local cartId, report = self:modCartPlan(listed) if cartId then self.mods = self:_cartPinRows(cartId, report, listed) end end -- The cart the MODS panel is answering for, with the plan that resolves its -- pins, or nil when the panel is on a base game. -function RomImporter:modCartPlan() +function RomImporter:modCartPlan(listed) local version = self.modScope if not version then return nil end local id = self.activeCart and self.activeCart[version] or nil if not id then return nil end - local report = self:cartPlan(version) + local report = self:cartPlan(version, listed) if type(report) ~= "table" or type(report.pins) ~= "table" then return nil end return id, report, version end @@ -5460,6 +5750,29 @@ function RomImporter:_modUpdateInfo(id) return self.modUpdateInfo and self.modUpdateInfo[id] or nil end +function RomImporter:_rejudgeModUpdate(id, fallbackVersion) + if type(id) ~= "string" then return end + local info = self:_modUpdateInfo(id) + local releases = info and info.releases + if type(releases) ~= "table" or #releases == 0 then return end + local installed + for _, m in ipairs(self.mods or {}) do + if m.id == id then installed = m.version break end + end + installed = installed or fallbackVersion + if type(installed) ~= "string" or installed == "" then return end + local ModUpdate = require("src.mods.ModUpdate") + local status, best = ModUpdate.statusFor(installed, releases) + info.status = status + info.latest = best and best.version or nil + info.best = best + info.err = nil + self._modUpdateRev = (self._modUpdateRev or 0) + 1 + for _, item in ipairs(self._modInfoFetch or {}) do + if item.mod and item.mod.id == id then item.mod.version = installed end + end +end + -- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist -- so that game's checkbox and status chips reflect the new resolution. -- Enabling an experimental mod arms a confirmation for that same game. @@ -5876,6 +6189,7 @@ function RomImporter:_pumpModInstall() -- The installed list is what the Install / Installed labels read, so it has -- to be re-derived before the next paint or the card lies. pcall(self._refreshMods, self) + self:_rejudgeModUpdate(spec.modId, resErr or job.version) local shown = tostring(resErr or job.version or "") local text = ("%s %s %s"):format(spec.verb or "Installed", tostring(spec.name or spec.modId), shown) @@ -6015,6 +6329,91 @@ function RomImporter:_installCartPins(version, id) self:pressInstallCartMods(version) end +-- ------- one-call cart install for the ONLINE tab +function RomImporter:installCartForOnline(cartId, version, onDone) + if type(cartId) ~= "string" or cartId == "" then + if onDone then onDone(false, "no cart was named") end + return false + end + if self._onlineCart then return false end + self._onlineCart = { id = cartId, base = version, done = onDone, + stage = "index" } + self:_ensureFind() + return true +end + +function RomImporter:_finishOnlineCart(ok, text) + local job = self._onlineCart + self._onlineCart = nil + if job and job.done then pcall(job.done, ok, text) end +end + +function RomImporter:pumpOnlineCartInstall() + local job = self._onlineCart + if not job then return end + local CartStore = require("src.carts.CartStore") + + if job.stage == "index" then + if self._findFetch then return end + if not self.findLoaded then + self:_ensureFind() + return + end + local entry + for _, row in ipairs((self.findIndex and self.findIndex.carts) or {}) do + if row.id == job.id then + entry = row + break + end + end + if not entry then + return self:_finishOnlineCart(false, + Strings("No mod index lists a cart called %s.", tostring(job.id))) + end + if self._modInstall or self._cartInstall then return end + self.findNotice = nil + self:_beginCartInstall(entry) + if not self._cartInstall then + return self:_finishOnlineCart(false, + tostring((self.findNotice and self.findNotice.text) + or "that cart has no download")) + end + job.stage = "download" + return + end + + if job.stage == "download" then + if self._cartInstall then return end + local ok, cart = pcall(CartStore.get, job.id) + if not ok or type(cart) ~= "table" then + return self:_finishOnlineCart(false, + tostring((self.findNotice and self.findNotice.text) + or "that cart did not install")) + end + self._modConfirm = nil + job.base = cart.base + job.title = cart.title or cart.id + if #self:_cartPinsMissing(cart.base, job.id) == 0 then + return self:_finishOnlineCart(true, + Strings("%s is installed.", tostring(job.title))) + end + self:_installCartPins(cart.base, job.id) + job.stage = "pins" + return + end + + if job.stage == "pins" then + if self._cartFill then return end + if #self:_cartPinsMissing(job.base, job.id) > 0 then + return self:_finishOnlineCart(false, + tostring((self.cartFillNotice and self.cartFillNotice.text) + or "some of this cart's mods could not be installed")) + end + return self:_finishOnlineCart(true, + Strings("%s and its mods are installed.", tostring(job.title or job.id))) + end +end + -- Start an async pull for a single dependency function RomImporter:_startDepPull(dep) if not dep or not dep.github then return end @@ -6391,27 +6790,17 @@ function RomImporter:_findRows() category = (not carts) and self.findCategory or nil, base = carts and self.findBase or nil, }) - if self.modScope then - if carts then - -- A cart plays as exactly one game, so the scope is a plain match on - -- the base rather than a ModTargets coverage question. - local kept = {} - for _, entry in ipairs(rows) do - if entry.base == self.modScope then kept[#kept + 1] = entry end + if self.modScope and not carts then + local ModTargets = require("src.mods.ModTargets") + local gen = GameVersion.generation(self.modScope) + local kept = {} + for _, entry in ipairs(rows) do + local versions = ModTargets.normalize(entry.games) + if #versions == 0 or ModTargets.covers(versions, gen) then + kept[#kept + 1] = entry end - rows = kept - else - local ModTargets = require("src.mods.ModTargets") - local gen = GameVersion.generation(self.modScope) - local kept = {} - for _, entry in ipairs(rows) do - local versions = ModTargets.normalize(entry.games) - if #versions == 0 or ModTargets.covers(versions, gen) then - kept[#kept + 1] = entry - end - end - rows = kept end + rows = kept end self._findRowsCache = { src = all, query = self.findQuery, category = self.findCategory, base = self.findBase, @@ -6724,16 +7113,39 @@ end function RomImporter:_findShowDetails(entry) local ModIndex = require("src.mods.ModIndex") local url = ModIndex.joinUrl(entry._base, entry.description_url) - local body = entry.summary or "" - if url then - local ok, text = pcall(ModIndex.fetchText, url) - if ok and type(text) == "string" and text ~= "" then body = text end - end + self:_cancelFindDetails() self._findDetails = { title = entry.title or entry.id, - body = body, + body = entry.summary or "", + loading = url ~= nil, scroll = 0, } + if url then + self._findDetailsFetch = ModIndex.beginFetchText(url) + end +end + +function RomImporter:_cancelFindDetails() + local h = self._findDetailsFetch + if not h then return end + self._findDetailsFetch = nil + pcall(require("src.mods.ModIndex").cancelFetchText, h) +end + +function RomImporter:_pumpFindDetails() + local h = self._findDetailsFetch + if not h then return end + if not self._findDetails then + self:_cancelFindDetails() + return + end + local ok, done, text = pcall(require("src.mods.ModIndex").pumpFetchText, h) + if ok and not done then return end + self._findDetailsFetch = nil + local d = self._findDetails + if not d then return end + d.loading = false + if ok and type(text) == "string" and text ~= "" then d.body = text end end -- Arm the install confirm. The compatibility list is the whole point of the diff --git a/src/import/online/Home.lua b/src/import/online/Home.lua new file mode 100644 index 00000000..b208995d --- /dev/null +++ b/src/import/online/Home.lua @@ -0,0 +1,142 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local Home = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end + +local STATE_TEXT = { + online = "Connected to the lobby.", + connecting = "Connecting...", + reconnecting = "Reconnecting...", +} + +function Home.identity(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local client = Client() + local pad, gap = Ui.pads(m) + local rowH = math.max(m.btnH, Kit.tapMin()) + local state = client.state() + local you = client.you() + local verified = type(you) == "table" and you.verified == true + local offline = state == "offline" or state == "error" + local statusLine = st.status + if not statusLine then + if state == "error" then + statusLine = tostring(client.error() or "Disconnected.") + else + statusLine = Strings(STATE_TEXT[state] + or "Offline. Connect to see who is playing.") + end + end + local statusH = Kit.wrapHeight("small", statusLine, w - 2 * pad, 2) + local stacked = not m.twoCol + local cardH = pad + Kit.textHeight("small") + gap + rowH + gap + statusH + pad + if stacked then cardH = cardH + rowH + gap end + + Kit.card(x, y, w, cardH) + local cx, cy = x + pad, y + pad + local inner = w - 2 * pad + cy = cy + Ui.label(Strings("Display name"), cx, cy) + gap + + local connectLabel = offline and Strings("Connect") or Strings("Disconnect") + local connectW = math.min(math.floor(inner * (stacked and 0.5 or 0.4)), + Kit.textWidth("small", connectLabel) + math.floor(36 * m.s)) + local pillW = math.floor(64 * m.s) + local badgeW = verified and (Kit.textWidth("micro", "VERIFIED") + + math.floor(18 * m.s)) or 0 + local fieldW = stacked and inner + or math.max(math.floor(60 * m.s), + inner - connectW - badgeW - pillW - 3 * gap) + + local editing = imp._onlineFocus == "online-name" + local shown = editing and (st.nameDraft or "") or OnlinePanel.ensureName(imp) + Ui.field(imp, cx, cy, fieldW, rowH, "online-name", shown, + Strings("Display name"), editing, function(text) + OnlinePanel.setName(imp, text) + end) + if stacked then cy = cy + rowH + gap end + local px = stacked and cx or (cx + fieldW + gap) + local badgeY = cy + math.floor((rowH - 18 * m.s) / 2) + if verified then + Kit.tag(px, badgeY, badgeW, math.floor(18 * m.s), "VERIFIED", PAL.green, + { fill = true, bold = true }) + px = px + badgeW + gap + end + Kit.tag(px, badgeY, pillW, math.floor(18 * m.s), + offline and "OFFLINE" or "ONLINE", offline and PAL.line or PAL.green) + LV().btn(imp, x + w - pad - connectW, cy, connectW, rowH, "online-connect", + connectLabel, { + kind = offline and "primary" or "ghost", font = "small", + enabled = st.job == nil, + action = function() + if offline then + OnlinePanel.connect(imp) + else + OnlinePanel.disconnect(imp) + end + end }) + cy = cy + rowH + gap + Kit.textWrapped("small", statusLine, cx, cy, inner, + st.statusOk and PAL.green or (state == "error" and PAL.red or PAL.muted), 2) + return cardH +end + +local CARDS = { + { id = "play", title = "Play", + note = "Find an open lobby or host your own battle." }, + { id = "watch", title = "Watch", + note = "Open matches and running tournaments to spectate." }, + { id = "trade", title = "Trade", + note = "Swap POKeMON with another save or another trainer." }, +} + +function Home.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local c = OnlinePanel.cache(imp) + local pad, gap = Ui.pads(m) + local online = Client().state() == "online" + local cy = y + Home.identity(imp, x, y, w, m) + gap + + if online then + Kit.text("small", Strings("%d players online, %d open lobbies", + c.counts.players, c.counts.lobbies), x, cy, PAL.heading) + else + Kit.text("small", Strings("Connect to see who is playing."), x, cy, + PAL.muted) + end + cy = cy + Kit.textHeight("small") + gap + + local cardH = math.max(math.floor(74 * m.s), m.rowH * 2) + if m.twoCol then + local colW = math.floor((w - 2 * gap) / 3) + for i, card in ipairs(CARDS) do + local cx = x + (i - 1) * (colW + gap) + Ui.entryCard(imp, cx, cy, colW, cardH, "online-go-" .. card.id, + Strings(card.title), Strings(card.note), + online or card.id == "trade", + function() OnlinePanel.go(imp, card.id) end) + end + cy = cy + cardH + else + for _, card in ipairs(CARDS) do + Ui.entryCard(imp, x, cy, w, cardH, "online-go-" .. card.id, + Strings(card.title), Strings(card.note), + online or card.id == "trade", + function() OnlinePanel.go(imp, card.id) end) + cy = cy + cardH + gap + end + cy = cy - gap + end + cy = cy + gap + return (cy - y) + pad +end + +return Home diff --git a/src/import/online/PcPicker.lua b/src/import/online/PcPicker.lua new file mode 100644 index 00000000..2261095d --- /dev/null +++ b/src/import/online/PcPicker.lua @@ -0,0 +1,92 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local PcPicker = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Sprites() return require("src.online.OnlineSprites") end + +function PcPicker.draw(imp, m) + local OnlinePanel = OP() + local pc = OnlinePanel.pcPicker(imp) + if not pc then return false end + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local tiny = math.floor(4 * m.s) + local w = math.floor(520 * m.s) + local btnH = math.max(m.btnH, Kit.tapMin()) + local rowH = math.max(m.rowH, Kit.tapMin()) + local rows = OnlinePanel.pcRows(imp) + local perPage = 6 + local h = math.floor(math.min(m.H - 2 * m.pad, + pad + Kit.textHeight("button") + gap + Kit.textHeight("small") + tiny + + btnH + gap + perPage * (rowH + tiny) + gap + btnH + pad)) + local px, py, pw, ph = LV().modalPanel(m, w, h) + local innerW = pw - 2 * pad + local cy = py + pad + + Kit.textBold("button", Strings("From the PC"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + gap + + cy = cy + Ui.label(Strings("Search"), px + pad, cy) + tiny + Ui.field(imp, px + pad, cy, innerW, btnH, OnlinePanel.PC_FIELD, pc.query, + Strings("Name, species or box"), + imp._onlineFocus == OnlinePanel.PC_FIELD, + function(text) OnlinePanel.pcQuery(imp, text) end) + cy = cy + btnH + gap + + local listBottom = py + ph - pad - btnH - gap + local fits = math.max(1, math.floor((listBottom - cy) / (rowH + tiny))) + if #rows == 0 then + Kit.emptyBox(px + pad, cy, innerW, rowH * 2, + Strings("Nothing in the PC matches that.")) + else + imp._pages = imp._pages or {} + local pageKey = "online-pc" + local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1, + #rows, fits) + imp._pages[pageKey] = pageNow + local sprites = Sprites() + local iconSize = math.max(16, math.floor(rowH * 0.62)) + for i = first, last do + local row = rows[i] + local ref = row.ref + local ink = LV().rowHit(imp, px + pad, cy, innerW, rowH, + row.order ~= nil, "online-pc-" .. row.key, function() + OnlinePanel.pcPick(imp, row) + end) + local tx = px + pad + math.floor(10 * m.s) + local sprite = sprites.get(row.version, row.mon) + if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2, + iconSize) then + tx = tx + iconSize + math.floor(6 * m.s) + end + if row.order then + Kit.textBold("small", ("%d."):format(row.order), tx, + cy + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading) + tx = tx + math.floor(20 * m.s) + end + local textW = innerW - (tx - px - pad) - math.floor(10 * m.s) + Kit.text("small", Kit.ellipsize("small", row.label, textW), tx, + cy + math.floor(5 * m.s), ink or PAL.text) + Kit.text("micro", Kit.ellipsize("micro", + tostring(row.source or ""), textW), tx, + cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted) + cy = cy + rowH + tiny + if ref == nil then break end + end + end + + local by = math.floor(py + ph - pad - btnH) + LV().btn(imp, px + pad, by, innerW, btnH, OnlinePanel.PC_CLOSE, + Strings("Done"), { kind = "primary", font = "small", + action = function() OnlinePanel.pcClose(imp) end }) + return true +end + +return PcPicker diff --git a/src/import/online/Play.lua b/src/import/online/Play.lua new file mode 100644 index 00000000..e4c4fa3d --- /dev/null +++ b/src/import/online/Play.lua @@ -0,0 +1,188 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local Play = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end + +function Play.yourLobby(imp, x, y, w, m) + local OnlinePanel = OP() + local c = OnlinePanel.cache(imp) + local mine = c.mine + if not mine then return 0 end + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local h = rowH + btnH + tiny + math.floor(16 * m.s) + Kit.card(x, y, w, h) + local tx = x + math.floor(12 * m.s) + local cy = y + math.floor(8 * m.s) + Kit.textBold("small", Strings("Your lobby"), tx, cy, PAL.heading) + cy = cy + Kit.textHeight("small") + tiny + Kit.text("micro", Kit.ellipsize("micro", + (mine.players or 0) < 2 and Strings("Waiting for an opponent - code %s", + tostring(mine.code or "------")) + or Strings("Code %s", tostring(mine.code or "------")), + w - math.floor(24 * m.s)), tx, cy, PAL.muted) + cy = cy + Kit.textHeight("micro") + tiny + local half = math.floor((w - math.floor(24 * m.s) - gap) / 2) + LV().btn(imp, tx, cy, half, btnH, "online-mine-return", Strings("Return"), + { kind = "primary", font = "small", + action = function() OnlinePanel.go(imp, "room") end }) + LV().btn(imp, tx + half + gap, cy, half, btnH, "online-mine-close", + Strings("Close"), + { kind = "danger", font = "small", + action = function() + pcall(Client().closeRoom) + OnlinePanel.invalidate(imp, "lobby") + end }) + return h + gap +end + +function Play.filters(imp, x, y, w, m) + local OnlinePanel = OP() + local _, gap, tiny = Ui.pads(m) + local h = math.max(math.floor(26 * m.s), Kit.tapMin()) + local cy = y + Ui.label(Strings(OnlinePanel.FILTER_LABEL), x, y) + tiny + local current = OnlinePanel.filter(imp) + local n = #OnlinePanel.FILTERS + local chipW = math.floor((w - gap * (n - 1)) / n) + for i, filter in ipairs(OnlinePanel.FILTERS) do + local id = filter.id + if Kit.chip(x + (i - 1) * (chipW + gap), cy, chipW, h, + Strings(filter.label), current == id, PAL.lineStrong, + "online-filter-" .. id) then + LV().queueAction(imp, "online-filter-" .. id, function() + OnlinePanel.setFilter(imp, id) + end) + end + end + return (cy - y) + h + tiny +end + +function Play.roomList(imp, x, y, w, availH, m, rows, idPrefix, action, label) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local online = Client().state() == "online" + local cy = y + if #rows == 0 then + Kit.emptyBox(x, cy, w, rowH * 2, online + and Strings("No open lobbies right now. Host one and it shows up here.") + or Strings("Connect to see who is playing.")) + return (rowH * 2) + gap + end + imp._pages = imp._pages or {} + local pageKey = idPrefix + local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 10) + local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1, + #rows, perPage) + imp._pages[pageKey] = pageNow + local joinW = math.floor(84 * m.s) + for i = first, last do + local row = rows[i] + local ink = LV().rowHit(imp, x, cy, w, rowH, false, + idPrefix .. "-" .. row.id, nil) + local tx = x + math.floor(10 * m.s) + local textW = w - joinW - math.floor(24 * m.s) + Kit.text("small", Kit.ellipsize("small", row.name, textW), tx, + cy + math.floor(5 * m.s), + row.reason and PAL.faint or (ink or PAL.heading)) + if row.verified then + local bw = math.floor(16 * m.s) + Kit.tag(tx + Kit.textWidth("small", row.name) + math.floor(6 * m.s), + cy + math.floor(5 * m.s), bw, math.floor(14 * m.s), "*", PAL.green) + end + local sub = row.reason and Strings("Can't join: %s", row.reason) + or ("%s %s %s %s"):format(row.game, row.arena, row.rule, + Strings("%d watching", row.spectators)) + if not row.reason and row.note then sub = sub .. " " .. row.note end + Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx, + cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), + row.reason and PAL.yellow or PAL.muted) + if row.reason == nil then + local code, rule = row.code, row.ruleTable + LV().btn(imp, x + w - joinW - math.floor(6 * m.s), + cy + (rowH - btnH) / 2, joinW, btnH, idPrefix .. "-go-" .. row.id, + Strings(label), + { kind = "accent", font = "small", enabled = online, + action = function() action(code, rule, row) end }) + end + cy = cy + rowH + tiny + end + if #rows > perPage then + local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey) + imp._pages[pageKey] = page + cy = cy + ph + end + return (cy - y) + gap +end + +function Play.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + local client = Client() + local _, gap, tiny = Ui.pads(m) + local btnH = math.max(m.btnH, Kit.tapMin()) + local online = client.state() == "online" + local version = OnlinePanel.engineVersion(imp) + local profile = OnlinePanel.myProfile(imp) + local canBattle = online and OnlinePanel.canBattleWith(version) + + local cy = y + Ui.header(imp, x, y, w, m, Strings("Play"), + online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line) + + cy = cy + Play.yourLobby(imp, x, cy, w, m) + + if version and OnlinePanel.isGen2(version) and not OnlinePanel.gen2Battles() then + cy = cy + Kit.textWrapped("small", + Strings("Gen 2 battles come later; you can still browse and trade."), + x, cy, w, PAL.yellow, 2) + tiny + end + + LV().btn(imp, x, cy, w, btnH, "online-host", Strings("Host a battle"), + { kind = "primary", font = "small", enabled = canBattle, + action = function() OnlinePanel.startWizard(imp, "hostBattle") end }) + cy = cy + btnH + gap + + cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny + local codeW = math.floor(w * 0.5) + Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode, + Strings("Six characters"), imp._onlineFocus == "online-code", + function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end) + LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH, + "online-join-code", Strings("Join"), + { kind = "accent", font = "small", + enabled = online and #st.joinCode == OnlinePanel.CODE_LEN, + action = function() OnlinePanel.startJoin(imp, st.joinCode) end }) + cy = cy + btnH + gap + + local rows = c.rooms + if #rows > OnlinePanel.FILTER_AT then + cy = cy + Play.filters(imp, x, cy, w, m) + end + + cy = cy + Ui.label(Strings("Open lobbies"), x, cy) + tiny + cy = cy + Play.roomList(imp, x, cy, w, + math.max(m.rowH * 2, availH - (cy - y) - btnH * 2), m, rows, + "online-entry", function(code, rule) + OnlinePanel.startJoin(imp, code, rule) + end, "Join") + + LV().btn(imp, x, cy, w, btnH, "online-tour-open", + Strings("Host a tournament"), + { kind = "ghost", font = "small", enabled = online and profile ~= nil, + action = function() OnlinePanel.startWizard(imp, "hostTournament") end }) + cy = cy + btnH + tiny + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y +end + +return Play diff --git a/src/import/online/Room.lua b/src/import/online/Room.lua new file mode 100644 index 00000000..547102e3 --- /dev/null +++ b/src/import/online/Room.lua @@ -0,0 +1,225 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local Room = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end + +Room.TRADE_STAGE_TEXT = { + waiting = "Waiting for the other trainer", + ready = "Both trainers are here", + battling = "Trade in progress", + ended = "Trade over", +} + +Room.STAGE_TEXT = { + waiting = "Waiting for a challenger", + ready = "Both trainers are picking a team", + battling = "Battle in progress", + ended = "Match over", +} + +function Room.codeCard(imp, x, y, w, m, code, idPrefix, copied) + local _, gap = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local copyW = math.floor(80 * m.s) + Kit.card(x, y, w, rowH) + Kit.text("title", tostring(code or "------"), x + math.floor(12 * m.s), + y + (rowH - Kit.textHeight("title")) / 2, PAL.heading) + LV().btn(imp, x + w - copyW - math.floor(8 * m.s), y + (rowH - btnH) / 2, + copyW, btnH, idPrefix .. "-copy", Strings("Copy"), + { kind = "ghost", font = "small", + action = function() + if love.system and love.system.setClipboardText then + pcall(love.system.setClipboardText, tostring(code or "")) + end + local st = OP().state(imp) + st.status, st.statusOk = Strings(copied), true + end }) + return rowH + gap +end + +function Room.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local client = Client() + local room = client.room() + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + + if not room then + local cy = y + Ui.header(imp, x, y, w, m, Strings("Room")) + Kit.emptyBox(x, cy, w, rowH * 2, Strings("You are not in a room.")) + return (cy - y) + rowH * 2 + end + + local stage = tostring(room.stage or "waiting") + local trade = tostring(room.intent or "battle") == "trade" + local cy = y + Ui.header(imp, x, y, w, m, + trade and Strings("Trade room") or Strings("Room"), + Strings(trade and (Room.TRADE_STAGE_TEXT[stage] or stage) + or (Room.STAGE_TEXT[stage] or stage)), PAL.lineStrong) + cy = cy + Room.codeCard(imp, x, cy, w, m, room.code, "online", + "Room code copied.") + cy = cy + Kit.textWrapped("small", + Strings("Give this code to your friend."), x, cy, w, PAL.muted, 1) + tiny + + if not trade then + local rule = room.profile and room.profile.rule + cy = cy + Ui.label(Strings("Rules"), x, cy) + tiny + cy = cy + Kit.textWrapped("small", + Strings("%s - %s", OnlinePanel.arenaText(room.profile or {}), + OnlinePanel.ruleText(rule)), x, cy, w, PAL.muted, 1) + tiny + end + + local deadline = not trade and room.deadlines + and (room.deadlines.ready or room.deadlines.shot) + if type(deadline) == "number" then + local left = OnlinePanel.countdown(deadline, client.serverTime() or 0) + if left then + cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w, + left <= 10 and PAL.red or PAL.muted, 1) + tiny + end + end + + if OnlinePanel.lastResult then + cy = cy + Kit.textWrapped("small", + Strings(OnlinePanel.RESULT_TEXT[OnlinePanel.lastResult] + or "The match ended."), x, cy, w, PAL.green, 1) + tiny + end + + local need = st.roomCart + if need then + cy = cy + Kit.textWrapped("small", + Strings("This room plays the %s cart.", tostring(need.id)), + x, cy, w, PAL.yellow, 2) + tiny + LV().btn(imp, x, cy, w, btnH, "online-install-cart", + st.cartInstall and Strings("Installing...") or Strings("Install cart"), + { kind = "accent", font = "small", enabled = st.cartInstall == nil, + action = function() OnlinePanel.installCart(imp, need) end }) + cy = cy + btnH + gap + end + + local me = OnlinePanel.mySeatId() + local hosting = me ~= nil and room.host == me + local playing = false + cy = cy + Ui.label(Strings("Players"), x, cy) + tiny + for _, player in ipairs(room.players or {}) do + if me and player.id == me then playing = true end + Kit.card(x, cy, w, rowH, "badge") + local text = tostring(player.name or "?") + if player.verified then text = text .. " *" end + if player.online == false then text = text .. Strings(" (away)") end + Kit.text("small", Kit.ellipsize("small", text, w - math.floor(96 * m.s)), + x + math.floor(10 * m.s), cy + (rowH - Kit.textHeight("small")) / 2, + PAL.heading) + if not trade then + local tagW = math.floor(74 * m.s) + Kit.tag(x + w - tagW - math.floor(8 * m.s), cy + (rowH - 18 * m.s) / 2, + tagW, math.floor(18 * m.s), player.ready and "READY" or "PICKING", + player.ready and PAL.green or PAL.line) + end + cy = cy + rowH + tiny + end + + if trade then + cy = cy + Kit.textWrapped("small", + (#(room.players or {}) < 2) + and Strings("Waiting for the other trainer.") + or Strings("Opening the trade..."), + x, cy, w, PAL.muted, 2) + tiny + LV().btn(imp, x, cy, w, btnH, "online-leave", + st.confirmLeave and Strings("Really leave?") or Strings("Leave"), + { kind = "danger", font = "small", + action = function() + if not st.confirmLeave then + st.confirmLeave = true + return + end + st.confirmLeave, st.ready = nil, false + client.leaveRoom() + OnlinePanel.clearPresence() + OnlinePanel.go(imp, "trade") + end }) + cy = cy + btnH + tiny + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y + end + + local spectators = room.spectators or {} + cy = cy + Ui.label(Strings("Spectators (%d)", #spectators), x, cy) + gap + + local rematch = OnlinePanel.lastResult ~= nil and stage ~= "battling" + local half = math.floor((w - gap) / 2) + LV().btn(imp, x, cy, half, btnH, "online-ready", + st.ready and Strings("Unready") + or (rematch and Strings("Rematch") or Strings("Ready")), + { kind = st.ready and "ghost" or "primary", font = "small", + enabled = playing and stage ~= "battling", + action = function() + if st.ready then + OnlinePanel.unready(imp) + else + OnlinePanel.sendReady(imp) + end + end }) + LV().btn(imp, x + half + gap, cy, half, btnH, "online-leave", + st.confirmLeave and Strings("Really leave?") or Strings("Leave"), + { kind = "danger", font = "small", + action = function() + if not st.confirmLeave then + st.confirmLeave = true + return + end + st.confirmLeave, st.ready = nil, false + client.leaveRoom() + OnlinePanel.clearPresence() + OnlinePanel.go(imp, "play") + end }) + cy = cy + btnH + tiny + + if hosting then + LV().btn(imp, x, cy, w, btnH, "online-host-more", + st.hostMore and Strings("Hide host controls") + or Strings("Host controls"), + { kind = "ghost", font = "small", + action = function() st.hostMore = not st.hostMore end }) + cy = cy + btnH + tiny + if st.hostMore then + for _, watcher in ipairs(spectators) do + local kickW = math.floor(64 * m.s) + Kit.text("small", Kit.ellipsize("small", + tostring(watcher.name or "?"), w - kickW), x + math.floor(10 * m.s), + cy + math.floor(4 * m.s), PAL.muted) + local id = watcher.id + LV().btn(imp, x + w - kickW, cy, kickW, btnH, + "online-kick-" .. tostring(id), Strings("Kick"), + { kind = "danger", font = "small", + action = function() + if type(client.kick) == "function" then pcall(client.kick, id) end + end }) + cy = cy + btnH + tiny + end + LV().btn(imp, x, cy, w, btnH, "online-close-room", + Strings("Close the room"), + { kind = "danger", font = "small", + action = function() + pcall(client.closeRoom) + OnlinePanel.go(imp, "play") + end }) + cy = cy + btnH + tiny + end + end + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y +end + +return Room diff --git a/src/import/online/Tournaments.lua b/src/import/online/Tournaments.lua new file mode 100644 index 00000000..f2a2571f --- /dev/null +++ b/src/import/online/Tournaments.lua @@ -0,0 +1,214 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") +local Room = require("src.import.online.Room") + +local PAL = Theme.PAL + +local Tournaments = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end + +Tournaments.STAGE_TEXT = { + registering = "Waiting for trainers", + running = "Bracket in progress", + finished = "Tournament over", +} + +function Tournaments.bracket(imp, x, y, w, m, tour) + local OnlinePanel = OP() + local gap = math.floor(6 * m.s) + local columns = OnlinePanel.bracketColumns(tour) + local cardH = math.floor(34 * m.s) + if #columns == 0 then + Kit.emptyBox(x, y, w, cardH, + Strings("The bracket is drawn when the tournament starts.")) + return cardH + end + local minColW = math.floor(96 * m.s) + local fit = math.max(1, math.floor((w + gap) / (minColW + gap))) + local shown = math.min(#columns, fit) + local first = 1 + if shown < #columns then + first = math.max(1, math.min(#columns - shown + 1, tonumber(tour.round) or 1)) + end + local colW = math.floor((w - gap * (shown - 1)) / shown) + local tallest = 0 + for i = 0, shown - 1 do + local column = columns[first + i] + local cx = x + i * (colW + gap) + local cy = y + Kit.text("micro", Strings("Round %d", column.round or (first + i)), cx, cy, + PAL.faint) + cy = cy + Kit.textHeight("micro") + math.floor(3 * m.s) + for _, entry in ipairs(column.matches) do + Kit.card(cx, cy, colW, cardH, entry.live and "badge" or nil) + if entry.live then + Theme.stroke(cx, cy, colW, cardH, PAL.green, Theme.A.focus, 2) + end + local pad = math.floor(6 * m.s) + local ink = PAL.text + if entry.state == "done" then ink = PAL.muted end + if entry.live then ink = PAL.heading end + Kit.text("micro", + Kit.ellipsize("micro", OnlinePanel.matchText(entry), colW - 2 * pad), + cx + pad, cy + math.floor(4 * m.s), ink) + local sub + if entry.bye and not entry.b then + sub = Strings("bye") + elseif entry.winnerName then + sub = entry.how and ("%s (%s)"):format(entry.winnerName, entry.how) + or entry.winnerName + elseif entry.live then + sub = Strings("live") + else + sub = Strings("pending") + end + Kit.text("micro", Kit.ellipsize("micro", sub, colW - 2 * pad), + cx + pad, cy + cardH - Kit.textHeight("micro") - math.floor(4 * m.s), + entry.live and PAL.green or PAL.faint) + cy = cy + cardH + math.floor(4 * m.s) + end + tallest = math.max(tallest, cy - y) + end + return tallest +end + +local function lobbyView(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap, tiny = Ui.pads(m) + local btnH = math.max(m.btnH, Kit.tapMin()) + local online = Client().state() == "online" + local profile = OnlinePanel.myProfile(imp) + local version = OnlinePanel.engineVersion(imp) + local canPlay = online and profile ~= nil + and OnlinePanel.canBattleWith(version) + local cy = y + + LV().btn(imp, x, cy, w, btnH, "online-tour-host", + Strings("Host a tournament"), + { kind = "primary", font = "small", enabled = online and profile ~= nil, + action = function() + OnlinePanel.startWizard(imp, "hostTournament") + end }) + cy = cy + btnH + gap + + cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny + local codeW = math.floor(w * 0.5) + Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode, + Strings("Six characters"), imp._onlineFocus == "online-code", + function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end) + local restW = math.floor((w - codeW - 2 * gap) / 2) + LV().btn(imp, x + codeW + gap, cy, restW, btnH, "online-tour-join", + Strings("Join"), + { kind = "accent", font = "small", + enabled = canPlay and #st.joinCode == OnlinePanel.CODE_LEN, + action = function() + OnlinePanel.startJoinTournament(imp, st.joinCode) + end }) + LV().btn(imp, x + codeW + restW + 2 * gap, cy, restW, btnH, + "online-tour-watch", Strings("Watch"), + { kind = "ghost", font = "small", + enabled = online and #st.joinCode == OnlinePanel.CODE_LEN, + action = function() + OnlinePanel.joinTournamentByCode(imp, st.joinCode, "spectator") + end }) + cy = cy + btnH + tiny + return cy - y +end + +function Tournaments.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local client = Client() + local tour = client.tournament() + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + + if not tour then + local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournaments")) + cy = cy + lobbyView(imp, x, cy, w, m) + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y + end + + local me = OnlinePanel.mySeatId() + local controls = OnlinePanel.creatorControls(tour, me) + local stage = tostring(tour.stage or "registering") + local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournament"), + Strings(Tournaments.STAGE_TEXT[stage] or stage), PAL.lineStrong) + + local banner = OnlinePanel.bannerText(tour, me) or OnlinePanel.tourNotice + if banner then + cy = cy + Kit.textWrapped("small", banner, x, cy, w, PAL.green, 2) + tiny + end + local left = OnlinePanel.tourDeadline(tour) + if left then + cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w, + left <= 10 and PAL.red or PAL.muted, 1) + tiny + end + + cy = cy + Tournaments.bracket(imp, x, cy, w, m, tour) + gap + + cy = cy + Room.codeCard(imp, x, cy, w, m, tour.code, "online-tour", + "Tournament code copied.") + cy = cy + Ui.label(Strings("Players (%d) - spectators (%d)", + #(tour.players or {}), #(tour.spectators or {})), x, cy) + tiny + + for _, player in ipairs(tour.players or {}) do + local kickW = math.floor(64 * m.s) + local wide = controls.canKick and player.id ~= me and st.hostMore + local text = tostring(player.name or "?") + if player.verified then text = text .. " *" end + if player.eliminated then text = text .. Strings(" (out)") end + if player.online == false then text = text .. Strings(" (away)") end + Kit.text("small", Kit.ellipsize("small", text, + w - (wide and kickW or 0) - math.floor(20 * m.s)), + x + math.floor(10 * m.s), cy, + player.eliminated and PAL.faint or PAL.text) + if wide then + local id = player.id + LV().btn(imp, x + w - kickW, cy - math.floor(4 * m.s), kickW, btnH, + "online-tour-kick-" .. tostring(id), Strings("Kick"), + { kind = "danger", font = "small", + action = function() pcall(client.kickFromTournament, id) end }) + cy = cy + btnH + tiny + else + cy = cy + Kit.textHeight("small") + tiny + end + end + cy = cy + gap + + local third = math.floor((w - 2 * gap) / 3) + LV().btn(imp, x, cy, third, btnH, "online-tour-start", Strings("Start"), + { kind = "primary", font = "small", enabled = controls.canStart, + action = function() pcall(client.startTournament) end }) + LV().btn(imp, x + third + gap, cy, third, btnH, "online-tour-more", + st.hostMore and Strings("Hide host") or Strings("Host controls"), + { kind = "ghost", font = "small", enabled = controls.isCreator, + action = function() st.hostMore = not st.hostMore end }) + LV().btn(imp, x + 2 * (third + gap), cy, third, btnH, "online-tour-leave", + Strings("Leave"), + { kind = "danger", font = "small", + action = function() + OnlinePanel.leaveTournament(imp) + OnlinePanel.go(imp, "play") + end }) + cy = cy + btnH + tiny + if st.hostMore and controls.canClose then + LV().btn(imp, x, cy, w, btnH, "online-tour-close", + Strings("Close the tournament"), + { kind = "danger", font = "small", + action = function() pcall(client.closeTournament) end }) + cy = cy + btnH + tiny + end + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y +end + +return Tournaments diff --git a/src/import/online/TradeScreen.lua b/src/import/online/TradeScreen.lua new file mode 100644 index 00000000..2dfcdf34 --- /dev/null +++ b/src/import/online/TradeScreen.lua @@ -0,0 +1,318 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local TradeScreen = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end +local function Sprites() return require("src.online.OnlineSprites") end + +local function rowId(side, row, at) + local key = tostring(row.key or row.index or at):gsub("|", "-") + return ("online-trade-%s-%s"):format(side, key) +end + +local function column(imp, x, y, w, m, side, rows, onPick) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local cy = y + if #rows == 0 then + Kit.emptyBox(x, cy, w, rowH, Strings("No POKeMON here.")) + return rowH + gap + end + local sprites = Sprites() + local iconSize = math.max(16, math.floor(rowH * 0.62)) + for at, row in ipairs(rows) do + local pick = row.ref ~= nil and row.ref or row.index + local ink = LV().rowHit(imp, x, cy, w, rowH, row.picked, + rowId(side, row, at), function() + if row.pickable == false then return end + onPick(pick) + end) + local tx = x + math.floor(10 * m.s) + local sprite = sprites.get(row.version, row.mon) + if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2, + iconSize) then + tx = tx + iconSize + math.floor(6 * m.s) + end + Kit.text("small", Kit.ellipsize("small", row.label, + w - (tx - x) - math.floor(10 * m.s)), tx, + cy + (rowH - Kit.textHeight("small")) / 2, + (row.pickable == false) and PAL.faint or (ink or PAL.text)) + cy = cy + rowH + tiny + end + return (cy - y) + gap +end + +local function localTrade(imp, x, y, w, m) + local OnlinePanel = OP() + local tr = OnlinePanel.tradeState(imp) + local c = OnlinePanel.cache(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.btnH, Kit.tapMin()) + local cy = y + local colW = math.floor((w - gap) / 2) + + if #c.tradeSlots < 2 then + Kit.emptyBox(x, cy, w, rowH * 2, + Strings("Trading needs two saves on this machine.")) + return (rowH * 2) + gap + end + + cy = cy + Ui.label(Strings("Saves"), x, cy) + tiny + for i, side in ipairs({ "a", "b" }) do + local entry = tr.sides[side] + Ui.chooser(imp, x + (i - 1) * (colW + gap), cy, colW, rowH, + "online-trade-slot-" .. side, + entry and entry.label or Strings("Pick a save"), + function() OnlinePanel.tradeCycleSide(imp, side, -1) end, + function() OnlinePanel.tradeCycleSide(imp, side, 1) end) + end + cy = cy + rowH + gap + + local tallest = 0 + for i, side in ipairs({ "a", "b" }) do + local sx = x + (i - 1) * (colW + gap) + local view = OnlinePanel.tradeSideView(imp, side) + local h + if view and not view.handle then + Kit.emptyBox(sx, cy, colW, rowH, + tostring(view.reason or "that save can't be read")) + h = rowH + gap + else + h = column(imp, sx, cy, colW, m, side, c.tradeRows[side] or {}, + function(ref) OnlinePanel.tradePick(imp, side, ref) end) + if OnlinePanel.tradePcAllowed(imp, side) then + LV().btn(imp, sx, cy + h, colW, rowH, "online-trade-pc-" .. side, + Strings("From PC"), + { kind = "ghost", font = "small", + action = function() + OnlinePanel.pcOpen(imp, { side = side }) + end }) + h = h + rowH + tiny + end + end + tallest = math.max(tallest, h) + end + cy = cy + tallest + + LV().btn(imp, x, cy, w, rowH, "online-trade-preview", Strings("Preview"), + { kind = "primary", font = "small", + enabled = tr.picks.a ~= nil and tr.picks.b ~= nil, + action = function() OnlinePanel.tradeModalPreview(imp) end }) + cy = cy + rowH + tiny + + if tr.status then + cy = cy + Kit.textWrapped("small", tostring(tr.status), x, cy, w, + tr.statusOk and PAL.green or PAL.yellow, 3) + tiny + end + return cy - y +end + +local function remoteTrade(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.btnH, Kit.tapMin()) + local cy = y + local colW = math.floor((w - gap) / 2) + local online = Client().state() == "online" + + if tr.remote then + local stage = tr.remote:stage() + cy = cy + Kit.textWrapped("small", OnlinePanel.remoteStageText(stage), + x, cy, w, PAL.heading, 2) + tiny + local mine, theirs = OnlinePanel.remoteRows(tr.remote) + local left = column(imp, x, cy, colW, m, "mine", mine, function(ref) + OnlinePanel.remotePick(imp, ref) + end) + local right = column(imp, x + colW + gap, cy, colW, m, "theirs", theirs, + function() end) + cy = cy + math.max(left, right) + local third = math.floor((w - 2 * gap) / 3) + LV().btn(imp, x, cy, third, rowH, "online-trade-yes", Strings("Confirm"), + { kind = "primary", font = "small", enabled = stage == "confirming", + action = function() OnlinePanel.remoteConfirm(imp, true) end }) + LV().btn(imp, x + third + gap, cy, third, rowH, "online-trade-no", + Strings("Say no"), + { kind = "ghost", font = "small", enabled = stage == "confirming", + action = function() OnlinePanel.remoteConfirm(imp, false) end }) + LV().btn(imp, x + 2 * (third + gap), cy, third, rowH, + "online-trade-cancel", Strings("Cancel"), + { kind = "danger", font = "small", + action = function() OnlinePanel.endRemoteTrade(imp) end }) + return (cy + rowH + gap) - y + end + + local refusal = OnlinePanel.remoteTradeRefusal(imp) + if refusal then + cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 3) + tiny + end + LV().btn(imp, x, cy, w, rowH, "online-trade-remote-start", + Strings("Set up a trade"), + { kind = "primary", font = "small", enabled = online, + action = function() OnlinePanel.startWizard(imp, "tradeRemote") end }) + cy = cy + rowH + tiny + + local note = tr.remoteError or tr.remoteResult or tr.status or st.status + if note then + cy = cy + Kit.textWrapped("small", tostring(note), x, cy, w, + tr.remoteError and PAL.red or PAL.muted, 3) + tiny + end + return cy - y +end + +local MODES = { + { id = "local", title = "On this device", + note = "Trade between two saves on this device." }, + { id = "remote", title = "Over the internet", + note = "Trade with another trainer over the relay." }, +} + +function TradeScreen.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local tr = OnlinePanel.tradeState(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.btnH, Kit.tapMin()) + local cy = y + Ui.header(imp, x, y, w, m, Strings("Trade")) + + if tr.remote then tr.chosen = true end + if not tr.chosen then + local cardH = math.max(math.floor(74 * m.s), m.rowH * 2) + if m.twoCol then + local colW = math.floor((w - gap) / 2) + for i, mode in ipairs(MODES) do + local id = mode.id + Ui.entryCard(imp, x + (i - 1) * (colW + gap), cy, colW, cardH, + "online-trade-mode-" .. id, Strings(mode.title), Strings(mode.note), + true, function() + OnlinePanel.tradeMode(imp, id) + tr.chosen = true + end) + end + cy = cy + cardH + gap + else + for _, mode in ipairs(MODES) do + local id = mode.id + Ui.entryCard(imp, x, cy, w, cardH, "online-trade-mode-" .. id, + Strings(mode.title), Strings(mode.note), true, function() + OnlinePanel.tradeMode(imp, id) + tr.chosen = true + end) + cy = cy + cardH + gap + end + end + return cy - y + end + + cy = cy + Ui.label(Strings("Trade with"), x, cy) + tiny + local half = math.floor((w - gap) / 2) + for i, mode in ipairs(MODES) do + local id = mode.id + if Kit.chip(x + (i - 1) * (half + gap), cy, half, rowH, + Strings(mode.title), tr.mode == id, PAL.lineStrong, + "online-trade-pick-" .. id) then + LV().queueAction(imp, "online-trade-pick-" .. id, function() + OnlinePanel.tradeMode(imp, id) + end) + end + end + cy = cy + rowH + gap + + if tr.mode == "remote" then + cy = cy + remoteTrade(imp, x, cy, w, m) + else + cy = cy + localTrade(imp, x, cy, w, m) + end + return cy - y +end + +local function modalMon(m, entry, label, x, w, y, box) + local sprites = Sprites() + local sprite = entry and entry.mon + and sprites.get(entry.version, entry.mon) or nil + if sprite then + sprites.drawFront(sprite, x + math.floor((w - box) / 2), y, box) + end + local cy = y + box + math.floor(4 * m.s) + Kit.textCenter("micro", label, x, cy, w, PAL.faint) + cy = cy + Kit.textHeight("micro") + math.floor(2 * m.s) + Kit.textCenter("small", + Kit.ellipsize("small", (entry and entry.label) or "?", w), + x, cy, w, PAL.heading) + return (cy + Kit.textHeight("small")) - y +end + +function TradeScreen.drawModal(imp, m) + local OnlinePanel = OP() + local mo = OnlinePanel.tradeModal(imp) + if not mo then return false end + local pad = math.floor(18 * m.s) + local gap = math.floor(8 * m.s) + local w = math.floor(460 * m.s) + local innerW = w - 2 * pad + local btnH = math.max(m.btnH, Kit.tapMin()) + local box = math.floor(56 * m.s) + local monH = box + math.floor(6 * m.s) + Kit.textHeight("micro") + + Kit.textHeight("small") + local result = mo.view == "result" + local lines = result and (mo.resultLines or {}) or (mo.lines or {}) + local heading = result and (mo.message or "") + or ((#lines > 0) and Strings("What changes") or nil) + local linesH = heading and (Kit.textHeight("small") + math.floor(4 * m.s)) + or 0 + for _, line in ipairs(lines) do + linesH = linesH + Kit.wrapHeight("small", tostring(line), innerW, 2) + + math.floor(3 * m.s) + end + local h = pad + Kit.textHeight("button") + gap + monH + gap + linesH + gap + + btnH + pad + local px, py, pw, ph = LV().modalPanel(m, w, h) + innerW = pw - 2 * pad + local cy = py + pad + Kit.textBold("button", Strings("Trade"), px + pad, cy, PAL.heading) + cy = cy + Kit.textHeight("button") + gap + + local slotW = math.floor((innerW - gap) / 2) + local left = modalMon(m, mo.give, Strings("Give"), px + pad, slotW, cy, box) + local right = modalMon(m, mo.get, Strings("Get"), + px + pad + slotW + gap, slotW, cy, box) + cy = cy + math.max(left, right) + gap + + if heading then + Kit.text("small", Kit.ellipsize("small", heading, innerW), px + pad, cy, + result and (mo.ok and PAL.green or PAL.yellow) or PAL.detail) + cy = cy + Kit.textHeight("small") + math.floor(4 * m.s) + end + for _, line in ipairs(lines) do + cy = cy + Kit.textWrapped("small", tostring(line), px + pad, cy, innerW, + PAL.muted, 2) + math.floor(3 * m.s) + end + + cy = math.floor(py + ph - pad - btnH) + if result then + LV().btn(imp, px + pad, cy, innerW, btnH, + OnlinePanel.TRADE_MODAL_DONE, Strings("Done"), + { kind = "primary", font = "small", + action = function() OnlinePanel.tradeModalClose(imp) end }) + return true + end + local half = math.floor((innerW - gap) / 2) + LV().btn(imp, px + pad, cy, half, btnH, + OnlinePanel.TRADE_MODAL_CANCEL, Strings("Cancel"), + { kind = "ghost", font = "small", + action = function() OnlinePanel.tradeModalClose(imp) end }) + LV().btn(imp, px + pad + half + gap, cy, innerW - half - gap, btnH, + OnlinePanel.TRADE_MODAL_CONFIRM, Strings("Confirm"), + { kind = "primary", font = "small", + action = function() OnlinePanel.tradeModalConfirm(imp) end }) + return true +end + +return TradeScreen diff --git a/src/import/online/Ui.lua b/src/import/online/Ui.lua new file mode 100644 index 00000000..bd466df3 --- /dev/null +++ b/src/import/online/Ui.lua @@ -0,0 +1,137 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") + +local PAL = Theme.PAL + +local Ui = {} + +local function LV() + return require("src.import.LauncherView") +end + +local function OP() + return require("src.import.OnlinePanel") +end + +Ui.LV = LV +Ui.OP = OP + +function Ui.pads(m) + local s = m.s + return math.floor(14 * s), math.floor(8 * s), math.floor(4 * s) +end + +function Ui.label(text, x, y, color) + Kit.text("small", text, x, y, color or PAL.muted) + return Kit.textHeight("small") +end + +function Ui.field(imp, x, y, w, h, key, text, placeholder, focused, set) + Kit._audit("control", x, y, w, h, key) + local ring = Kit.focusable(key, x, y, w, h) + Theme.fill(x, y, w, h, PAL.bg, 1) + Theme.stroke(x, y, w, h, PAL.line, + (focused or ring) and Theme.A.focus + or (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), + focused and 2 or 1) + local pad = math.floor(10 * Kit.scale) + local ty = y + (h - Kit.textHeight("button")) / 2 + if (text or "") == "" and not focused then + Kit.text("button", Kit.ellipsize("button", placeholder or "", w - 2 * pad), + x + pad, ty, PAL.faint) + else + local shown = Kit.ellipsizeLeft("button", text or "", w - 2 * pad) + local tw = Kit.text("button", shown, x + pad, ty, PAL.heading) + if focused and ((imp.pulse or 0) * 2 % 1) < 0.5 then + Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale), + Kit.textHeight("button"), PAL.ink, 1) + end + end + if Kit.press(x, y, w, h) or Kit._activateId == key then + if Kit.VirtualKeyboard then + Kit.VirtualKeyboard.open({ + text = text or "", + targetId = key, + title = placeholder or "Enter Text", + onDone = function(newText, confirmed) + if confirmed then set(newText) end + end, + }) + end + LV().queueAction(imp, key, function() imp:_focusOnlineField(key) end) + end +end + +function Ui.chooser(imp, x, y, w, h, key, text, onPrev, onNext) + local bw = math.max(Kit.tapMin(), math.floor(28 * Kit.scale)) + local gap = math.floor(4 * Kit.scale) + LV().btn(imp, x, y, bw, h, key .. "-prev", "<", + { kind = "ghost", font = "small", action = onPrev }) + LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", ">", + { kind = "ghost", font = "small", action = onNext }) + local inner = math.max(0, w - 2 * bw - 2 * gap) + Kit.textCenter("small", Kit.ellipsize("small", text, inner), + x + bw + gap, y + (h - Kit.textHeight("small")) / 2, inner, PAL.heading) +end + +function Ui.indexOf(list, value) + for i = 1, #list do + if list[i] == value or (list[i] == false and value == nil) then return i end + end + return 1 +end + +function Ui.cycle(list, value, delta) + local at = Ui.indexOf(list, value) + local to = ((at - 1 + delta) % #list) + 1 + local picked = list[to] + if picked == false then return nil end + return picked +end + +function Ui.header(imp, x, y, w, m, title, pill, pillColor) + local _, gap = Ui.pads(m) + local h = math.max(m.btnH, Kit.tapMin()) + local backW = math.floor(82 * m.s) + LV().btn(imp, x, y, backW, h, "online-back", Strings("Back"), + { kind = "ghost", font = "small", + action = function() OP().back(imp) end }) + local right = 0 + if pill and pill ~= "" then + right = math.min(math.floor(w * 0.4), + Kit.textWidth("micro", pill) + math.floor(22 * m.s)) + Kit.tag(x + w - right, y + (h - math.floor(18 * m.s)) / 2, right, + math.floor(18 * m.s), pill, pillColor or PAL.line) + end + local tx = x + backW + 2 * gap + Kit.textBold("button", + Kit.ellipsize("button", title, + math.max(0, w - backW - 3 * gap - right)), + tx, y + (h - Kit.textHeight("button")) / 2, PAL.heading) + return h + gap +end + +function Ui.statusLine(imp, x, y, w, m) + local st = OP().state(imp) + if not st.status then return 0 end + local _, gap = Ui.pads(m) + return Kit.textWrapped("small", tostring(st.status), x, y, w, + st.statusOk and PAL.green or PAL.yellow, 2) + gap +end + +function Ui.entryCard(imp, x, y, w, h, key, title, note, enabled, action) + local pad = math.floor(12 * Kit.scale) + local clicked, hot = Kit.row(x, y, w, h, false, enabled and key or nil) + local ink = enabled and (hot or PAL.heading) or PAL.faint + Kit.textBold("button", Kit.ellipsize("button", title, w - 2 * pad), + x + pad, y + pad, ink) + Kit.textWrapped("small", note, x + pad, + y + pad + Kit.textHeight("button") + math.floor(4 * Kit.scale), + w - 2 * pad, enabled and PAL.muted or PAL.faint, 2) + if clicked and enabled and action then + LV().queueAction(imp, key, action) + end +end + +return Ui diff --git a/src/import/online/Watch.lua b/src/import/online/Watch.lua new file mode 100644 index 00000000..fcb97707 --- /dev/null +++ b/src/import/online/Watch.lua @@ -0,0 +1,98 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local Watch = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Client() return require("src.online.Client") end + +function Watch.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local online = Client().state() == "online" + + local cy = y + Ui.header(imp, x, y, w, m, Strings("Watch"), + online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line) + + cy = cy + Ui.label(Strings("Watch by code"), x, cy) + tiny + local codeW = math.floor(w * 0.5) + Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode, + Strings("Six characters"), imp._onlineFocus == "online-code", + function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end) + LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH, + "online-spectate-code", Strings("Spectate"), + { kind = "primary", font = "small", + enabled = online and #st.joinCode == OnlinePanel.CODE_LEN, + action = function() + if OnlinePanel.joinByCode(imp, st.joinCode, "spectator") then + OnlinePanel.go(imp, "room") + end + end }) + cy = cy + btnH + gap + + local rows = c.watch + cy = cy + Ui.label(Strings("Live now"), x, cy) + tiny + if #rows == 0 then + Kit.emptyBox(x, cy, w, rowH * 2, online + and Strings("Nothing to watch yet. Paste a code, or check back.") + or Strings("Connect to see who is playing.")) + cy = cy + rowH * 2 + gap + else + imp._pages = imp._pages or {} + local pageKey = "online-watch" + local perPage = Kit.rowsThatFit(math.max(rowH * 2, + availH - (cy - y) - btnH), rowH, tiny, 2, 8) + local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1, + #rows, perPage) + imp._pages[pageKey] = pageNow + local watchW = math.floor(88 * m.s) + for i = first, last do + local row = rows[i] + local ink = LV().rowHit(imp, x, cy, w, rowH, false, + "online-watch-" .. row.id, nil) + local tx = x + math.floor(10 * m.s) + local textW = w - watchW - math.floor(24 * m.s) + Kit.text("small", Kit.ellipsize("small", row.name, textW), tx, + cy + math.floor(5 * m.s), ink or PAL.heading) + local tournament = row.intent == "tournament" + local sub = ("%s %s %s %s"):format(row.game, row.arena, row.rule, + tournament and Strings("tournament") + or Strings("%d watching", row.spectators)) + Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx, + cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted) + local code = row.code + LV().btn(imp, x + w - watchW - math.floor(6 * m.s), + cy + (rowH - btnH) / 2, watchW, btnH, "online-watch-go-" .. row.id, + tournament and Strings("Watch") or Strings("Spectate"), + { kind = "accent", font = "small", enabled = online, + action = function() + if tournament then + OnlinePanel.joinTournamentByCode(imp, code, "spectator") + elseif OnlinePanel.joinByCode(imp, code, "spectator") then + OnlinePanel.go(imp, "room") + end + end }) + cy = cy + rowH + tiny + end + if #rows > perPage then + local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey) + imp._pages[pageKey] = page + cy = cy + ph + end + cy = cy + gap + end + + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y +end + +return Watch diff --git a/src/import/online/Wizard.lua b/src/import/online/Wizard.lua new file mode 100644 index 00000000..4513e4e2 --- /dev/null +++ b/src/import/online/Wizard.lua @@ -0,0 +1,519 @@ +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local Strings = require("src.core.Strings") +local GameVersion = require("src.core.GameVersion") +local Ui = require("src.import.online.Ui") + +local PAL = Theme.PAL + +local Wizard = {} + +local function LV() return require("src.import.LauncherView") end +local function OP() return require("src.import.OnlinePanel") end +local function Sprites() return require("src.online.OnlineSprites") end + +local SPECTATOR_CHOICES = { 0, 4, 8, 16 } + +Wizard.SPECTATOR_CHOICES = SPECTATOR_CHOICES + +local function chipRow(imp, x, y, w, m, label, id, options, isOn, onPick) + local _, gap, tiny = Ui.pads(m) + local h = math.max(m.btnH, Kit.tapMin()) + local cy = y + if label then cy = cy + Ui.label(label, x, cy) + tiny end + local n = math.max(1, #options) + local perRow = math.min(n, (m.twoCol and 4) or 2) + local chipW = math.floor((w - gap * (perRow - 1)) / perRow) + local col = 0 + for _, option in ipairs(options) do + local cx = x + (col % perRow) * (chipW + gap) + local key = id .. "-" .. tostring(option.id) + if Kit.chip(cx, cy, chipW, h, Kit.ellipsize("small", option.label, chipW), + isOn(option), PAL.lineStrong, key) then + local pick = option + LV().queueAction(imp, key, function() onPick(pick) end) + end + col = col + 1 + if col % perRow == 0 then cy = cy + h + tiny end + end + if col % perRow ~= 0 then cy = cy + h + tiny end + return (cy - y) + gap - tiny +end + +Wizard.chipRow = chipRow + +-- ------------------------------------------------------------------ steps + +local function stepGame(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + local _, gap = Ui.pads(m) + local cy = y + local versions = OnlinePanel.readyVersions(imp) + if #versions == 0 then + Kit.emptyBox(x, cy, w, math.max(m.rowH, Kit.tapMin()), + Strings("Import a game first.")) + return math.max(m.rowH, Kit.tapMin()) + gap + end + local options = {} + for _, id in ipairs(versions) do + local info = GameVersion.info(id) + options[#options + 1] = { id = id, label = (info and info.name) or id } + end + cy = cy + chipRow(imp, x, cy, w, m, Strings("Game"), "online-game", options, + function(option) return option.id == OnlinePanel.selectedVersion(imp) end, + function(option) + st.version, st.slotId, st.cartId, st.team = option.id, nil, nil, {} + st.slotRead, st.ready, st.kind = nil, false, "vanilla" + OnlinePanel.invalidate(imp) + end) + + local arena = { { id = "vanilla", label = Strings("Vanilla") } } + for _, row in ipairs(c.carts) do + arena[#arena + 1] = { id = row.id, label = row.title, cart = true } + end + cy = cy + chipRow(imp, x, cy, w, m, Strings("Arena"), "online-arena", arena, + function(option) + if option.cart then return st.kind == "cart" and st.cartId == option.id end + return st.kind ~= "cart" + end, + function(option) + if option.cart then + st.kind, st.cartId = "cart", option.id + else + st.kind, st.cartId = "vanilla", nil + end + st.slotId, st.team, st.slotRead, st.ready = nil, {}, nil, false + OnlinePanel.invalidate(imp) + end) + if #c.carts == 0 then + cy = cy + Kit.textWrapped("micro", + Strings("No sealed carts on this machine."), x, cy, w, PAL.faint, 1) + + gap + end + return cy - y +end + +local function stepSave(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local cy = y + Ui.label(Strings("Save"), x, y) + tiny + if #c.slots == 0 then + Kit.emptyBox(x, cy, w, rowH, Strings("No save in this game yet.")) + return (cy - y) + rowH + gap + end + imp._pages = imp._pages or {} + local pageKey = "online-slots" + local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 6) + local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1, + #c.slots, perPage) + imp._pages[pageKey] = pageNow + for i = first, last do + local row = c.slots[i] + local id = row.id + local ink = LV().rowHit(imp, x, cy, w, rowH, st.slotId == id, + "online-slot-" .. id, function() + st.slotId, st.team, st.slotRead, st.ready = id, {}, nil, false + OnlinePanel.invalidate(imp, "party", "summary") + end) + Kit.text("small", Kit.ellipsize("small", row.label, + w - math.floor(20 * m.s)), x + math.floor(10 * m.s), + cy + math.floor(5 * m.s), ink or PAL.heading) + Kit.text("micro", row.sub, x + math.floor(10 * m.s), + cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted) + cy = cy + rowH + tiny + end + if #c.slots > perPage then + local page, ph = Kit.pager(x, cy, w, pageNow, #c.slots, perPage, pageKey) + imp._pages[pageKey] = page + cy = cy + ph + end + return (cy - y) + gap +end + +function Wizard.monRow(imp, row, x, y, w, m, onPick) + local rowH = math.max(m.rowH, Kit.tapMin()) + local sprites = Sprites() + local iconSize = math.max(16, math.floor(rowH * 0.62)) + local ink = LV().rowHit(imp, x, y, w, rowH, row.order ~= nil, + "online-mon-" .. row.key, onPick) + local tx = x + math.floor(10 * m.s) + local sprite = sprites.get(row.version, row.mon) + if sprite and sprites.drawIcon(sprite, tx, y + (rowH - iconSize) / 2, + iconSize) then + tx = tx + iconSize + math.floor(6 * m.s) + end + if row.order then + Kit.textBold("small", ("%d."):format(row.order), tx, + y + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading) + tx = tx + math.floor(20 * m.s) + end + local textW = w - (tx - x) - math.floor(10 * m.s) + Kit.text("small", Kit.ellipsize("small", row.label, textW), tx, + y + math.floor(5 * m.s), + row.refused and PAL.faint or (ink or PAL.text)) + local sub = row.note or ((row.where == "box") and row.source or nil) + if sub then + Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx, + y + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), + row.refused and PAL.red or PAL.faint) + end + return rowH +end + +local function stepTeam(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local c = OnlinePanel.cache(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local cy = y + + local generations = OnlinePanel.installedGenerations(imp) + if #generations > 1 and OnlinePanel.crossGen(imp) then + cy = cy + Kit.textWrapped("small", + Strings("Time Capsule: your team is converted for %s.", + tostring(OnlinePanel.engineVersion(imp) or "?")), + x, cy, w, PAL.yellow, 2) + tiny + end + + cy = cy + Ui.label(Strings("Your party"), x, cy) + tiny + if #c.party == 0 then + Kit.emptyBox(x, cy, w, rowH, + c.partyReason or Strings("That save has no POKeMON.")) + cy = cy + rowH + tiny + else + local box = math.floor(56 * m.s) + local listW = w - box - gap + local listY = cy + for _, row in ipairs(c.party) do + local ref = row.ref + cy = cy + Wizard.monRow(imp, row, x, cy, listW, m, function() + st.focusMon = row.key + if row.refused then return end + OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX) + st.ready = false + OnlinePanel.invalidate(imp, "party", "summary") + end) + tiny + end + local focus + for _, row in ipairs(c.party) do + if row.key == st.focusMon then focus = row end + end + focus = focus or c.team[1] or c.party[1] + if focus then + local sprite = Sprites().get(focus.version, focus.mon) + if sprite then + Sprites().drawFront(sprite, x + listW + gap, listY, box) + Kit.text("micro", Kit.ellipsize("micro", + tostring(focus.mon.species or "?"), box), x + listW + gap, + listY + box, PAL.faint) + end + end + end + + LV().btn(imp, x, cy, w, btnH, "online-team-pc", Strings("From PC"), + { kind = "ghost", font = "small", enabled = st.slotId ~= nil, + action = function() OnlinePanel.pcOpen(imp) end }) + cy = cy + btnH + gap + + cy = cy + Ui.label(Strings("Picked (%d)", #(st.team or {})), x, cy) + tiny + if #(c.team or {}) == 0 then + Kit.emptyBox(x, cy, w, rowH, + Strings("Tap a POKeMON above, or pull one out of the PC.")) + cy = cy + rowH + tiny + else + for _, row in ipairs(c.team) do + if row then + local ref = row.ref + cy = cy + Wizard.monRow(imp, row, x, cy, w, m, function() + OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX) + OnlinePanel.invalidate(imp, "party", "summary") + end) + tiny + end + end + end + if c.teamNote and not c.teamOk then + cy = cy + Kit.textWrapped("small", tostring(c.teamNote):gsub("\n", " "), + x, cy, w, PAL.yellow, 2) + tiny + end + return (cy - y) + gap +end + +local function stepRules(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.btnH, Kit.tapMin()) + local cy = y + local rule = OnlinePanel.ruleFor(imp) + local function bump() + OnlinePanel.editRule(imp) + st.ready = false + OnlinePanel.invalidate(imp, "party", "summary") + end + local third = m.twoCol and math.floor((w - 2 * gap) / 3) or w + + cy = cy + Ui.label(Strings("How many POKeMON each"), x, cy) + tiny + Ui.chooser(imp, x, cy, third, rowH, "online-size", + ("%d v %d"):format(rule.partySize or 1, rule.partySize or 1), + function() + rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, -1) + bump() + end, + function() + rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, 1) + bump() + end) + cy = cy + rowH + gap + + cy = cy + Ui.label(Strings("Levels"), x, cy) + tiny + local col2 = m.twoCol and (x + third + gap) or x + local row2 = m.twoCol and cy or (cy + rowH + tiny) + Ui.chooser(imp, x, cy, third, rowH, "online-min", + rule.minLevel and ("Lv%d+"):format(rule.minLevel) or Strings("Any min"), + function() + rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, -1) + bump() + end, + function() + rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, 1) + bump() + end) + Ui.chooser(imp, col2, row2, third, rowH, "online-max", + rule.maxLevel and ("Lv%d-"):format(rule.maxLevel) or Strings("Any max"), + function() + rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, -1) + bump() + end, + function() + rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, 1) + bump() + end) + cy = row2 + rowH + gap + + cy = cy + Ui.label(Strings("Force every level"), x, cy) + tiny + Ui.chooser(imp, x, cy, third, rowH, "online-force", + rule.forceLevel and ("All Lv%d"):format(rule.forceLevel) + or Strings("Own levels"), + function() + rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, -1) + bump() + end, + function() + rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, 1) + bump() + end) + cy = cy + rowH + gap + return cy - y +end + +local function stepVisibility(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap, tiny = Ui.pads(m) + local btnH = math.max(m.btnH, Kit.tapMin()) + local cy = y + cy = cy + chipRow(imp, x, cy, w, m, Strings("Visibility"), "online-visible", + { { id = "public", label = Strings("Public") }, + { id = "code", label = Strings("Code only") } }, + function(option) + if option.id == "public" then return st.public ~= false end + return st.public == false + end, + function(option) st.public = option.id == "public" end) + cy = cy + Kit.textWrapped("micro", st.public == false + and Strings("Only trainers you give the code to can join.") + or Strings("Anyone browsing Play sees this lobby."), + x, cy, w, PAL.faint, 2) + gap + cy = cy + Ui.label(Strings("Note (optional)"), x, cy) + tiny + Ui.field(imp, x, cy, w, btnH, "online-note", st.note, + Strings("Say something, like first to three"), + imp._onlineFocus == "online-note", function(text) st.note = text end) + cy = cy + btnH + gap + return cy - y +end + +local function stepPlaying(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap = Ui.pads(m) + local cy = y + chipRow(imp, x, y, w, m, Strings("Your seat"), + "online-tour-playing", + { { id = "play", label = Strings("Play in it") }, + { id = "organize", label = Strings("Organize and watch") } }, + function(option) + if option.id == "play" then return st.tourPlaying ~= false end + return st.tourPlaying == false + end, + function(option) + st.tourPlaying = option.id == "play" + OnlinePanel.invalidate(imp, "summary") + end) + cy = cy + Kit.textWrapped("micro", st.tourPlaying == false + and Strings("You run the bracket and never take a seat in it.") + or Strings("You take one of the seats in the bracket."), + x, cy, w, PAL.faint, 2) + gap + return cy - y +end + +local function stepShotClock(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap = Ui.pads(m) + local options = {} + for _, seconds in ipairs(OnlinePanel.SHOT_CLOCKS) do + options[#options + 1] = { id = seconds, + label = Strings("%d seconds", seconds) } + end + local cy = y + chipRow(imp, x, y, w, m, Strings("Thinking time a move"), + "online-shot", options, + function(option) return option.id == st.tourShotClock end, + function(option) st.tourShotClock = option.id end) + cy = cy + Kit.textWrapped("micro", + Strings("Thinking time for each move. Run out and the move is picked for you."), + x, cy, w, PAL.faint, 2) + gap + return cy - y +end + +local function stepSpectators(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local _, gap = Ui.pads(m) + local options = {} + for _, n in ipairs(SPECTATOR_CHOICES) do + options[#options + 1] = { id = n, + label = (n == 0) and Strings("None") or Strings("Up to %d", n) } + end + local cy = y + chipRow(imp, x, y, w, m, Strings("How many can watch"), + "online-spectators", options, + function(option) return option.id == (tonumber(st.tourSpectators) or 0) end, + function(option) st.tourSpectators = option.id end) + cy = cy + Kit.textWrapped("micro", + Strings("How many people can watch the bracket play out."), + x, cy, w, PAL.faint, 2) + gap + return cy - y +end + +local function stepRole(imp, x, y, w, m) + local OnlinePanel = OP() + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + local _, gap, tiny = Ui.pads(m) + local btnH = math.max(m.btnH, Kit.tapMin()) + local cy = y + chipRow(imp, x, y, w, m, Strings("Trade"), "online-trade-role", + { { id = "host", label = Strings("Host a trade") }, + { id = "join", label = Strings("Join with a code") } }, + function(option) return option.id == (st.tradeRole or "host") end, + function(option) st.tradeRole = option.id end) + if (st.tradeRole or "host") == "join" then + cy = cy + Ui.label(Strings("Trade code"), x, cy) + tiny + Ui.field(imp, x, cy, w, btnH, "online-trade-code", tr.code, + Strings("Six characters"), imp._onlineFocus == "online-trade-code", + function(text) tr.code = OnlinePanel.sanitizeCode(text) end) + cy = cy + btnH + gap + else + cy = cy + Kit.textWrapped("micro", + Strings("You get a code to give the other trainer."), + x, cy, w, PAL.faint, 2) + gap + end + local refusal = OnlinePanel.remoteTradeRefusal(imp) + if refusal then + cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 2) + gap + end + return cy - y +end + +local function stepSummary(imp, x, y, w, m) + local OnlinePanel = OP() + local _, gap, tiny = Ui.pads(m) + local rowH = math.max(m.rowH, Kit.tapMin()) + local btnH = math.max(m.btnH, Kit.tapMin()) + local changeW = math.floor(84 * m.s) + local cy = y + for _, answer in ipairs(OnlinePanel.wizardAnswers(imp)) do + Kit.card(x, cy, w, rowH, "badge") + local tx = x + math.floor(10 * m.s) + Kit.text("micro", answer.label, tx, cy + math.floor(5 * m.s), PAL.muted) + Kit.text("small", Kit.ellipsize("small", answer.value, + w - changeW - math.floor(28 * m.s)), tx, + cy + rowH - Kit.textHeight("small") - math.floor(5 * m.s), PAL.heading) + local step = answer.step + LV().btn(imp, x + w - changeW - math.floor(8 * m.s), + cy + (rowH - btnH) / 2, changeW, btnH, + "online-change-" .. step, Strings("Change"), + { kind = "ghost", font = "small", + action = function() OnlinePanel.wizardTo(imp, step) end }) + cy = cy + rowH + tiny + end + return (cy - y) + gap +end + +local BODIES = { + game = function(imp, x, y, w, availH, m) return stepGame(imp, x, y, w, m) end, + save = stepSave, + team = function(imp, x, y, w, availH, m) return stepTeam(imp, x, y, w, m) end, + rules = function(imp, x, y, w, availH, m) return stepRules(imp, x, y, w, m) end, + visibility = function(imp, x, y, w, availH, m) + return stepVisibility(imp, x, y, w, m) + end, + playing = function(imp, x, y, w, availH, m) + return stepPlaying(imp, x, y, w, m) + end, + shotclock = function(imp, x, y, w, availH, m) + return stepShotClock(imp, x, y, w, m) + end, + spectators = function(imp, x, y, w, availH, m) + return stepSpectators(imp, x, y, w, m) + end, + role = function(imp, x, y, w, availH, m) return stepRole(imp, x, y, w, m) end, + summary = function(imp, x, y, w, availH, m) + return stepSummary(imp, x, y, w, m) + end, +} + +Wizard.BODIES = BODIES + +function Wizard.draw(imp, x, y, w, availH, m) + local OnlinePanel = OP() + local _, gap, tiny = Ui.pads(m) + local btnH = math.max(m.btnH, Kit.tapMin()) + local def = OnlinePanel.wizardDef(imp) + if not def then + local cy = y + Ui.header(imp, x, y, w, m, Strings("Set up")) + Kit.emptyBox(x, cy, w, m.rowH * 2, Strings("Nothing to set up.")) + return (cy - y) + m.rowH * 2 + end + local id, at, total = OnlinePanel.wizardStep(imp) + local cy = y + Ui.header(imp, x, y, w, m, Strings(def.title), + ("%d/%d"):format(at or 1, total or 1), PAL.lineStrong) + Kit.textBold("button", Strings(OnlinePanel.STEP_TITLE[id] or id), x, cy, + PAL.heading) + cy = cy + Kit.textHeight("button") + tiny + + local body = math.max(m.rowH * 2, availH - (cy - y) - btnH * 2) + local run = BODIES[id] or BODIES.summary + cy = cy + run(imp, x, cy, w, body, m) + + local last = (at or 1) >= (total or 1) + local half = math.floor((w - gap) / 2) + LV().btn(imp, x, cy, half, btnH, "online-wizard-back", Strings("Back"), + { kind = "ghost", font = "small", + action = function() OnlinePanel.wizardBack(imp) end }) + LV().btn(imp, x + half + gap, cy, w - half - gap, btnH, + "online-wizard-next", + last and Strings(def.confirm) or Strings("Next"), + { kind = "primary", font = "small", + enabled = OnlinePanel.wizardReady(imp), + action = function() OnlinePanel.wizardNext(imp) end }) + cy = cy + btnH + tiny + cy = cy + Ui.statusLine(imp, x, cy, w, m) + return cy - y +end + +return Wizard diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 8fd8dbb2..87f3bc9e 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -281,7 +281,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) -- WakeUpEntireParty runs on the enemy's bench too for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end if not woke then - return "failed", { romText(data, "_PlayedFluteNoEffectText", + -- engine/items/item_effects.asm:1728, engine/battle/core.asm:2257 (#1882) + return "kept", { romText(data, "_PlayedFluteNoEffectText", "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") } end return "flute", { romText(data, "_PlayedFluteHadEffectText", diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua index 23c74241..88890853 100644 --- a/src/link/CodeEntry.lua +++ b/src/link/CodeEntry.lua @@ -1,5 +1,5 @@ --- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's --- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32 +-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState +-- uses for LAN address 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). @@ -30,7 +30,7 @@ function CodeEntry.new(opts) end -- Seed the slots from an existing string: prefilling the LAN address means --- the player scrubs the last octet instead of all twelve digits. Anything +-- the player scrubs the last octet instead of the whole address. Anything -- not in the charset lands on slot 1's character. -- Slots past the end of the seed -- and any character the charset does not -- carry -- land on the charset's blank where it has one, so seeding a diff --git a/src/link/Fingerprint.lua b/src/link/Fingerprint.lua index c557b23b..5950c77c 100644 --- a/src/link/Fingerprint.lua +++ b/src/link/Fingerprint.lua @@ -315,6 +315,19 @@ local function writeGen2TypeChart(out, data) end end +local function writeRulesets(out, data) + local rulesets = data.rulesets + if not rulesets then return end + out[#out + 1] = "[rulesets]" + for _, id in ipairs(sortedIds(rulesets)) do + local record = rulesets[id] + if type(record) == "table" then + out[#out + 1] = "@" .. id + writeValue(out, record) + end + end +end + local function writeConstants(out, data) if not data.constants then return end out[#out + 1] = "[constants]" @@ -365,6 +378,7 @@ local function surfaceGen1(data, mods) writeTypeChart(out, data) writeSection(out, data, "statuses") writeSection(out, data, "move_effects") + writeRulesets(out, data) writeConstants(out, data) writeLinkFields(out, data) out[#out + 1] = "[mods]" .. modKey(mods) diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 876a79d6..894ddcb1 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -140,7 +140,7 @@ end -- the enabled mods that keep this install out of online play, in the -- id order Handshake.mods sorts: everything except verified translations. --- LinkState:offerVanillaRestart names these and switches off exactly these. +-- The launcher's ONLINE tab names these and switches off exactly these. function Handshake.onlineBlockers(game) local mod = loader(game) local blockers = {} @@ -152,8 +152,8 @@ function Handshake.onlineBlockers(game) return blockers end --- online play (the relay-based online match / tournament flows in --- LinkState/Tournament) meets strangers, not a coordinating friend, so it +-- online play (the launcher's relay-based rooms and tournaments) meets +-- strangers, not a coordinating friend, so it -- skips the LAN path's per-peer compatibility negotiation entirely and -- just requires a vanilla simulation on both ends: no mod-added Pokemon, no -- surprises. #501 carves out translations, because a language is not a @@ -175,6 +175,21 @@ function Handshake.generation(game) return Fingerprint.generationOf(game and game.data) end +Handshake.DEFAULT_RULESET = "gen1_faithful" + +function Handshake.ruleset(game) + local data = game and game.data + local fallback = (data and data.constants and data.constants.defaultRuleset) + or Handshake.DEFAULT_RULESET + local selected = game and game.save and game.save.options + and game.save.options.ruleset + if selected == nil then return fallback end + selected = tostring(selected) + local rulesets = data and data.rulesets + if rulesets and rulesets[selected] == nil then return fallback end + return selected +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) @@ -193,6 +208,7 @@ function Handshake.hello(game, mode) generation = generation, fingerprint = Fingerprint.compute(game and game.data, mods, generation), linkModified = Handshake.linkModified(game), + ruleset = Handshake.ruleset(game), mods = mods, } end @@ -247,6 +263,11 @@ function Handshake.checkCompat(localHello, remoteHello) return "engine_skew", "engine_release_mismatch" end if remoteHello.fingerprint == localHello.fingerprint then + local localRuleset = localHello.ruleset or Handshake.DEFAULT_RULESET + local remoteRuleset = remoteHello.ruleset or Handshake.DEFAULT_RULESET + if tostring(localRuleset) ~= tostring(remoteRuleset) then + return "ruleset_skew", "ruleset_mismatch" + end return "full", nil end return "subset", "fingerprint_mismatch" @@ -256,6 +277,7 @@ end -- peer keeps the old substitute-a-move behaviour it was built against function Handshake.strict(verdict) return verdict == "full" or verdict == "subset" or verdict == "engine_skew" + or verdict == "ruleset_skew" end function Handshake.battleAllowed(verdict) @@ -359,6 +381,24 @@ function Handshake.describe(localHello, remoteHello, verdict, mode) end return lines end + if verdict == "ruleset_skew" then + wrap(lines, "Your battle rules") + wrap(lines, "differ:") + wrap(lines, (" you: %s"):format( + tostring(localHello.ruleset or Handshake.DEFAULT_RULESET):upper():sub(1, 12))) + wrap(lines, (" %s: %s"):format(peer:sub(1, 8), + tostring((remoteHello and remoteHello.ruleset) + or Handshake.DEFAULT_RULESET):upper():sub(1, 12))) + if mode == "battle" then + wrap(lines, "Battle needs the") + wrap(lines, "same RULESET in") + wrap(lines, "OPTIONS.") + else + wrap(lines, "Trading still") + wrap(lines, "works.") + end + return lines + end if verdict == "engine_skew" then -- name both releases so two friends can tell WHO updates: this used -- to surface three turns in as a desync draw blaming mods (#758) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index abb66c3b..f18c9567 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -29,12 +29,14 @@ local LinkBattle = {} -- Deterministic Park-Miller PRNG: both sides must roll identical -- streams, so love.math.random can't be used. -local function makeRng(seed) +local function makeRng(seed, owner) local s = tonumber(seed) or 1 if s ~= s or s == math.huge or s == -math.huge then s = 1 end s = math.floor(s) % 2147483647 if s <= 0 then s = s + 2147483646 end + if owner then owner.rngDraws = 0 end return function(a, b) + if owner then owner.rngDraws = (owner.rngDraws or 0) + 1 end s = (s * 16807) % 2147483647 if a == nil then return s / 2147483647 end if b == nil then a, b = 1, a end @@ -42,6 +44,19 @@ local function makeRng(seed) end end +local BUILTIN_RULESETS = { + gen1_faithful = require("src.battle.rulesets.gen1_faithful"), + modern_clean = require("src.battle.rulesets.modern_clean"), +} + +local function rulesetFor(game, id) + local rulesets = game.data.rulesets or BUILTIN_RULESETS + local fallback = (game.data.constants and game.data.constants.defaultRuleset) + or "gen1_faithful" + return (id and rulesets[id]) or rulesets[fallback] + or BUILTIN_RULESETS.gen1_faithful +end + -- Battlers go through BattleState.makeBattler so pics get the same -- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer -- battles. save=nil skips badge boosts so both machines keep identical @@ -177,7 +192,8 @@ local function stateSig(self, role, myParty, theirParty) local hostParty = role == "host" and myParty or theirParty local guestParty = role == "host" and theirParty or myParty return { - actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)), + actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest) + .. "|r" .. tostring(self.rngDraws or 0)), volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)), bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)), } @@ -197,7 +213,7 @@ local PARTS = { "actives", "volatile", "bench" } local FATAL_PART = { actives = true, bench = true } -- opts: { myParty = packed, theirParty = packed, theirName, role = --- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when +-- "host"/"guest", seed, ruleset, verdict, strict }. Returns nil plus a reason when -- the handshake says the two link surfaces don't match: a lockstep -- simulation of two different rulebooks can only end in a bogus draw. function LinkBattle.new(game, net, opts) @@ -255,7 +271,9 @@ function LinkBattle.new(game, net, opts) -- back up and the link can stall or time out. Game:step services -- game.linkNet unconditionally every frame. game.linkNet = net - self.rng = makeRng(opts.seed or 1) + self.rng = makeRng(opts.seed or 1, self) + self.rulesetId = opts.ruleset + self.ruleset = rulesetFor(game, opts.ruleset) self.player = mkBattler(game.data, myParty[1], true) self.enemy = mkBattler(game.data, theirParty[1], false) self.enemyParty = theirParty @@ -751,8 +769,8 @@ function LinkBattle.new(game, net, opts) -- the whole tournament, not just this match. if not opts.keepNetOpen then net:close() + if game.linkNet == net then game.linkNet = nil end end - if game.linkNet == net then game.linkNet = nil end baseFinish(s) end @@ -802,7 +820,9 @@ function LinkBattle.newSpectator(game, net, opts) 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.rng = makeRng(opts.seed or 1, self) + self.rulesetId = opts.ruleset + self.ruleset = rulesetFor(game, opts.ruleset) self.player = mkBattler(game.data, hostParty[1], true) self.enemy = mkBattler(game.data, guestParty[1], false) self.enemyParty = guestParty @@ -1011,10 +1031,11 @@ function LinkBattle.newSpectator(game, net, opts) end elseif inner.type == "bye" or inner.type == "forfeit" then if not s.result then endSpectate(s, "The match ended.") end + elseif inner.type == "hello" or inner.type == "party" then + s.pendingTournamentMessages = s.pendingTournamentMessages or {} + table.insert(s.pendingTournamentMessages, msg) end - -- "hello"/"party"/"hash" ride along too (Tournament.lua already - -- consumed hello/party before building this battle); none of them - -- need any action here + -- a spectated "hash" needs no action here else -- same reasoning as the real-participant loop above: don't lose a -- bracket_update/match_start_spectate that arrives mid-match @@ -1046,7 +1067,6 @@ function LinkBattle.newSpectator(game, net, opts) 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 diff --git a/src/link/LinkBattle2.lua b/src/link/LinkBattle2.lua new file mode 100644 index 00000000..be73e2db --- /dev/null +++ b/src/link/LinkBattle2.lua @@ -0,0 +1,599 @@ + +local Battle = require("src.battle.gen2.Battle") +local BattleState = require("src.ui.gen2.BattleState") +local Fingerprint = require("src.link.Fingerprint") +local Handshake = require("src.link.Handshake") +local Logger = require("src.core.Logger") +local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") + +local LinkBattle2 = {} + +local function makeRandom(seed, owner) + local s = tonumber(seed) or 1 + if s ~= s or s == math.huge or s == -math.huge then s = 1 end + s = math.floor(s) % 2147483647 + if s <= 0 then s = s + 2147483646 end + owner.rngDraws = 0 + return function(n) + owner.rngDraws = owner.rngDraws + 1 + s = (s * 16807) % 2147483647 + n = math.floor(tonumber(n) or 1) + if n < 1 then n = 1 end + return s % n + end +end + +local PARTS = { "actives", "volatile", "bench" } + +local FATAL_PART = { actives = true, bench = true } + +local IDLE_PHASE = { + menu = true, moves = true, ["locked-in"] = true, ["forced-switch"] = true, + ["link-wait"] = true, ["link-hold"] = true, ["refuse-menu"] = true, + ["refuse-move"] = true, ["refuse-switch"] = true, +} + +local function unpackParty(game, packed, unpackOpts, errFmt) + local out = {} + for _, p in ipairs(packed or {}) do + local mon, why = Protocol.unpackMon2(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 + +local function linkSave(game, party) + local player = (game and game.save and game.save.player) or {} + return { + party = party, + player = { name = player.name or "PLAYER", gender = player.gender }, + inventory = {}, + pokedex = { seen = {}, caught = {} }, + options = {}, + modData = {}, + } +end + +local function digestParts(parts) + return { + actives = Fingerprint.digest(parts.actives), + volatile = Fingerprint.digest(parts.volatile), + bench = Fingerprint.digest(parts.bench), + } +end + +local function moveSlot(mon, moveId) + for i, mv in ipairs((mon and mon.moves) or {}) do + if mv.id == moveId then return i end + end + return nil +end + +local function encodeAction(battle, action) + if action.kind == "switch" then + return { type = "action", kind = "switch", index = action.index } + end + if action.kind == "run" then + return { type = "action", kind = "run" } + end + if action.move == Battle.STRUGGLE then + return { type = "action", kind = "struggle" } + end + local slot = moveSlot(battle.player, action.move) + if not slot then + return { type = "action", kind = "locked" } + end + return { type = "action", kind = "move", slot = slot } +end + +local function decodeAction(battle, msg, mon) + local kind = msg.kind + if kind == "switch" then + local index = math.floor(tonumber(msg.index) or 1) + return { kind = "switch", index = index } + end + if kind == "run" then return { kind = "run" } end + if kind == "struggle" then + return { kind = "move", move = Battle.STRUGGLE } + end + if kind == "locked" then + local locked = battle:lockedInMove(mon) + or battle:volatile(mon).chargeMove + or battle:forcedMove(mon) + return { kind = "move", move = locked } + end + local moves = (mon and mon.moves) or {} + local slot = math.max(1, math.min(math.max(1, #moves), + math.floor(tonumber(msg.slot) or 1))) + local mv = moves[slot] + return { kind = "move", move = mv and mv.id or Battle.STRUGGLE } +end + +local function outcomeResult(outcome, battle) + if outcome == "win" then + if battle and not Battle.firstHealthy(battle.party) then return "draw" end + return "win" + end + if outcome == "lose" then return "lose" end + return "draw" +end + +function LinkBattle2.new(game, net, opts) + local role = opts.role + local theirName = opts.theirName or "FOE" + + if not Handshake.battleAllowed(opts.verdict) then + return nil, Strings("Link battle needs\nthe same version\nand mods.") + end + + local unpackOpts = { strict = opts.strict or false, + forceLevel = opts.forceLevel } + local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) + return Strings("Your %s can't\nbattle on the\nother game.", + tostring(p.species)) + end) + if not myParty then return nil, myErr end + local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, + function(p, why) + return Strings("Their %s isn't\nin this game.\n(%s)", + tostring(p.species), tostring(why)) + end) + if not theirParty then return nil, theirErr end + if #myParty == 0 or #theirParty == 0 then + Logger.warn("link2: empty party on one side") + end + + local function announceReceived(party) + for _, mon in ipairs(party) do + Runtime.emit("pokemon.received", + { mon = mon, from = "link", peerName = theirName }) + end + end + announceReceived(role == "host" and myParty or theirParty) + announceReceived(role == "host" and theirParty or myParty) + + local owner = {} + local random = makeRandom(opts.seed or 1, owner) + local battle = Battle.new({ + data = game.data, + random = random, + party = myParty, + trainer = { name = theirName, party = theirParty }, + save = nil, + }) + battle.linkBattle = true + battle.mirrored = role == "guest" + theirParty = battle.enemyParty + + local hooks = {} + local screen + local ended, byeSent = false, false + local pendingMine, pendingTheirs = nil, nil + local remoteReplace = {} + local turnCount = 0 + local localHashes, remoteHashes = {}, {} + local localParts, remoteParts = {}, {} + local checkedTurns = {} + local clock = nil + + local function send(msg) net:send(msg) end + + local function kick(s) + if IDLE_PHASE[s.phase] then + s.phase = "resolving" + s:advanceQueue() + end + end + + local function endWithResult(s, result, text) + if ended then return end + ended = true + s.result = result + if text then s:pushAll({ { kind = "message", text = text } }) end + if not battle.over then + battle:endBattle(result == "win" and "win" + or (result == "lose" and "lose" or "draw")) + end + kick(s) + end + + local function endAsDraw(s, text) + endWithResult(s, "draw", text) + end + + local function reportDesync(s, turn, component, localH, remoteH) + Logger.warn("link2: desync turn %s component=%s (%s vs %s)", + tostring(turn), component, tostring(localH), tostring(remoteH)) + Runtime.emit("link.desync", { turn = turn, component = component, + localHash = localH, remoteHash = remoteH, + fatal = true }) + endAsDraw(s, Strings( + "Link desync! %s differs. Are both games the same version and mods?", + component)) + end + + local function noteDrift(turn, component, localH, remoteH) + Logger.warn("link2: %s drift on turn %s (%s vs %s) -- match continues", + component, tostring(turn), tostring(localH), tostring(remoteH)) + Runtime.emit("link.desync", { turn = turn, component = component, + localHash = localH, remoteHash = remoteH, + fatal = false }) + end + + local function checkHashes(s) + for turn, localH in pairs(localHashes) do + local remoteH = remoteHashes[turn] + if remoteH and not checkedTurns[turn] then + checkedTurns[turn] = true + local mine, theirs = localParts[turn], remoteParts[turn] + if mine and theirs then + for _, component in ipairs(PARTS) do + if mine[component] ~= theirs[component] then + if FATAL_PART[component] then + reportDesync(s, turn, component, mine[component], + theirs[component]) + return + end + noteDrift(turn, component, mine[component], theirs[component]) + end + end + end + if remoteH ~= localH then + reportDesync(s, turn, "state", localH, remoteH) + return + end + end + end + end + + local function signTurn(s) + battle.rngDraws = owner.rngDraws + turnCount = turnCount + 1 + local raw = battle:linkSignature(role) + local parts = digestParts(raw) + local value = parts.actives .. "|" .. parts.bench + localHashes[turnCount] = value + localParts[turnCount] = parts + if LinkBattle2.keepSignatures then s.linkSignatures[turnCount] = raw end + send({ type = "hash", turn = turnCount, value = value, parts = parts }) + checkHashes(s) + end + + local function resolveLockstep(s, myMsg, theirMsg) + if myMsg.kind == "run" or theirMsg.kind == "run" then + local who = myMsg.kind == "run" + and ((game.save and game.save.player and game.save.player.name) + or "PLAYER") + or theirName + endAsDraw(s, Strings("%s ran from the battle!", who)) + return + end + local myAction = decodeAction(battle, myMsg, battle.player) + local theirAction = decodeAction(battle, theirMsg, battle.enemy) + local events = battle:takeLinkTurn(myAction, theirAction) + signTurn(s) + if ended then return end + s.phase = "resolving" + s:pushAll(events) + s.message = nil + s.messageTimer = 0 + s:advanceQueue() + end + + local function tryResolve(s) + if not pendingMine or not pendingTheirs then return end + local mine, theirs = pendingMine, pendingTheirs + pendingMine, pendingTheirs = nil, nil + resolveLockstep(s, mine, theirs) + end + + local function applyRemoteReplace(s) + if not battle.pendingEnemySwitch or #remoteReplace == 0 then return end + local index = table.remove(remoteReplace, 1) + battle:forcedReplacement("enemy", index) + s:pushAll(battle:takeEvents()) + if s.phase == "link-hold" then kick(s) end + end + + hooks.submit = function(s, action) + if ended then return end + if action.kind == "item" then + return s:refuseMenu(Strings("Items can't be used in a link battle!")) + end + if action.kind == "run" then + return s:refuseMenu(Strings("No running from a link battle!")) + end + local msg = encodeAction(battle, action) + send(msg) + pendingMine = msg + s.phase = "link-wait" + tryResolve(s) + end + + hooks.menuChoice = function(s, choice) + if choice == "item" then + s:refuseMenu(Strings("Items can't be used in a link battle!")) + return true + end + if choice == "run" then + s:refuseMenu(Strings("No running from a link battle!")) + return true + end + return false + end + + -- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after a + hooks.forcedSwitch = function(s, index) + local mon = myParty[index] + if not mon or (mon.hp or 0) <= 0 or mon.isEgg then + return s:refuseSwitch(true) + end + if not battle:forcedReplacement("player", index) then + return s:refuseSwitch(true) + end + send({ type = "replace", index = index }) + s.phase = "resolving" + s:pushAll(battle:takeEvents()) + s:advanceQueue() + return true + end + + screen = BattleState.new(game, { + battle = battle, + save = linkSave(game, myParty), + link = hooks, + onDone = function(outcome) + local result = screen.result or outcomeResult(outcome, battle) + screen.result = result + if not byeSent then + byeSent = true + send({ type = "bye" }) + end + if not opts.keepNetOpen then + net:close() + if game.linkNet == net then game.linkNet = nil end + end + if game.stack and game.stack:top() == screen then game.stack:pop() end + if screen.onFinish then screen.onFinish(result) end + end, + }) + + screen.kind = "link" + screen.linkRole = role + screen.result = nil + screen.playerParty = myParty + screen.enemyParty = theirParty + screen.localHashes = localHashes + screen.remoteHashes = remoteHashes + screen.localParts = localParts + screen.remoteParts = remoteParts + screen.linkSignatures = {} + screen.rngOwner = owner + game.linkNet = net + + local baseUpdate = screen.update + screen.update = function(s, dt) + net:update() + for _, msg in ipairs(net:poll()) do + if msg.type == "action" then + pendingTheirs = msg + tryResolve(s) + elseif msg.type == "hash" then + remoteHashes[msg.turn or 0] = msg.value + remoteParts[msg.turn or 0] = msg.parts + checkHashes(s) + elseif msg.type == "replace" then + table.insert(remoteReplace, math.max(1, math.min(#theirParty, + math.floor(tonumber(msg.index) or 1)))) + elseif msg.type == "bye" then + if not ended and not battle.over then + endAsDraw(s, Strings("%s left the battle.", theirName)) + end + elseif msg.type == "forfeit" then + if not ended and not battle.over then + endWithResult(s, "win", Strings("%s ran out of time!", theirName)) + end + else + s.pendingLinkMessages = s.pendingLinkMessages or {} + table.insert(s.pendingLinkMessages, msg) + end + end + if net.closed and not ended and not battle.over then + endAsDraw(s) + end + applyRemoteReplace(s) + if battle.pendingEnemySwitch + and (s.phase == "menu" or s.phase == "moves" + or s.phase == "locked-in") then + s.phase = "link-hold" + end + if opts.turnLimit and (s.phase == "menu" or s.phase == "moves") then + clock = (clock or opts.turnLimit) - (dt or 0) + if clock <= 0 then + clock = nil + send({ type = "forfeit" }) + endWithResult(s, "lose", + Strings("Time's up! You forfeit the match.")) + end + elseif opts.turnLimit then + clock = nil + end + return baseUpdate(s, dt) + end + + return screen +end + +function LinkBattle2.newSpectator(game, net, opts) + local hostName = opts.hostName or "HOST" + local guestName = opts.guestName or "GUEST" + + if not Handshake.battleAllowed(opts.verdict) then + return nil, Strings("Link battle needs\nthe same version\nand mods.") + end + + local unpackOpts = { strict = opts.strict or false, + forceLevel = opts.forceLevel } + local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, + function(p) + return Strings("%s's %s can't\nbattle on this\ngame.", 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 Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, + tostring(p.species), tostring(why)) + end) + if not guestParty then return nil, guestErr end + + local owner = {} + local random = makeRandom(opts.seed or 1, owner) + local battle = Battle.new({ + data = game.data, + random = random, + party = hostParty, + trainer = { name = guestName, party = guestParty }, + save = nil, + }) + battle.linkBattle = true + guestParty = battle.enemyParty + + local hooks = {} + local screen + local hostMsg, guestMsg = nil, nil + local hostReplace, guestReplace = {}, {} + local ended = false + + local function endSpectate(s, text) + if ended then return end + ended = true + s.result = s.result or "ended" + if text then s:pushAll({ { kind = "message", text = text } }) end + if not battle.over then battle:endBattle("draw") end + if IDLE_PHASE[s.phase] then + s.phase = "resolving" + s:advanceQueue() + end + end + + local function resolveSpecTurn(s, hMsg, gMsg) + if hMsg.kind == "run" or gMsg.kind == "run" then + endSpectate(s, Strings("The match ended.")) + return + end + local hostAction = decodeAction(battle, hMsg, battle.player) + local guestAction = decodeAction(battle, gMsg, battle.enemy) + local events = battle:takeLinkTurn(hostAction, guestAction) + if ended then return end + s.phase = "resolving" + s:pushAll(events) + s.message = nil + s.messageTimer = 0 + s:advanceQueue() + end + + hooks.forcedPrompt = function(s) + local index = table.remove(hostReplace, 1) + if not index then return true end + battle:forcedReplacement("player", index) + s.phase = "resolving" + s:pushAll(battle:takeEvents()) + s:advanceQueue() + return true + end + + hooks.submit = function() end + hooks.menuChoice = function() return true end + hooks.forcedSwitch = function(s) return s:refuseSwitch(true) end + + screen = BattleState.new(game, { + battle = battle, + save = linkSave(game, hostParty), + link = hooks, + onDone = function(outcome) + local result = screen.result or outcomeResult(outcome, battle) + screen.result = result + if game.stack and game.stack:top() == screen then game.stack:pop() end + if screen.onFinish then screen.onFinish(result) end + end, + }) + + screen.kind = "link" + screen.spectating = true + screen.result = nil + screen.playerParty = hostParty + screen.enemyParty = guestParty + game.linkNet = net + + local baseUpdate = screen.update + screen.update = function(s, dt) + net:update() + for _, msg in ipairs(net:poll()) do + if msg.type == "spectate" and type(msg.msg) == "table" then + local inner = msg.msg + if inner.type == "action" then + if msg.side == "host" then hostMsg = inner else guestMsg = inner end + if hostMsg and guestMsg then + local h, g = hostMsg, guestMsg + hostMsg, guestMsg = nil, nil + resolveSpecTurn(s, h, g) + end + elseif inner.type == "replace" then + local index = math.floor(tonumber(inner.index) or 1) + if msg.side == "host" then + table.insert(hostReplace, + math.max(1, math.min(#hostParty, index))) + else + table.insert(guestReplace, + math.max(1, math.min(#guestParty, index))) + end + elseif inner.type == "bye" or inner.type == "forfeit" then + endSpectate(s, Strings("The match ended.")) + end + else + s.pendingLinkMessages = s.pendingLinkMessages or {} + table.insert(s.pendingLinkMessages, msg) + end + end + if net.closed and not ended and not battle.over then endSpectate(s) end + if battle.pendingEnemySwitch then + local index = table.remove(guestReplace, 1) + if index then + battle:forcedReplacement("enemy", index) + s:pushAll(battle:takeEvents()) + if s.phase == "link-hold" then + s.phase = "resolving" + s:advanceQueue() + end + elseif s.phase == "menu" or s.phase == "moves" + or s.phase == "locked-in" then + s.phase = "link-hold" + end + end + if s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in" then + s.phase = "link-wait" + end + return baseUpdate(s, dt) + end + + return screen +end + +function LinkBattle2.newHost(game, net, opts) + opts.role = "host" + return LinkBattle2.new(game, net, opts) +end + +function LinkBattle2.newGuest(game, net, opts) + opts.role = "guest" + return LinkBattle2.new(game, net, opts) +end + +return LinkBattle2 diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 9e119a5d..860b6c6d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -48,26 +48,34 @@ end -- stages before a successful transport has become this link's Session; -- terminal checks skip them rather than keying off self.net's presence -local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } +local PRE_CONNECT_STAGES = { menu = 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 --- the joiner edits an IPv4 address as 12 digits (three per octet), --- prefilled with our own LAN IP so usually only the tail needs changing -local function ipDigits(ip) - local digits = {} - local a, b, c, d = (ip or ""):match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") - local octets = { tonumber(a) or 192, tonumber(b) or 168, - tonumber(c) or 0, tonumber(d) or 1 } +LinkState.ADDR_LENGTH = 15 +LinkState.ADDR_CHARSET = "0123456789. " + +local ADDR_OPTS = { length = LinkState.ADDR_LENGTH, + charset = LinkState.ADDR_CHARSET } + +function LinkState.addrEntry(ip) + local seed = ip or "192.168.0.1" + if not seed:match("^%d+%.%d+%.%d+%.%d+$") then seed = "192.168.0.1" end + local state = CodeEntry.fromText(seed, ADDR_OPTS) + state.pos = math.max(1, math.min(ADDR_OPTS.length, #seed)) + return state +end + +function LinkState.addrText(state) + local text = (CodeEntry.text(state):gsub(" ", "")) + local octets = { text:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") } + if #octets ~= 4 then return nil end for _, o in ipairs(octets) do - o = math.min(255, o) - table.insert(digits, math.floor(o / 100)) - table.insert(digits, math.floor(o / 10) % 10) - table.insert(digits, o % 10) + if #o > 3 or tonumber(o) > 255 then return nil end end - return digits + return text end local function openSession(role, connect) @@ -88,36 +96,17 @@ function LinkState.new(game) game.linkSession = true self.stage = "menu" self.index = 1 - self.addr = ipDigits(Net.lanIP()) - self.addrPos = 12 -- the last octet is what usually differs + self.addr = LinkState.addrEntry(Net.lanIP()) self.status = "" return self end --- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua): --- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to --- "connecting with this code", same as if the player had typed it in -function LinkState.newJoinOnline(game, code) - local self = LinkState.new(game) - local session, detail = openSession("guest", function(transport) - return transport:joinOnline(nil, code) - end) - if session then - self.net = session - self.stage = "onlineJoining" - else - self.stage = "menu" -- exitWith below needs a real stage to unwind from - self:exitWith(Strings("Link error:\n%s", detail)) - end - return self -end - -- Adopt a transport that is ALREADY paired and skip the connect UI: an -- overworld multiplayer session handing one pair of players off to a battle -- or a trade. The caller has settled which mode and which side hosts, so -- all that is left is the hello exchange every link session runs before it -- commits -- the fingerprint/mod compatibility check still gets its say, --- exactly as it would have on the LAN or ONLINE path. +-- exactly as it would have on the LAN path. -- -- `transport` is anything Session accepts (update/poll/send/close plus the -- .paired/.closed/.error fields), which is what lets a mod route a battle @@ -150,48 +139,6 @@ function LinkState:exitWith(message, reason) end end --- Online play meets strangers, so it requires a vanilla simulation on both --- ends (Handshake.onlineAllowed). Mods merge into the shared Data --- registries at boot and there is no unmerge, so switching them off has to --- go through a relaunch -- but the player should not have to go find the --- mod manager and work out which mods count. This turns the blocking mods --- off, records them so the mod manager can put them back, and relaunches. --- Verified translations are not blockers (#501), so a player keeps their --- language across the restart and only the gameplay mods go. The restart --- is confirmed rather than silent: it drops unsaved progress. -function LinkState:offerVanillaRestart() - local game = self.game - local loader = game.mods - local mods = Handshake.onlineBlockers(game) - local names = {} - for i, mod in ipairs(mods) do - if i > 2 then break end - names[#names + 1] = tostring(mod.id):upper():sub(1, 12) - end - local list = table.concat(names, ", ") - if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end - local text = Strings( - "Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list) - self.game.linkSession = nil - Runtime.emit("link.ended", { reason = "error" }) - if self.net then self.net:close() end - game.stack:pop() - game.stack:push(TextBox.new(game, text, nil, { choice = function(yes) - if not yes then return end - -- setEnabled persists the toggle itself (Loader:_saveState), so the - -- relaunch comes up vanilla and the mod manager lists them as disabled - -- for the player to switch back on afterwards - for _, mod in ipairs(mods) do - if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end - end - if game.restartWithMods then - game:restartWithMods() - elseif love.event and love.event.quit then - love.event.quit("restart") - end - end })) -end - -- ------------------------------------------------------------------- -- handshake v2 (D8): both peers announce engine version, api version and -- a fingerprint of their link surface, and the verdict comes from the two @@ -263,8 +210,7 @@ function LinkState:update(dt) -- so a final message travelling with the disconnect still counts) if status == "closed" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" - and self.stage ~= "codeEntry" and self.stage ~= "notice" - and self.stage ~= "battleRunning" then + and self.stage ~= "notice" and self.stage ~= "battleRunning" then self:exitWith(Strings("The link was\nbroken.")) return end @@ -281,38 +227,10 @@ function LinkState:update(dt) 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:offerVanillaRestart() - 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.stage = "menu" - self.index = 1 + self:exitWith(nil) elseif input:wasPressed("a") then if self.index == 1 then local session, detail = openSession("host", function(transport) @@ -329,73 +247,6 @@ 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 - local session, detail = openSession("host", function(transport) - return transport:hostOnline() - end) - if session then - self.net = session - self.stage = "onlineHosting" - else - self:exitWith(Strings("Link error:\n%s", detail)) - end - else - self.stage = "codeEntry" - self.codeEntry = CodeEntry.new() - end - end - - elseif self.stage == "onlineHosting" then - if not self.discordCodeSet and self.net.code then - DiscordPresence.setJoinCode(self.net.code) - self.discordCodeSet = true - end - if input:wasPressed("b") then self:exitWith(nil) return end - if self.net.paired then - DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising - 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) - local session, detail = openSession("guest", function(transport) - return transport:joinOnline(nil, code) - end) - if session then - self.net = session - self.stage = "onlineJoining" - else - self:exitWith(Strings("Link error:\n%s", detail)) - 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 @@ -406,22 +257,20 @@ function LinkState:update(dt) elseif self.stage == "addrEntry" then if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("up") then - self.addr[self.addrPos] = (self.addr[self.addrPos] + 1) % 10 + CodeEntry.up(self.addr) elseif input:wasPressed("down") then - self.addr[self.addrPos] = (self.addr[self.addrPos] - 1) % 10 + CodeEntry.down(self.addr) elseif input:wasPressed("left") then - self.addrPos = math.max(1, self.addrPos - 1) + CodeEntry.left(self.addr) elseif input:wasPressed("right") then - self.addrPos = math.min(12, self.addrPos + 1) + CodeEntry.right(self.addr) elseif input:wasPressed("a") then - local octets = {} - for i = 1, 4 do - local base = (i - 1) * 3 - octets[i] = math.min(255, self.addr[base + 1] * 100 - + self.addr[base + 2] * 10 - + self.addr[base + 3]) + local address = LinkState.addrText(self.addr) + if not address then + self.status = Strings("Not an IP address.") + return end - local address = table.concat(octets, ".") + self.status = "" local session, detail = openSession("guest", function(transport) return transport:join(address) end) @@ -511,7 +360,10 @@ function LinkState:update(dt) if message then -- the host owns this rule (same as mode); the guest only learns -- it here, off the host's own party message - if not self.isHost then self.forceLevel = message.forceLevel end + if not self.isHost then + self.forceLevel = message.forceLevel + self.rulesetId = message.ruleset + end local LinkBattle = require("src.link.LinkBattle") local opts = { myParty = Protocol.packParty(self.game.save.party), @@ -521,6 +373,7 @@ function LinkState:update(dt) verdict = self.verdict, strict = Handshake.strict(self.verdict), forceLevel = self.forceLevel, + ruleset = self.rulesetId, } local battle, why if self.isHost then @@ -581,11 +434,13 @@ function LinkState:startMode(mode, isHost) -- the host deals the shared RNG seed for the lockstep simulation if isHost then self.linkSeed = love.math.random(1, 2 ^ 30) + self.rulesetId = Handshake.ruleset(self.game) end self.net:send({ type = "party", mons = Protocol.packParty(self.game.save.party), seed = self.linkSeed, - forceLevel = isHost and self.forceLevel or nil }) + forceLevel = isHost and self.forceLevel or nil, + ruleset = isHost and self.rulesetId or nil }) end end @@ -734,49 +589,12 @@ end function LinkState:draw() if self.stage == "menu" then - drawTitle("BOIS CLUB LIVE") - Font.draw(Strings("LINK CABLE (LAN)"), 32, 44) - Font.draw(Strings("ONLINE MATCH"), 32, 60) - Font.draw(Strings("TOURNAMENT"), 32, 76) - Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) - - elseif self.stage == "lanMenu" then drawTitle("LINK CABLE (LAN)") Font.draw(Strings("HOST A GAME"), 32, 48) Font.draw(Strings("JOIN A GAME"), 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128) - elseif self.stage == "onlineMenu" then - drawTitle("ONLINE MATCH") - Font.draw(Strings("HOST ONLINE"), 32, 48) - Font.draw(Strings("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(Strings("Tell your friend"), 16, 40) - Font.draw(Strings("the code:"), 16, 52) - Font.draw(self.net.code or "??????", 32, 68) - Font.draw(Strings("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(Strings("A: connect B: back"), 8, 128) - - elseif self.stage == "onlineJoining" then - drawTitle("CONNECTING...") - Font.draw(Strings("Calling..."), 8, 56) - Font.draw(self.net.target or "", 8, 72) - elseif self.stage == "hosting" then drawTitle("HOSTING") Font.draw(Strings("Friend joins at:"), 16, 48) @@ -785,18 +603,16 @@ function LinkState:draw() elseif self.stage == "addrEntry" then drawTitle("ENTER HOST ADDRESS") - for i = 1, 12 do - local octet = math.floor((i - 1) / 3) -- 0..3 - local x = 16 + (i - 1) * 8 + octet * 8 -- gap for the dots - Font.draw(tostring(self.addr[i]), x, 64) - if i == self.addrPos then - Font.drawCode(0xEE, x, 76) -- ▼ under the active digit + for i = 1, LinkState.ADDR_LENGTH do + local x = 8 + (i - 1) * 8 + local ch = CodeEntry.charAt(self.addr, i) + if ch ~= " " then Font.draw(ch, x, 64) end + if i == self.addr.pos then + Font.drawCode(0xEE, x, 76) end end - for octet = 1, 3 do - Font.draw(".", 16 + octet * 32 - 8, 64) - end - Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96) + Font.draw(Strings("Port: %s", Net.defaultPort()), 8, 96) + if self.status ~= "" then Font.draw(self.status, 8, 112) end Font.draw(Strings("A: connect B: back"), 8, 128) elseif self.stage == "joining" then @@ -828,8 +644,13 @@ function LinkState:draw() elseif self.stage == "notice" then -- a version-skew notice has nothing to do with mods (#758) - drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME" - or "CHECK YOUR MODS") + local noticeTitle = "CHECK YOUR MODS" + if self.verdict == "engine_skew" then + noticeTitle = "UPDATE YOUR GAME" + elseif self.verdict == "ruleset_skew" then + noticeTitle = "CHECK YOUR RULES" + end + drawTitle(noticeTitle) for i, line in ipairs(self.noticeLines or {}) do if i > 8 then break end -- what fits above the prompt row Font.draw(line, 8, 24 + (i - 1) * 12) diff --git a/src/link/Net.lua b/src/link/Net.lua index 6d1ad4b2..73028dc1 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -38,12 +38,18 @@ local Net = {} Net.__index = Net Net.DEFAULT_PORT = 7777 -Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778" +Net.DEFAULT_RELAY_ADDRESS = "relay.gen1re.com:7778" Net.MAX_LINE = 256 * 1024 Net.MAX_RX_PER_FRAME = 512 * 1024 Net.ENET_BANDWIDTH = 256 * 1024 +Net.CONNECT_TIMEOUT = 5 +Net.PING_INTERVAL = 10 +Net.PING_MISSES = 3 +Net.CLOSE_FLUSH_TIMEOUT = 0.25 +Net.CLOSE_FLUSH_ATTEMPTS = 256 + function Net.available() return enet ~= nil end @@ -97,6 +103,11 @@ function Net.new() 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 + connecting = false, -- relay backend: TCP connect still in flight + selectable = true, + lastSendAt = nil, + pendingPings = 0, + sawPong = false, }, Net) end @@ -160,13 +171,34 @@ 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 +-- opens a TCP connection to a pokeserver relay. The connect is +-- non-blocking: connectTCP returns true once the socket is dialing and +-- updateTCP polls writability until the 5 s deadline. Callers 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. +-- {type="join",...}, {type="host_tournament",...}, ...) straight away; it +-- waits in txBuf until the connection lands. Every reply that isn't one +-- of the generic ones below reaches the normal inbox for the caller +-- (LinkState, or the launcher online client) to interpret. +local function connectPending(err) + err = tostring(err or "") + return err == "timeout" or err:find("in progress", 1, true) ~= nil +end + +function Net:beginTCP(tcp, host, port, pending) + self.tcpSocket = tcp + self.connectHost, self.connectPort = host, port + self.rxBuf = "" + self.txBuf = "" + self.connecting = pending and true or false + self.connectDeadline = now() + Net.CONNECT_TIMEOUT + self.lastSendAt = now() + if not pending then self:applyKeepalive() end +end + +function Net:applyKeepalive() + pcall(function() self.tcpSocket:setoption("keepalive", true) end) +end + function Net:connectTCP(addr) if not socket then self.error = "online play needs luasocket (bundled with LOVE)" @@ -176,19 +208,56 @@ function Net:connectTCP(addr) host = host or addr port = tonumber(port) or 7778 local tcp = socket.tcp() - tcp:settimeout(5) + tcp:settimeout(0) local ok, err = tcp:connect(host, port) - if not ok then - self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err)) + if ok then + self:beginTCP(tcp, host, port, false) + return true + end + if connectPending(err) then + self:beginTCP(tcp, host, port, true) + return true + end + pcall(function() tcp:close() end) + local blocking = socket.tcp() + blocking:settimeout(Net.CONNECT_TIMEOUT) + local bok, berr = blocking:connect(host, port) + if not bok then + pcall(function() blocking:close() end) + self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(berr)) return false end - tcp:settimeout(0) - self.tcpSocket = tcp - self.rxBuf = "" - self.txBuf = "" + blocking:settimeout(0) + self:beginTCP(blocking, host, port, false) return true end +function Net:pollConnectTCP() + local sock = self.tcpSocket + local okSel, _, writable = pcall(socket.select, nil, { sock }, 0) + if okSel and writable and #writable > 0 then + local ok, err = sock:connect(self.connectHost, self.connectPort) + err = tostring(err or "") + if ok or err:find("already connected", 1, true) then + self.connecting = false + self:applyKeepalive() + return true + end + if not connectPending(err) then + self.error = Strings("can't reach relay %s:%d\n(%s)", + self.connectHost, self.connectPort, err) + self.closed = true + return false + end + end + if now() > (self.connectDeadline or 0) then + self.error = Strings("can't reach relay %s:%d\n(%s)", + self.connectHost, self.connectPort, "timeout") + self.closed = true + end + return false +end + function Net:hostOnline(addr) if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end self.mode = "onlineHosting" @@ -208,6 +277,7 @@ function Net:send(msg) if self.closed then return end if self.tcpSocket then self.txBuf = self.txBuf .. Json.encode(msg) .. "\n" + self.lastSendAt = now() return end if self.peerEnd then -- loopback: re-encode through json like the wire @@ -242,6 +312,7 @@ local function handleGenericRelayControl(self, msg) self.paired = true return true elseif msg.type == "join_error" then + if self.v2 then return false end self.error = Strings(({ not_found = Strings.source("That code wasn't\nfound."), full = Strings.source("That game already\nhas two players."), @@ -255,6 +326,13 @@ local function handleGenericRelayControl(self, msg) elseif msg.type == "peer_gone" then self.closed = true return true + elseif msg.type == "ping" then + self:send({ type = "pong", t = msg.t }) + return true + elseif msg.type == "pong" then + self.sawPong = true + self.pendingPings = 0 + return true end return false end @@ -295,9 +373,24 @@ end -- 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:heartbeatTCP() + local t = now() + if not self.lastSendAt then self.lastSendAt = t end + if t - self.lastSendAt < Net.PING_INTERVAL then return end + self:send({ type = "ping", t = math.floor(t * 1000) }) + self.pendingPings = (self.pendingPings or 0) + 1 + if self.sawPong and self.pendingPings >= Net.PING_MISSES then + self.error = Strings("The relay stopped\nanswering.") + self.closed = true + end +end + function Net:updateTCP() if self.closed then return end local sock = self.tcpSocket + if self.connecting then + if not self:pollConnectTCP() then return end + end if #self.txBuf > 0 then local sent, err, lastByte = sock:send(self.txBuf) if sent then @@ -329,6 +422,31 @@ function Net:updateTCP() if not data then break end -- nothing more buffered this frame end self:drainLines() + if not self.closed then self:heartbeatTCP() end +end + +function Net:flushTCP(seconds) + local sock = self.tcpSocket + if not sock or self.connecting then return end + local deadline = now() + (seconds or Net.CLOSE_FLUSH_TIMEOUT) + for _ = 1, Net.CLOSE_FLUSH_ATTEMPTS do + if #self.txBuf == 0 then return end + local sent, err, lastByte = sock:send(self.txBuf) + if sent then + self.txBuf = "" + return + elseif err == "timeout" then + self.txBuf = self.txBuf:sub((lastByte or 0) + 1) + else + return + end + if #self.txBuf == 0 or now() >= deadline then return end + if self.selectable and socket and socket.select then + if not pcall(socket.select, nil, { sock }, 0.01) then + self.selectable = false + end + end + end end -- pump enet events; decoded JSON messages are queued for poll() @@ -400,6 +518,7 @@ function Net:close() return end if self.tcpSocket then + pcall(function() self:flushTCP() end) pcall(function() self.tcpSocket:close() end) self.tcpSocket = nil self.closed = true diff --git a/src/link/Protocol.lua b/src/link/Protocol.lua index 5de59257..cf2fe0aa 100644 --- a/src/link/Protocol.lua +++ b/src/link/Protocol.lua @@ -103,7 +103,7 @@ function Protocol.unpackMon(data, packed, opts) end -- forceLevel comes from an "auto-level" ruling. The picker's ANY choice -- ("use each mon's real level", Gen1's only mode) is a string sentinel on - -- the LinkState/Tournament side (see levelForWire) that must mean "no + -- the LinkState side (see levelForWire) that must mean "no -- forced level" here. Coerce once so a non-numeric level string -- the ANY -- sentinel, an old peer, or a mod (#204) -- can never reach math.floor -- below: tonumber("ANY") == nil, i.e. keep the packed real level, while @@ -404,6 +404,20 @@ function Protocol.packParty(party, indices) return mons end +function Protocol.packParty2(party, indices) + local mons = {} + if indices then + for _, i in ipairs(indices) do + table.insert(mons, Protocol.packMon2(party[i])) + end + return mons + end + for _, mon in ipairs(party) do + table.insert(mons, Protocol.packMon2(mon)) + end + return mons +end + -- ------- subset negotiation -- every record hash, not just this party's slice: the receiver filters diff --git a/src/link/Session.lua b/src/link/Session.lua index 87fda6b6..525a6bd0 100644 --- a/src/link/Session.lua +++ b/src/link/Session.lua @@ -4,13 +4,13 @@ local Wire = require("src.link.Wire") local Session = {} Session.__index = Session -local VALID_ROLES = { host = true, guest = true } +local VALID_ROLES = { host = true, guest = true, client = true } local REQUIRED_METHODS = { "update", "poll", "send", "close" } function Session.new(transport, options) assert(type(transport) == "table", "Session.new requires a transport") assert(type(options) == "table", "Session.new requires options") - assert(VALID_ROLES[options.role], "Session role must be host or guest") + assert(VALID_ROLES[options.role], "Session role must be host, guest or client") assert(type(options.kind) == "string" and options.kind ~= "", "Session kind must be a non-empty string") for _, method in ipairs(REQUIRED_METHODS) do @@ -155,10 +155,11 @@ local function finishRead(self) self:_refreshStatus() end -function Session:take(messageType) +function Session:take(messageType, predicate) assert(type(messageType) == "string", "Session.take requires a message type") for index, message in ipairs(self._inbox) do - if message.type == messageType then + if message.type == messageType + and (predicate == nil or predicate(message) == true) then local found = table.remove(self._inbox, index) finishRead(self) return found @@ -181,6 +182,17 @@ function Session:poll() return messages end +function Session:unread(messages) + if type(messages) ~= "table" then return end + for index = #messages, 1, -1 do + local message = messages[index] + if type(message) == "table" and type(message.type) == "string" then + table.insert(self._inbox, 1, message) + end + end + finishRead(self) +end + function Session:close() if self._status == "closed" or self._status == "failed" then return end if self._terminal then diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua deleted file mode 100644 index a8a06193..00000000 --- a/src/link/Tournament.lua +++ /dev/null @@ -1,651 +0,0 @@ --- 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 DiscordPresence = require("src.core.DiscordPresence") -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 Session = require("src.link.Session") -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 Strings = require("src.core.Strings") - -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 = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING --- LEVELS cycles through this: ANY (real levels) or a fixed level every --- match normalizes to, regardless of each side's real party -local FORCE_LEVEL_STEPS = { ANY, 50, 100 } --- tournaments have no real participant cap (any number can join before --- start); this is just generous headroom so Discord's party.size never --- reads as "full" and blocks a real invite click -local TOURNAMENT_PARTY_MAX = 16 - -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) - -- ANY ("use each mon's real level") goes on the wire as nil (no forced - -- level). An explicit guard, not `v == ANY and nil or v`: that idiom's - -- true branch is nil, so it falls through to `or v` and returned the - -- literal "ANY" string, which then crashed math.floor in unpackMon (#204). - if v == ANY then return nil end - return v -end - -local function forceLevelLabel(v) - return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(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, - forceLevel = ANY, participating = true } - self.settingsIndex = 1 - self.roster = {} - self.spectatorRoster = {} - -- everything from here to exitWith runs at 1X regardless of the GAME - -- SPEED option (see Game:logicSpeed): a tournament's shot clock counts - -- down on the logic step, so fast-forward would hand one player less - -- real time to choose than the opponent they are racing - game.linkSession = true - Sound.startLoop(game.data, MUSIC) - return self -end - --- entry point for a Discord "Ask to Join" click on a tournament invite --- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry, --- straight to "connecting with this code" -function Tournament.newJoinOnline(game, code) - local self = Tournament.new(game) - self:startJoining(code) - return self -end - --- headcount for Discord's party.size: the roster only ever lists --- competing players, so a non-participating (organizer-only) host isn't --- in it even though they're right here running the thing -function Tournament:discordPartySize() - return #self.roster + (self.participating == false and 1 or 0) -end - -function Tournament:exitWith(message) - DiscordPresence.setJoinCode(nil) - self.game.linkSession = nil -- back to the player's own GAME SPEED - 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 --- ------------------------------------------------------------------- - -local function openSession(role) - local transport = Net.new() - if transport:connectTCP(Net.defaultRelayAddress()) then - return Session.new(transport, { role = role, kind = "tournament" }) - end - local detail = transport.error or "?" - transport:close() - return nil, detail -end - -function Tournament:startHosting() - local session, detail = openSession("host") - if not session then - self:exitWith(Strings("Link error:\n%s", detail)) - return - end - self.net = session - 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), - forceLevel = levelForWire(self.settings.forceLevel), - participating = self.settings.participating, - name = self.game.save.player.name, - partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, - }) - self.stage = "registering" -end - -function Tournament:startJoining(code) - local session, detail = openSession("guest") - if not session then - self:exitWith(Strings("Link error:\n%s", detail)) - return - end - self.net = session - 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 = Strings.source("That code wasn't\nfound."), - already_started = Strings.source("That tournament\nhas already begun."), - expired = Strings.source("That code has\nexpired."), -} - -function Tournament:handleMessage(msg) - if msg.type == "tournament_hosted" then - self.code = msg.code - self.settings.turnLimit = msg.turnLimit - self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel - self.participating = msg.participating - self.stage = "bracket" - -- the server already seeded t.players/spectators with the creator at - -- creation time; mirror that here so the Discord party size (and the - -- "waiting for players" list) show the host from the very first frame, - -- not just once someone else joins and a real roster broadcast arrives - if self.participating then - self.roster = { self.game.save.player.name } - else - self.spectatorRoster = { self.game.save.player.name } - end - if self.isCreator then - DiscordPresence.setJoinCode(self.code, "tournament", - self:discordPartySize(), TOURNAMENT_PARTY_MAX) - end - elseif msg.type == "tournament_host_error" then - if msg.reason == "party_ineligible" then - self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) - else - self:exitWith(Strings("Couldn't host\nthat tournament.")) - end - elseif msg.type == "tournament_join_error" then - if msg.reason == "party_ineligible" then - self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) - else - self:exitWith(JOIN_ERROR_TEXT[msg.reason] - and Strings(JOIN_ERROR_TEXT[msg.reason]) - or Strings("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.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel - self.stage = "bracket" - if self.isCreator and self.code then - DiscordPresence.setJoinCode(self.code, "tournament", - self:discordPartySize(), TOURNAMENT_PARTY_MAX) - end - 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 message - if not self.peerHello then message = self.net:take("hello") end - if message then self.peerHello = message end - return message ~= nil -end - --- The relay assigns a side for each match; this can differ from the immutable --- tournament creator/joiner role held by Session. -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(Strings("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, - forceLevel = levelForWire(self.settings.forceLevel), - 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() - local status = self.net:getStatus() - if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings" - and self.stage ~= "codeEntry" then - self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60))) - return - end - if status == "closed" and self.stage ~= "done" then - self:exitWith(Strings("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() - return - end - for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end - return - elseif self.stage == "matchWaitParty" then - if input:wasPressed("b") then self:exitWith(nil) return end - while self.net:hasPending() do - local message = self.net:pollOne() - if message.type == "party" then - self.pendingBattleOpts.theirParty = message.mons - if self.isHost then - self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed - else - self.pendingBattleOpts.seed = message.seed - end - local battle, why - if self.isHost then - battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) - else - battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) - end - if not battle then - self:exitWith(why or Strings("Link battle\ncan't start.")) - return - end - self.activeBattle = battle - self.game.stack:push(battle) - self.stage = "matchRunning" - return - else - self:handleMessage(message) - end - end - return - elseif self.stage == "spectateWait" then - if input:wasPressed("b") then self:exitWith(nil) return end - while self.net:hasPending() do - local message = self.net:pollOne() - if message.type == "spectate" and message.msg.type == "party" then - local inner = message.msg - if message.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, - forceLevel = levelForWire(self.settings.forceLevel), - }) - if not battle then - self:exitWith(why or Strings("Can't watch this\nmatch.")) - return - end - self.activeBattle = battle - self.game.stack:push(battle) - self.stage = "spectateRunning" - return - end - else - self:handleMessage(message) - 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 - local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel) - i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1 - self.settings.forceLevel = FORCE_LEVEL_STEPS[i] - elseif self.settingsIndex == 6 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 - DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising - 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", "LEVELS", "PLAYING" } - -function Tournament:draw() - if self.stage == "menu" then - drawTitle("TOURNAMENT") - Font.draw(Strings("HOST"), 32, 48) - Font.draw(Strings("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", - forceLevelLabel(self.settings.forceLevel), - 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(Strings("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(Strings("A: join B: back"), 8, 128) - - elseif self.stage == "registering" then - drawTitle("CONNECTING...") - Font.draw(Strings("B: cancel"), 8, 128) - - elseif self.stage == "bracket" or self.stage == "matchHello" - or self.stage == "matchWaitParty" or self.stage == "spectateWait" then - drawTitle(Strings("TOURNAMENT %s", self.code or "??????")) - if self.bracket then - local y = 20 - for _, round in ipairs(self.bracket.rounds) do - Font.draw(Strings("ROUND %d", round.round), 8, y) - y = y + 10 - for _, m in ipairs(round.matches) do - local line - if m.bye then - line = Strings("%s (bye)", m.a or m.b or "?") - else - local mark = m.state == "live" and "*" or (m.winner and "" or "") - line = Strings("%s%s vs %s%s", 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(Strings("(organizing --"), 16, 32) - Font.draw(Strings("not playing)"), 16, 42) - end - Font.draw(Strings("Waiting for"), 16, 48) - Font.draw(Strings("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(Strings("A: START B: cancel"), 8, 132) - else - Font.draw(Strings("B: cancel"), 8, 132) - end - - elseif self.stage == "done" then - drawTitle("TOURNAMENT OVER") - if self.champion then - Font.draw(Strings("%s is the", self.champion), 16, 56) - Font.draw(Strings("champion!"), 16, 68) - end - Font.draw(Strings("A: continue"), 8, 128) - end - love.graphics.setColor(1, 1, 1, 1) -end - -return Tournament diff --git a/src/link/Wire.lua b/src/link/Wire.lua index 6404a888..40984042 100644 --- a/src/link/Wire.lua +++ b/src/link/Wire.lua @@ -137,6 +137,7 @@ local function name(v) end local sanitize +local arenaStart local SCHEMAS = {} @@ -150,6 +151,7 @@ SCHEMAS.hello = function(m) generation = Wire.num(m.generation, nil, 0, 255), fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING), linkModified = Wire.bool(m.linkModified, nil), + ruleset = Wire.str(m.ruleset, nil, MAX_STRING), mods = Wire.list(m.mods, MAX_MODS, modEntry), } end @@ -167,6 +169,7 @@ SCHEMAS.party = function(m) mons = Wire.list(m.mons, MAX_PARTY, mon), seed = Wire.num(m.seed, nil, 0, MAX_INT), forceLevel = Wire.num(m.forceLevel, nil, 0, 65535), + ruleset = Wire.str(m.ruleset, nil, MAX_STRING), } end @@ -207,15 +210,24 @@ SCHEMAS.replace = function(m) end SCHEMAS.bye = function() return {} end -SCHEMAS.forfeit = function() return {} end +SCHEMAS.forfeit = function(m) + return { match = Wire.str(m.match, nil, MAX_NAME) } +end SCHEMAS.hosted = function(m) return { code = Wire.str(m.code, nil, MAX_NAME) } end SCHEMAS.paired = function() return {} end +SCHEMAS.ping = function(m) + return { t = Wire.num(m.t, 0, 0) } +end +SCHEMAS.pong = SCHEMAS.ping SCHEMAS.peer_gone = function() return {} end SCHEMAS.join_error = function(m) - return { reason = Wire.str(m.reason, "", MAX_STRING) } + return { reason = Wire.str(m.reason, "", MAX_STRING), + field = Wire.str(m.field, nil, MAX_STRING), + detail = Wire.str(m.detail, nil, MAX_STRING), + code = Wire.str(m.code, nil, MAX_NAME) } end local function rule(m) @@ -247,6 +259,7 @@ SCHEMAS.tournament_roster = function(m) local out = rule(m) out.players = Wire.list(m.players, MAX_MATCHES, name) out.spectators = Wire.list(m.spectators, MAX_MATCHES, name) + out.creator = name(m.creator) return out end @@ -256,6 +269,8 @@ local function match(v) a = name(v.a), b = name(v.b), winner = name(v.winner), bye = Wire.bool(v.bye, false), state = Wire.str(v.state, nil, MAX_STRING), + match = Wire.str(v.match, nil, MAX_NAME), + how = Wire.str(v.how, nil, MAX_STRING), } end @@ -279,24 +294,32 @@ SCHEMAS.bracket_update = function(m) end SCHEMAS.match_start = function(m) - return { + local out = { opponent = name(m.opponent), round = Wire.num(m.round, 0, 0, MAX_ROUNDS), turnLimit = Wire.num(m.turnLimit, nil, 0, 65535), role = Wire.str(m.role, "", MAX_STRING), + match = Wire.str(m.match, nil, MAX_NAME), } + arenaStart(m, out) + return out end SCHEMAS.match_start_spectate = function(m) - return { + local out = { round = Wire.num(m.round, 0, 0, MAX_ROUNDS), playerHost = name(m.playerHost), playerGuest = name(m.playerGuest), + match = Wire.str(m.match, nil, MAX_NAME), + role = Wire.str(m.role, nil, MAX_STRING), } + arenaStart(m, out) + return out end SCHEMAS.tournament_bye = function(m) - return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) } + return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS), + match = Wire.str(m.match, nil, MAX_NAME) } end SCHEMAS.tournament_over = function(m) @@ -315,6 +338,474 @@ SCHEMAS.spectate = function(m) return { side = Wire.str(m.side, "", MAX_STRING), msg = inner } end +local CodeEntry = require("src.link.CodeEntry") + +local MAX_DISPLAY_NAME = 16 +local MAX_TICKET = 128 +local MAX_SESSION = 64 +local MAX_PROFILES = 16 +local MAX_LOBBY_ENTRIES = 200 +local MAX_NOTE = 40 +local MAX_SPECTATORS = 64 +local MAX_REPLAY = 512 +local MAX_ROOM_PLAYERS = 4 +local MAX_ENTRY_PLAYERS = 64 +local MAX_TEAM = 6 +local MAX_DEADLINES = 8 + +local function displayName(v) + return Wire.str(v, nil, MAX_DISPLAY_NAME) +end + +local function codeStr(v) + if type(v) ~= "string" then return nil end + local s = v:upper() + if #s ~= CodeEntry.LENGTH then return nil end + for i = 1, #s do + if not CodeEntry.CHARSET:find(s:sub(i, i), 1, true) then return nil end + end + return s +end + +Wire.code = codeStr + +local function cartRef(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + version = Wire.str(v.version, nil, MAX_NAME), + hash = Wire.str(v.hash, nil, MAX_STRING), + } +end + +local function arenaRule(v) + if type(v) ~= "table" then v = {} end + return { + partySize = Wire.num(v.partySize, nil, 1, MAX_TEAM), + minLevel = Wire.num(v.minLevel, nil, 1, 100), + maxLevel = Wire.num(v.maxLevel, nil, 1, 100), + forceLevel = Wire.num(v.forceLevel, nil, 1, 100), + } +end + +local function profile(v) + if type(v) ~= "table" then return nil end + return { + engine = Wire.num(v.engine, nil, 1, 2), + version = Wire.str(v.version, nil, MAX_NAME), + engineVersion = Wire.str(v.engineVersion, nil, MAX_STRING), + apiVersion = Wire.num(v.apiVersion, nil, 0, MAX_INT), + fingerprint = Wire.str(v.fingerprint, nil, MAX_STRING), + rulesetId = Wire.str(v.rulesetId, nil, MAX_NAME), + kind = Wire.str(v.kind, nil, MAX_NAME), + cart = cartRef(v.cart), + rule = arenaRule(v.rule), + } +end + +Wire.profile = profile + +arenaStart = function(m, out) + out.seed = Wire.num(m.seed, nil, 0, MAX_INT) + out.ruleset = Wire.str(m.ruleset, nil, MAX_STRING) + out.rule = m.rule ~= nil and arenaRule(m.rule) or nil + out.code = codeStr(m.code) or out.code + out.peerName = displayName(m.peerName) + out.hostName = displayName(m.hostName) + out.guestName = displayName(m.guestName) + out.theirParty = m.theirParty ~= nil and Wire.list(m.theirParty, MAX_TEAM, mon) or nil + out.hostParty = m.hostParty ~= nil and Wire.list(m.hostParty, MAX_TEAM, mon) or nil + out.guestParty = m.guestParty ~= nil and Wire.list(m.guestParty, MAX_TEAM, mon) or nil + return out +end + +local function youEntry(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + name = displayName(v.name), + verified = Wire.bool(v.verified, false), + account = Wire.str(v.account, nil, MAX_NAME), + } +end + +local function lobbyEntry(v) + if type(v) ~= "table" then return nil end + local id = Wire.str(v.id, nil, MAX_NAME) + if not id then return nil end + return { + id = id, + name = displayName(v.name), + verified = Wire.bool(v.verified, false), + intent = Wire.str(v.intent, nil, MAX_NAME), + profile = profile(v.profile), + since = Wire.num(v.since, nil, 0), + note = Wire.str(v.note, nil, MAX_STRING), + code = codeStr(v.code), + open = Wire.bool(v.open, nil), + stage = Wire.str(v.stage, nil, MAX_NAME), + players = Wire.num(v.players, nil, 0, MAX_ENTRY_PLAYERS), + spectators = Wire.num(v.spectators, nil, 0, MAX_SPECTATORS), + maxSpectators = Wire.num(v.maxSpectators, nil, 0, MAX_SPECTATORS), + } +end + +local function playerEntry(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + name = displayName(v.name), + verified = Wire.bool(v.verified, false), + role = Wire.str(v.role, nil, MAX_NAME), + ready = Wire.bool(v.ready, false), + online = Wire.bool(v.online, true), + party = Wire.list(v.party, MAX_TEAM, mon), + partyDigest = Wire.str(v.partyDigest, nil, MAX_STRING), + } +end + +local function spectatorEntry(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + name = displayName(v.name), + verified = Wire.bool(v.verified, false), + } +end + +local function deadlineEntry(v) + if type(v) ~= "table" then return nil end + return { + kind = Wire.str(v.kind, nil, MAX_NAME), + at = Wire.num(v.at, nil, 0), + } +end + +local function innerMsg(v) + if type(v) ~= "table" then return nil end + return sanitize(v) +end + +local function replayEntry(v) + if type(v) ~= "table" then return nil end + local inner = innerMsg(v.msg) + if not inner then + inner = innerMsg(v) + if not inner then return nil end + return { seq = Wire.num(v.seq, nil, 0, MAX_INT), msg = inner } + end + return { + seq = Wire.num(v.seq, nil, 0, MAX_INT), + clientSeq = Wire.num(v.clientSeq, nil, 0, MAX_INT), + side = Wire.str(v.side, nil, MAX_NAME), + msg = inner, + } +end + +SCHEMAS.lobby_hello = function(m) + return { + ticket = Wire.str(m.ticket, nil, MAX_TICKET), + name = displayName(m.name), + engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING), + platform = Wire.str(m.platform, nil, MAX_NAME), + profiles = Wire.list(m.profiles, MAX_PROFILES, profile), + } +end + +SCHEMAS.lobby_welcome = function(m) + return { + session = Wire.str(m.session, nil, MAX_SESSION), + you = youEntry(m.you), + serverTime = Wire.num(m.serverTime, nil, 0), + heartbeatMs = Wire.num(m.heartbeatMs, nil, 0, 600000), + resumed = Wire.bool(m.resumed, false), + } +end + +SCHEMAS.resume = function(m) + return { + session = Wire.str(m.session, nil, MAX_SESSION), + ack = Wire.num(m.ack, 0, 0, MAX_INT), + } +end + +SCHEMAS.advertise = function(m) + return { + intent = Wire.str(m.intent, nil, MAX_NAME), + profile = profile(m.profile), + note = Wire.str(m.note, nil, MAX_STRING), + } +end + +SCHEMAS.unadvertise = function() return {} end + +SCHEMAS.room_create = function(m) + return { + intent = Wire.str(m.intent, nil, MAX_NAME), + profile = profile(m.profile), + playing = Wire.bool(m.playing, true), + maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS), + public = Wire.bool(m.public, true), + note = Wire.str(m.note, nil, MAX_NOTE), + } +end + +SCHEMAS.room_join = function(m) + return { + code = codeStr(m.code), + as = Wire.str(m.as, nil, MAX_NAME), + profile = profile(m.profile), + } +end + +SCHEMAS.room_leave = function() return {} end + +SCHEMAS.room_ready = function(m) + return { + party = Wire.list(m.party, MAX_TEAM, mon), + partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING), + } +end + +SCHEMAS.room_msg = function(m) + local inner = innerMsg(m.msg) + if not inner then return nil end + return { + seq = Wire.num(m.seq, 0, 0, MAX_INT), + clientSeq = Wire.num(m.clientSeq, nil, 0, MAX_INT), + side = Wire.str(m.side, nil, MAX_NAME), + msg = inner, + } +end + +SCHEMAS.room_report = function(m) + return { + match = Wire.str(m.match, nil, MAX_NAME), + result = Wire.str(m.result, nil, MAX_NAME), + } +end + +SCHEMAS.room_kick = function(m) + return { id = Wire.str(m.id, nil, MAX_NAME) } +end + +SCHEMAS.room_close = function() return {} end +SCHEMAS.lobby_query = function() return {} end + +SCHEMAS.room_ack = function(m) + return { seq = Wire.num(m.seq, 0, 0, MAX_INT) } +end + +SCHEMAS.lobby_list = function(m) + return { + entries = Wire.list(m.entries, MAX_LOBBY_ENTRIES, lobbyEntry), + online = Wire.num(m.online, nil, 0, MAX_INT), + } +end + +SCHEMAS.lobby_delta = function(m) + local ids = function(v) + return Wire.list(v, MAX_LOBBY_ENTRIES, function(x) + return Wire.str(x, nil, MAX_NAME) + end) + end + return { + added = Wire.list(m.added, MAX_LOBBY_ENTRIES, lobbyEntry), + changed = Wire.list(m.changed, MAX_LOBBY_ENTRIES, lobbyEntry), + removed = ids(m.removed), + add = Wire.list(m.add, MAX_LOBBY_ENTRIES, lobbyEntry), + update = Wire.list(m.update, MAX_LOBBY_ENTRIES, lobbyEntry), + remove = Wire.list(m.remove, MAX_LOBBY_ENTRIES, function(v) + return Wire.str(v, nil, MAX_NAME) + end), + op = Wire.str(m.op, nil, MAX_NAME), + entry = lobbyEntry(m.entry), + id = Wire.str(m.id, nil, MAX_NAME), + } +end + +SCHEMAS.room_state = function(m) + return { + code = codeStr(m.code), + players = Wire.list(m.players, MAX_ROOM_PLAYERS, playerEntry), + spectators = Wire.list(m.spectators, MAX_SPECTATORS, spectatorEntry), + stage = Wire.str(m.stage, nil, MAX_NAME), + profile = profile(m.profile), + host = Wire.str(m.host, nil, MAX_NAME), + seed = Wire.num(m.seed, nil, 0, MAX_INT), + intent = Wire.str(m.intent, nil, MAX_NAME), + maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS), + rule = m.rule ~= nil and arenaRule(m.rule) or nil, + match = Wire.str(m.match, nil, MAX_NAME), + deadlines = Wire.list(m.deadlines, MAX_DEADLINES, deadlineEntry), + } +end + +SCHEMAS.room_replay = function(m) + return { + from = Wire.num(m.from, 0, 0, MAX_INT), + yourSeq = Wire.num(m.yourSeq, nil, 0, MAX_INT), + msgs = Wire.list(m.msgs, MAX_REPLAY, replayEntry), + } +end + +SCHEMAS.room_deadline = function(m) + return { + kind = Wire.str(m.kind, nil, MAX_NAME), + at = Wire.num(m.at, nil, 0), + } +end + +SCHEMAS.room_result = function(m) + return { + match = Wire.str(m.match, nil, MAX_NAME), + code = codeStr(m.code), + winner = displayName(m.winner), + winnerId = Wire.str(m.winnerId, nil, MAX_NAME), + how = Wire.str(m.how, nil, MAX_NAME), + } +end + +SCHEMAS.room_closed = function(m) + return { + reason = Wire.str(m.reason, nil, MAX_NAME), + code = codeStr(m.code), + } +end + +local MAX_TOUR_PLAYERS = 64 +local MAX_TOUR_SPECTATORS = 64 +local MAX_TOUR_ROUNDS = 7 +local MAX_TOUR_MATCHES = 32 + +local TOUR_STAGES = { registering = true, running = true, finished = true } +local TOUR_MATCH_STATES = { pending = true, live = true, done = true, + bye = true } + +local function tourPlayerEntry(v) + if type(v) ~= "table" then return nil end + return { + id = Wire.str(v.id, nil, MAX_NAME), + name = displayName(v.name), + verified = Wire.bool(v.verified, false), + online = Wire.bool(v.online, true), + eliminated = Wire.bool(v.eliminated, false), + } +end + +local function tourMatchEntry(v) + if type(v) ~= "table" then return nil end + local state = Wire.str(v.state, nil, MAX_NAME) + return { + match = Wire.str(v.match, nil, MAX_NAME), + a = Wire.str(v.a, nil, MAX_NAME), + b = Wire.str(v.b, nil, MAX_NAME), + winner = Wire.str(v.winner, nil, MAX_NAME), + how = Wire.str(v.how, nil, MAX_NAME), + state = TOUR_MATCH_STATES[state or ""] and state or nil, + } +end + +local function tourRoundEntry(v) + if type(v) ~= "table" then return nil end + return { + round = Wire.num(v.round, 0, 0, MAX_TOUR_ROUNDS), + matches = Wire.list(v.matches, MAX_TOUR_MATCHES, tourMatchEntry), + } +end + +SCHEMAS.tour_create = function(m) + return { + profile = profile(m.profile), + rule = m.rule ~= nil and arenaRule(m.rule) or nil, + playing = Wire.bool(m.playing, true), + shotClock = Wire.num(m.shotClock, nil, 0, 3600), + maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS), + party = m.party ~= nil and Wire.list(m.party, MAX_TEAM, mon) or nil, + partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING), + public = Wire.bool(m.public, true), + note = Wire.str(m.note, nil, MAX_NOTE), + } +end + +SCHEMAS.tour_join = function(m) + return { + code = codeStr(m.code), + as = Wire.str(m.as, nil, MAX_NAME), + profile = profile(m.profile), + party = Wire.list(m.party, MAX_TEAM, mon), + partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING), + } +end + +SCHEMAS.tour_leave = function() return {} end +SCHEMAS.tour_start = function() return {} end +SCHEMAS.tour_close = function() return {} end + +SCHEMAS.tour_kick = function(m) + return { id = Wire.str(m.id, nil, MAX_NAME) } +end + +SCHEMAS.tour_state = function(m) + local stage = Wire.str(m.stage, nil, MAX_NAME) + return { + code = codeStr(m.code), + creator = Wire.str(m.creator, nil, MAX_NAME), + stage = TOUR_STAGES[stage or ""] and stage or "registering", + players = Wire.list(m.players, MAX_TOUR_PLAYERS, tourPlayerEntry), + spectators = Wire.list(m.spectators, MAX_TOUR_SPECTATORS, spectatorEntry), + profile = profile(m.profile), + rule = m.rule ~= nil and arenaRule(m.rule) or nil, + shotClock = Wire.num(m.shotClock, nil, 0, 3600), + round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS), + bracket = Wire.list(m.bracket, MAX_TOUR_ROUNDS, tourRoundEntry), + live = Wire.str(m.live, nil, MAX_NAME), + champion = Wire.str(m.champion, nil, MAX_NAME), + championId = Wire.str(m.championId, nil, MAX_NAME), + maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS), + } +end + +SCHEMAS.tour_match = function(m) + return { + match = Wire.str(m.match, nil, MAX_NAME), + round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS), + code = codeStr(m.code), + } +end + +SCHEMAS.tour_match_spectate = SCHEMAS.tour_match + +SCHEMAS.tour_bye = function(m) + return { + match = Wire.str(m.match, nil, MAX_NAME), + round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS), + } +end + +SCHEMAS.tour_deadline = function(m) + return { + kind = Wire.str(m.kind, nil, MAX_NAME), + at = Wire.num(m.at, nil, 0), + match = Wire.str(m.match, nil, MAX_NAME), + } +end + +SCHEMAS.tour_closed = function(m) + return { + code = codeStr(m.code), + reason = Wire.str(m.reason, nil, MAX_NAME), + } +end + +SCHEMAS.tour_over = function(m) + return { + code = codeStr(m.code), + champion = displayName(m.champion), + championId = Wire.str(m.championId, nil, MAX_NAME), + } +end + Wire.SCHEMAS = SCHEMAS local function passthrough(m) diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index 7bc4f150..b5c3ec08 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -1942,8 +1942,9 @@ COVERAGE["src.battle.BattleState"] = { .. "takes its native safari path when it finds one", openParty = "Gold's takes a `forced` argument Gen 1's does not; a wrap " .. "must forward ... faithfully rather than normalising it away", - wantsFillScale = "returns TRUE unconditionally on Gold, which reads as " - .. "\"the player chose FILL\" and is not a choice at all", + wantsFillScale = "reads OPTION -> BATTLE SIZE (#1709), same fixed/fill " + .. "key as Gen 1; Gold has no WIDE layout, so FILL is the scale of the " + .. "160x144 panel and nothing more", swapMoves = "no disabled-slot migration and no sfx on Gold", sides = "self.battle.sides is the same { index, battlers, screens, " .. "hazards, tokens } shape, with the same index-1-is-player rule", diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 627f7ce2..f73e3573 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -1230,8 +1230,9 @@ local ModProfile = require("src.mods.ModProfile") function LauncherMods.getProfiles(options) options = options or SaveData.loadOptions() - local manifests = discover() - ModProfile.ensureFirst(options, manifests, options.modOptions) + if not options.modProfilesSeeded then + ModProfile.ensureFirst(options, discover(), options.modOptions) + end return options.modProfiles or {}, options.activeProfile or "PROFILE 1" end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 86d7b796..82cb3b76 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -327,6 +327,13 @@ end function Loader:_loadState() self.disabled = {} + if self.arenaMode then + self.safeMode = false + Runtime.safeMode = false + self.gen2Forced = {} + self.modOptions = {} + return + end local options = SaveData.loadOptions(self.fs) self.safeMode = SaveData.isSafeMode(options) Runtime.safeMode = self.safeMode @@ -608,6 +615,88 @@ function Loader:cartStatus() return self.cartReport end +Loader.ARENA_MODES = { normal = true, disableAll = true, cartOnly = true } + +local function translationCandidate(manifest) + if type(manifest) ~= "table" then return false end + if manifest.language ~= true then return false end + if manifest.affects_link ~= false then return false end + return #(manifest.permissions or {}) == 0 +end + +function Loader:_arenaDisableAll() + for id, mod in pairs(self.mods) do + if not translationCandidate(mod.manifest) then self.disabled[id] = true end + end +end + +function Loader:_arenaVerifyTranslations() + local ok, Handshake = pcall(require, "src.link.Handshake") + if not ok or not Handshake or not Handshake.onlineBlockers then return end + local blocked = {} + for _, entry in ipairs(Handshake.onlineBlockers({ mods = self })) do + blocked[entry.id] = true + end + if next(blocked) == nil then return end + for id in pairs(blocked) do + local mod = self.mods[id] + if mod then + self:_rollback(id) + mod.enabled, mod.state = false, "disabled" + mod.skipReason = "not a verified translation" + self.disabled[id] = true + end + end + for i = #self.loaded, 1, -1 do + if blocked[self.loaded[i].manifest.id] then table.remove(self.loaded, i) end + end + for i = #self.order, 1, -1 do + if blocked[self.order[i]] then table.remove(self.order, i) end + end +end + +function Loader:_arenaCart() + local cartId = self.arenaCartId + if type(cartId) ~= "string" or cartId == "" then + return false, "no cart chosen" + end + local cart, err = self.cart + if not cart then cart, err = CartStore.get(cartId, self.fs) end + if not cart then + return false, tostring(err or "this cart is not installed") + end + local report = Loader.planCart(cart, self.mods, self.arenaSealBroken) + report.id = cartId + self.cartReport = report + if self.arenaSealBroken then return false, "this save's seal is broken" end + if cart.seal ~= "sealed" then + return false, ("%s is not a sealed cart"):format(cart.title or cartId) + end + if report.refused or not report.enforced then + return false, report.message or ("%s cannot be enforced"):format(cartId) + end + self.cartSwitches = {} + for id, mod in pairs(self.mods) do + local pin = report.pins[id] + if pin then + local on = CartManifest.modEnabled(pin) + mod.enabled, mod.state = on, on and "pending" or "disabled" + self.disabled[id] = not on or nil + else + mod.enabled, mod.state = false, "disabled" + self.disabled[id] = true + end + end + local merged = {} + for id, pin in pairs(report.pins) do + local bucket = {} + for key, value in pairs(pin.options or {}) do bucket[key] = value end + merged[id] = bucket + end + self.modOptions = merged + return true +end + function Loader:_applyCart() local cartId = SaveData.getCart() if not cartId or self.safeMode then return end @@ -1737,7 +1826,15 @@ local function stampAudioOwners(data, name, registry) owners[key] = map end -function Loader:load(data) +function Loader:load(data, opts) + opts = opts or {} + local mode = opts.mode or "normal" + if not Loader.ARENA_MODES[mode] then + return false, ("unknown loader mode %q"):format(tostring(mode)) + end + self.arenaMode = mode ~= "normal" and mode or nil + self.arenaCartId = mode == "cartOnly" and opts.cartId or nil + self.arenaSealBroken = opts.sealBroken == true self.baseData = data -- every registry folds against the pristine view of its Data target; -- resolution is lazy so optional namespaces may appear later @@ -1760,11 +1857,17 @@ function Loader:load(data) if self.safeMode then for id in pairs(self.mods) do self.disabled[id] = true end end + if self.arenaMode == "disableAll" then + self:_arenaDisableAll() + elseif self.arenaMode == "cartOnly" then + local ok, reason = self:_arenaCart() + if not ok then return false, reason end + end -- Existing installs stored one shared answer. Once their manifests are -- known, split that answer across every game before the next launcher/game -- toggle can change one independently. _loadState already used the same -- fallback, so this write cannot change the current boot's result. - do + if not self.arenaMode then local options = SaveData.loadOptions(self.fs) local installed = {} for id, mod in pairs(self.mods) do @@ -1779,7 +1882,7 @@ function Loader:load(data) end -- Experimental mods stay off until the player opts in: a missing -- options.mods entry normally means enabled, but experimental flips that. - do + if not self.arenaMode then local options = SaveData.loadOptions(self.fs) local scope = self:_enableScope() for id, mod in pairs(self.mods) do @@ -1795,7 +1898,8 @@ function Loader:load(data) -- the one build where its env var is set. for id, mod in pairs(self.mods) do local envName = mod.manifest.force_enable_env - if not self.safeMode and envName and os.getenv(envName) == "1" then + if not self.safeMode and not self.arenaMode and envName + and os.getenv(envName) == "1" then self.disabled[id] = nil end end @@ -1803,7 +1907,7 @@ function Loader:load(data) mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" end - self:_applyCart() + if not self.arenaMode then self:_applyCart() end -- engine call sites reach these buses -- and this error feed, for failures -- that only surface at play time -- through Runtime from here on Runtime.install(self.events, self.hooks, self.errors) @@ -1856,6 +1960,7 @@ function Loader:load(data) -- the commands registry is final once every entry chunk has run, so -- each map_scripts contribution's rows can be judged before they merge self:_validateScripts() + if self.arenaMode == "disableAll" then self:_arenaVerifyTranslations() end -- merge: fold every touched id from its pristine base value and write it -- home, creating the Data namespace when the base modules never shipped -- one. A registry nobody wrote to -- engine included -- is skipped, so @@ -1977,7 +2082,8 @@ function Loader:status() table.sort(available, function(a, b) return a.id < b.id end) table.sort(loaded, function(a, b) return a.id < b.id end) return { available = available, loaded = loaded, errors = self.errors, - order = self.order, cart = self.cartReport } + order = self.order, cart = self.cartReport, + arenaMode = self.arenaMode or "normal" } end return Loader diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 78618e49..ae7a9ed4 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -880,6 +880,42 @@ function ModIndex.fetchText(url) return ModIndex.httpGet(url) end +function ModIndex.beginFetchText(url) + local h = { url = url, stage = "start" } + if type(url) ~= "string" or url == "" then + h.stage, h.err = "done", "no description" + end + return h +end + +function ModIndex.pumpFetchText(h) + if not h then return true, nil, "no handle" end + if h.stage == "done" then return true, h.body, h.err end + local Fetch = require("src.net.Fetch") + if h.stage == "start" then + h.job = Fetch.get(h.url, { userAgent = "gen1recomp-mod-index" }) + h.stage = "fetching" + return false + end + local st = Fetch.poll(h.job) + if st.status == "pending" then return false end + Fetch.release(h.job) + h.job, h.stage = nil, "done" + if st.status == "ok" and type(st.body) == "string" and st.body ~= "" then + h.body = st.body + return true, h.body + end + h.err = st.err or "description fetch failed" + return true, nil, h.err +end + +function ModIndex.cancelFetchText(h) + if not h or not h.job then return end + local Fetch = require("src.net.Fetch") + pcall(Fetch.cancel, h.job) + h.job, h.stage = nil, "done" +end + -- Download a thumbnail into the save directory and return the love.filesystem -- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with -- a non-empty-file check -- nothing in it is zip-specific. diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 1861aae7..858083e9 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -2045,7 +2045,8 @@ R.field = { cursorOrder = f.opt(f.list(f.str)), locations = f.opt(f.map(f.str, f.rec{ x = f.int(0), y = f.int(0), name = f.opt(f.str) })), - nest = f.opt(f.any) }, + nest = f.opt(f.any), + upArrow = f.opt(f.any) }, flyOrder = f.list(f.str), -- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle -- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of diff --git a/src/online/ArenaBoot.lua b/src/online/ArenaBoot.lua new file mode 100644 index 00000000..2f39430b --- /dev/null +++ b/src/online/ArenaBoot.lua @@ -0,0 +1,250 @@ +local Protocol = require("src.link.Protocol") + +local ArenaBoot = {} + +local VERSIONS = { + red = true, blue = true, yellow = true, + gold = true, silver = true, crystal = true, +} + +local ROLES = { host = true, guest = true, spectator = true } + +local KINDS = { vanilla = true, cart = true } + +local function isCallable(v) + if type(v) == "function" then return true end + if type(v) ~= "table" then return false end + local mt = getmetatable(v) + return type(mt) == "table" and type(mt.__call) == "function" +end + +local function hasMethod(obj, name) + if type(obj) ~= "table" then return false end + return isCallable(obj[name]) +end + +local function isPackedParty(v) + if type(v) ~= "table" then return false end + for _, mon in ipairs(v) do + if type(mon) ~= "table" or type(mon.species) ~= "string" then return false end + end + return true +end + +local function level(v) + return type(v) == "number" and v >= 1 and v <= 100 and math.floor(v) == v +end + +local function normaliseRule(rule) + if rule == nil then rule = {} end + if type(rule) ~= "table" then return nil, "rule must be a table" end + local size = rule.partySize + if size == nil then size = 6 end + if type(size) ~= "number" or size < 1 or size > 6 or math.floor(size) ~= size then + return nil, "rule.partySize must be 1..6" + end + if rule.minLevel ~= nil and not level(rule.minLevel) then + return nil, "rule.minLevel must be 1..100" + end + if rule.maxLevel ~= nil and not level(rule.maxLevel) then + return nil, "rule.maxLevel must be 1..100" + end + if rule.minLevel and rule.maxLevel and rule.minLevel > rule.maxLevel then + return nil, "rule.minLevel is above rule.maxLevel" + end + if rule.forceLevel ~= nil and not level(rule.forceLevel) then + return nil, "rule.forceLevel must be 1..100" + end + return { + partySize = size, + minLevel = rule.minLevel, + maxLevel = rule.maxLevel, + forceLevel = rule.forceLevel, + } +end + +function ArenaBoot.profile(fields) + if type(fields) ~= "table" then return nil, "profile must be a table" end + local engine = fields.engine + if engine ~= 1 and engine ~= 2 then return nil, "profile.engine must be 1 or 2" end + if type(fields.version) ~= "string" or not VERSIONS[fields.version] then + return nil, "profile.version is not a known game" + end + local kind = fields.kind or "vanilla" + if not KINDS[kind] then return nil, "profile.kind must be vanilla or cart" end + if kind == "cart" then + local cart = fields.cart + if type(cart) ~= "table" or type(cart.id) ~= "string" or cart.id == "" + or type(cart.hash) ~= "string" or cart.hash == "" then + return nil, "a cart profile needs cart.id and cart.hash" + end + end + if fields.engineVersion ~= nil and type(fields.engineVersion) ~= "string" then + return nil, "profile.engineVersion must be a string" + end + if fields.rulesetId ~= nil and type(fields.rulesetId) ~= "string" then + return nil, "profile.rulesetId must be a string" + end + local rule, ruleErr = normaliseRule(fields.rule) + if not rule then return nil, ruleErr end + local cart = nil + if kind == "cart" then + cart = { + id = fields.cart.id, + version = fields.cart.version, + hash = fields.cart.hash, + } + end + return { + engine = engine, + version = fields.version, + engineVersion = fields.engineVersion, + apiVersion = fields.apiVersion, + fingerprint = fields.fingerprint, + rulesetId = fields.rulesetId, + kind = kind, + cart = cart, + rule = rule, + } +end + +local function normaliseTeam(team, size) + if team == nil then return nil end + if type(team) ~= "table" then return nil, "team must be an array of party indices" end + local seen, out = {}, {} + for _, index in ipairs(team) do + if type(index) ~= "number" or index < 1 or index > 6 or math.floor(index) ~= index then + return nil, "team holds a party index outside 1..6" + end + if seen[index] then return nil, "team repeats a party index" end + seen[index] = true + out[#out + 1] = index + end + if #out == 0 then return nil, "team is empty" end + if #out > size then return nil, "team is longer than the rule allows" end + return out +end + +function ArenaBoot.spec(fields) + if type(fields) ~= "table" then return nil, "spec must be a table" end + local profile, profileErr = ArenaBoot.profile(fields.profile) + if not profile then return nil, profileErr end + + local role = fields.role + if type(role) ~= "string" or not ROLES[role] then + return nil, "role must be host, guest or spectator" + end + + local spectating = role == "spectator" + if not spectating then + if type(fields.slotId) ~= "string" or fields.slotId == "" then + return nil, "slotId is required" + end + elseif fields.slotId ~= nil and type(fields.slotId) ~= "string" then + return nil, "slotId must be a string" + end + + local team, teamErr = normaliseTeam(fields.team, profile.rule.partySize) + if fields.team ~= nil and not team then return nil, teamErr end + + if type(fields.seed) ~= "number" then return nil, "seed must be a number" end + + local session = fields.session + if not (hasMethod(session, "send") and hasMethod(session, "poll") + and hasMethod(session, "close")) then + return nil, "session must provide send, poll and close" + end + + if fields.onDone ~= nil and not isCallable(fields.onDone) then + return nil, "onDone must be a function" + end + + if spectating then + if not isPackedParty(fields.hostParty) or not isPackedParty(fields.guestParty) then + return nil, "a spectator spec needs hostParty and guestParty" + end + else + if not isPackedParty(fields.theirParty) then + return nil, "theirParty must be a packed party" + end + if fields.myParty ~= nil and not isPackedParty(fields.myParty) then + return nil, "myParty must be a packed party" + end + end + + local onDone = fields.onDone + return { + profile = profile, + role = role, + slotId = fields.slotId, + team = team, + seed = fields.seed, + peerName = fields.peerName or "FOE", + hostName = fields.hostName or "HOST", + guestName = fields.guestName or "GUEST", + myParty = fields.myParty, + theirParty = fields.theirParty, + hostParty = fields.hostParty, + guestParty = fields.guestParty, + session = session, + onDone = function(result) + if onDone then onDone(result) end + end, + } +end + +function ArenaBoot.battleOpts(spec) + if type(spec) ~= "table" or type(spec.profile) ~= "table" then + return nil, "spec must carry a profile" + end + local rule = spec.profile.rule or {} + if spec.role == "spectator" then + return { + hostParty = spec.hostParty, + guestParty = spec.guestParty, + hostName = spec.hostName, + guestName = spec.guestName, + seed = spec.seed, + ruleset = spec.profile.rulesetId, + verdict = "full", + strict = true, + forceLevel = rule.forceLevel, + keepNetOpen = true, + } + end + return { + myParty = spec.myParty, + theirParty = spec.theirParty, + theirName = spec.peerName, + role = spec.role, + seed = spec.seed, + ruleset = spec.profile.rulesetId, + verdict = "full", + strict = true, + forceLevel = rule.forceLevel, + keepNetOpen = true, + } +end + +function ArenaBoot.packOwnParty(game, spec) + if type(spec) ~= "table" then return nil, "spec must be a table" end + if spec.role == "spectator" then return nil end + if isPackedParty(spec.myParty) and #spec.myParty > 0 then return spec.myParty end + local party = game and game.save and game.save.party + if type(party) ~= "table" then return nil, "no party to send" end + local size = (spec.profile and spec.profile.rule and spec.profile.rule.partySize) or 6 + local indices = {} + for _, index in ipairs(spec.team or {}) do + if party[index] then indices[#indices + 1] = index end + end + if #indices == 0 then + for index = 1, math.min(#party, size) do indices[index] = index end + end + if #indices == 0 then return nil, "no party to send" end + spec.myParty = (spec.profile and spec.profile.engine == 2) + and Protocol.packParty2(party, indices) + or Protocol.packParty(party, indices) + return spec.myParty +end + +return ArenaBoot diff --git a/src/online/ArenaData.lua b/src/online/ArenaData.lua new file mode 100644 index 00000000..7179b9b0 --- /dev/null +++ b/src/online/ArenaData.lua @@ -0,0 +1,260 @@ +local CacheFs = require("src.import.CacheFs") +local CartStore = require("src.carts.CartStore") +local Fingerprint = require("src.link.Fingerprint") +local GameVersion = require("src.core.GameVersion") +local Handshake = require("src.link.Handshake") +local Loader = require("src.mods.Loader") +local Runtime = require("src.mods.Runtime") +local SaveData = require("src.core.SaveData") +local Version = require("src.core.Version") + +local ArenaData = {} + +local rulesetMemo = {} + +local FIELDS = { + { key = "engine", text = "engine differs" }, + { key = "version", text = "game differs" }, + { key = "engineVersion", text = "engine version differs" }, + { key = "apiVersion", text = "mod api differs" }, + { key = "kind", text = "arena kind differs" }, + { key = "rulesetId", text = "ruleset differs" }, + { key = "fingerprint", text = "data differs" }, +} + +local CART_FIELDS = { + { key = "id", text = "cart differs" }, + { key = "version", text = "cart version differs" }, + { key = "hash", text = "cart hash differs" }, +} + +function ArenaData.cacheKey(version, kind, cartHash) + return tostring(version) .. "|" .. tostring(kind) .. "|" + .. (cartHash or "-") .. "|" .. tostring(Version.engine) +end + +local function copyRule(rule) + rule = type(rule) == "table" and rule or {} + local size = tonumber(rule.partySize) or 3 + size = math.max(1, math.min(6, math.floor(size))) + return { + partySize = size, + minLevel = tonumber(rule.minLevel) or nil, + maxLevel = tonumber(rule.maxLevel) or nil, + forceLevel = tonumber(rule.forceLevel) or nil, + } +end + +local function copyProfile(entry, rule) + local out = { + engine = entry.engine, + version = entry.version, + engineVersion = entry.engineVersion, + apiVersion = entry.apiVersion, + fingerprint = entry.fingerprint, + rulesetId = entry.rulesetId, + kind = entry.kind, + rule = copyRule(rule), + } + if type(entry.cart) == "table" then + out.cart = { id = entry.cart.id, version = entry.cart.version, + hash = entry.cart.hash } + end + return out +end + +function ArenaData.equal(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + for _, field in ipairs(FIELDS) do + if a[field.key] ~= b[field.key] then return false end + end + local ca, cb = a.cart, b.cart + if (ca == nil) ~= (cb == nil) then return false end + if ca then + for _, field in ipairs(CART_FIELDS) do + if ca[field.key] ~= cb[field.key] then return false end + end + end + return true +end + +function ArenaData.describeMismatch(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return "no profile" end + for _, field in ipairs(FIELDS) do + if a[field.key] ~= b[field.key] then return field.text end + end + local ca, cb = a.cart, b.cart + if (ca == nil) ~= (cb == nil) then return "cart differs" end + if ca then + for _, field in ipairs(CART_FIELDS) do + if ca[field.key] ~= cb[field.key] then return field.text end + end + end + return nil +end + +local function cachedProfiles() + local options = SaveData.loadOptions() + local bucket = options.arenaProfiles + return type(bucket) == "table" and bucket or {}, options +end + +local function storeProfile(key, entry) + local bucket = cachedProfiles() + bucket[key] = entry + pcall(SaveData.saveOptions, { arenaProfiles = bucket }) +end + +function ArenaData.forget(version) + local bucket = cachedProfiles() + local prefix = version and (tostring(version) .. "|") or nil + for key in pairs(bucket) do + if not prefix or key:sub(1, #prefix) == prefix then bucket[key] = nil end + end + pcall(SaveData.saveOptions, { arenaProfiles = bucket }) + if version then rulesetMemo[version] = nil else rulesetMemo = {} end +end + +local function gen2Dataset() + local data = {} + local function load(name) + return CacheFs.loadActive("data/generated/" .. name .. ".lua") + end + data.pokemon = load("pokemon") or {} + data.items = load("items") or {} + data.moves = load("moves") or {} + data.type_chart = load("type_chart") or {} + data.gen2Constants = load("constants") + data.text = load("rom_text") or {} + data.font = load("font") + local chart = data.type_chart + chart.matchups = chart.matchups or {} + for _, row in ipairs(chart.foresightMatchups or {}) do + chart.matchups[#chart.matchups + 1] = row + end + local ItemEffects = require("src.core.gen2.ItemEffects") + data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items) + return data, ItemEffects.heldSnapshot(data.gen2HeldItems) +end + +local function loadDataset(version, generation) + if generation == 2 then + local data, held = gen2Dataset() + return data, function() + require("src.core.gen2.ItemEffects").applyHeldItems(data, held) + end + end + local Data = require("src.core.Data") + Data:load() + return Data, nil +end + +local function busy() + return require("src.online.Trade").gameIsLive() +end + +local function compute(version, kind, cartId, cart, cartHash) + local generation = GameVersion.generation(version) + local prevVersion = GameVersion.get() + local prevPrefix = CacheFs.prefix + local saved = { events = Runtime.events, hooks = Runtime.hooks, + errors = Runtime.errors, safeMode = Runtime.safeMode } + + local function restore() + pcall(function() require("src.core.Data"):unloadGenerated() end) + pcall(CacheFs.unmountVersion, version) + if Loader.endSession then pcall(Loader.endSession) end + if Runtime.reset then pcall(Runtime.reset) end + Runtime.events, Runtime.hooks = saved.events, saved.hooks + Runtime.errors, Runtime.safeMode = saved.errors, saved.safeMode + Runtime.currentMod = nil + GameVersion.set(prevVersion) + CacheFs.prefix = prevPrefix + end + + local ok, result = pcall(function() + GameVersion.set(version) + CacheFs.prefix = GameVersion.cachePrefix(version) + CacheFs.mountVersion(version) + if not CacheFs.readActive("data/generated/pokemon.lua") then + return { error = ("%s is not imported"):format(version) } + end + local data, after = loadDataset(version, generation) + local loader = Loader.new({ generation = generation, cart = cart }) + local opts = kind == "cart" + and { mode = "cartOnly", cartId = cartId } + or { mode = "disableAll" } + local loaded, reason = loader:load(data, opts) + if loaded == false and reason then return { error = reason } end + if after then after() end + local mods = Handshake.mods({ mods = loader }) + local rulesetId = "gen2" + if generation ~= 2 then + rulesetId = Handshake.ruleset({ data = data }) + local ids = {} + for id in pairs(data.rulesets or {}) do ids[#ids + 1] = id end + table.sort(ids) + rulesetMemo[version] = ids + else + rulesetMemo[version] = { "gen2" } + end + return { + engine = generation, + version = version, + engineVersion = Version.engine, + apiVersion = Handshake.apiVersion or Version.modApi, + fingerprint = Fingerprint.compute(data, mods, generation), + rulesetId = rulesetId, + kind = kind, + cart = cart and { id = cartId, version = cart.version, hash = cartHash } + or nil, + } + end) + + restore() + if not ok then return nil, tostring(result) end + if type(result) ~= "table" then return nil, "could not read that game" end + if result.error then return nil, result.error end + return result +end + +function ArenaData.profile(version, kind, cartId, rule) + kind = kind or "vanilla" + if not GameVersion.VERSIONS[version] then return nil, "unknown game" end + if kind ~= "vanilla" and kind ~= "cart" then return nil, "unknown arena kind" end + + local cart, cartHash + if kind == "cart" then + local got, hash = CartStore.get(cartId) + if not got then return nil, tostring(hash or "this cart is not installed") end + cart, cartHash = got, hash + if cart.base ~= version then return nil, "that cart is for another game" end + if cart.seal ~= "sealed" then return nil, "that cart is not sealed" end + end + + local key = ArenaData.cacheKey(version, kind, cartHash) + local bucket = cachedProfiles() + local hit = bucket[key] + if type(hit) == "table" and hit.fingerprint then + return copyProfile(hit, rule) + end + + if busy() then return nil, "close the game first" end + local entry, reason = compute(version, kind, cartId, cart, cartHash) + if not entry then return nil, reason end + storeProfile(key, entry) + return copyProfile(entry, rule) +end + +function ArenaData.rulesetIds(version) + if rulesetMemo[version] then return rulesetMemo[version] end + if GameVersion.generation(version) == 2 then + rulesetMemo[version] = { "gen2" } + return rulesetMemo[version] + end + local _, reason = ArenaData.profile(version, "vanilla", nil, nil) + if rulesetMemo[version] then return rulesetMemo[version] end + return { Handshake.DEFAULT_RULESET }, reason +end + +return ArenaData diff --git a/src/online/Client.lua b/src/online/Client.lua new file mode 100644 index 00000000..cf4199d4 --- /dev/null +++ b/src/online/Client.lua @@ -0,0 +1,1019 @@ +local Protocol2 = require("src.online.Protocol2") +local Session = require("src.link.Session") +local Version = require("src.core.Version") +local Wire = require("src.link.Wire") + +local Client = {} + +local BACKOFF = { 1, 2, 4, 8, 15 } +local MAX_ATTEMPTS = 12 +local MATCH_STAGES = { battling = true } +local ROOM_STAGES = { waiting = true, ready = true, battling = true, + ended = true } +local UNACKED_MAX = 512 + +local function now() + if love and love.timer and love.timer.getTime then + return love.timer.getTime() + end + return os.clock() +end + +local S +local clearRoom +local clearTournament +local resumeLost + +local function blankState() + return { + relayAddress = nil, + connectFn = nil, + platform = nil, + engineVersion = Version.engine, + status = "offline", + err = nil, + opts = nil, + net = nil, + session = nil, + sessionId = nil, + you = nil, + heartbeatMs = nil, + serverTime = nil, + serverTimeAt = nil, + lobby = {}, + lobbyById = {}, + room = nil, + roomSession = nil, + pending = nil, + seq = 0, + ack = 0, + rxSeq = 0, + unacked = {}, + unackedFloor = 0, + unackedDropped = 0, + pendingReport = nil, + reportSent = nil, + roomInbox = {}, + delivered = {}, + matchStarted = false, + match = nil, + role = nil, + resuming = false, + attempt = 0, + retryAt = nil, + handlers = {}, + dropped = 0, + duplicates = 0, + advertised = nil, + online = nil, + tournament = nil, + tourMatch = nil, + tourFinished = {}, + } +end + +S = blankState() + +function Client.reset() + if S.session then pcall(function() S.session:close() end) end + S = blankState() +end + +-- ---------------------------------------------------------------- events + +function Client.on(event, fn) + if type(event) ~= "string" or type(fn) ~= "function" then return end + local list = S.handlers[event] + if not list then + list = {} + S.handlers[event] = list + end + list[#list + 1] = fn +end + +function Client.off(event, fn) + local list = S.handlers[event] + if not list then return end + for i = #list, 1, -1 do + if list[i] == fn or fn == nil then table.remove(list, i) end + end +end + +local function emit(event, payload) + local list = S.handlers[event] + if not list then return end + for i = 1, #list do + local ok, err = pcall(list[i], payload) + if not ok then + S.err = tostring(err) + end + end +end + +local function setStatus(status) + if S.status == status then return end + S.status = status + emit("state", status) +end + +-- ---------------------------------------------------------------- accessors + +function Client.state() return S.status end +function Client.match() return S.match end + +function Client.serverTime() + if not S.serverTime then return nil end + return S.serverTime + (now() - (S.serverTimeAt or now())) * 1000 +end + +function Client.role() return S.role end +function Client.error() return S.err end +function Client.you() return S.you end +function Client.lobby() return S.lobby end +function Client.room() return S.room end +function Client.tournament() return S.tournament end +function Client.tourMatch() return S.tourMatch end +function Client.dropped() return S.dropped end +function Client.unackedDropped() return S.unackedDropped end +function Client.unackedCount() return #S.unacked end +function Client.pendingReport() return S.pendingReport end +function Client.duplicates() return S.duplicates end +function Client.sessionId() return S.sessionId end + +-- ---------------------------------------------------------------- transport + +local function openTransport() + if S.connectFn then + local transport, err = S.connectFn(S.relayAddress) + if not transport then return nil, err or "no transport" end + return transport + end + local Net = require("src.link.Net") + local net = Net.new() + local address = S.relayAddress or Net.defaultRelayAddress() + if not net:connectTCP(address) then + return nil, net.error or ("can't reach the relay at " .. tostring(address)) + end + net.mode = "onlineLobby" + net.v2 = true + return net +end + +local function sendRaw(msg) + if not msg or not S.session then return false end + S.session:send(msg) + return true +end + +Client.sendRaw = sendRaw + +-- ---------------------------------------------------------------- room session + +local RoomSession = {} +RoomSession.__index = RoomSession + +local function refreshRoomSession() + local rs = S.roomSession + if not rs then return end + local room = S.room + local players = room and room.players or {} + rs.paired = room ~= nil and #players >= 2 + rs.error = S.err + if not room and not rs.left then rs.closed = true end + if S.status == "error" or S.status == "offline" then rs.closed = true end +end + +local function shaped(entry) + if not entry.side then return entry.msg end + if not entry.shape then + entry.shape = { type = "spectate", side = entry.side, msg = entry.msg } + end + return entry.shape +end + +local function deliver(entries) + local out = {} + for i = 1, #entries do + out[i] = shaped(entries[i]) + local seq = entries[i].seq + if type(seq) == "number" and seq > S.ack then S.delivered[seq] = true end + end + local before = S.ack + while S.delivered[S.ack + 1] do + S.delivered[S.ack + 1] = nil + S.ack = S.ack + 1 + end + if S.ack > before then sendRaw(Protocol2.roomAck(S.ack)) end + return out +end + +function RoomSession:update() + Client.update(0) +end + +function RoomSession:send(msg) + if type(msg) ~= "table" then return end + if self.closed then return end + S.seq = S.seq + 1 + local wrapped = Protocol2.roomMsg(S.seq, msg) + if not wrapped then return end + S.unacked[#S.unacked + 1] = wrapped + while #S.unacked > UNACKED_MAX do + local oldest = table.remove(S.unacked, 1) + S.unackedDropped = S.unackedDropped + 1 + S.unackedFloor = (oldest.seq or 0) + 1 + end + sendRaw(wrapped) +end + +function RoomSession:poll() + local entries = S.roomInbox + S.roomInbox = {} + return deliver(entries) +end + +function RoomSession:pollOne() + if #S.roomInbox == 0 then return nil end + local entry = table.remove(S.roomInbox, 1) + return deliver({ entry })[1] +end + +function RoomSession:take(messageType, predicate) + for index = 1, #S.roomInbox do + local entry = S.roomInbox[index] + local msg = shaped(entry) + if msg.type == messageType + and (predicate == nil or predicate(msg) == true) then + table.remove(S.roomInbox, index) + return deliver({ entry })[1] + end + end + return nil +end + +function RoomSession:unread(messages) + if type(messages) ~= "table" then return end + for index = #messages, 1, -1 do + local msg = messages[index] + if type(msg) == "table" and type(msg.type) == "string" then + table.insert(S.roomInbox, 1, { seq = nil, msg = msg }) + end + end +end + +function RoomSession:hasPending() return #S.roomInbox > 0 end + +function RoomSession:close() + if self.left then return end + self.left = true + self.closed = true + sendRaw(Protocol2.roomLeave()) + clearRoom() +end + +function Client.roomSession() + if not S.room then return nil end + if not S.roomSession or S.roomSession.left then + S.roomSession = setmetatable({ + paired = false, closed = false, error = nil, left = false, + code = S.room.code, target = S.room.code, + }, RoomSession) + end + refreshRoomSession() + return S.roomSession +end + +-- ---------------------------------------------------------------- lobby model + +local function lobbyLess(a, b) + local sa, sb = a.since or 0, b.since or 0 + if sa ~= sb then return sa < sb end + return tostring(a.id) < tostring(b.id) +end + +local function sortLobby() + table.sort(S.lobby, lobbyLess) +end + +local function putEntry(entry) + if not entry or not entry.id then return end + local existing = S.lobbyById[entry.id] + if existing then + for i = 1, #S.lobby do + if S.lobby[i].id == entry.id then S.lobby[i] = entry end + end + else + S.lobby[#S.lobby + 1] = entry + end + S.lobbyById[entry.id] = entry +end + +local function dropEntry(id) + if not id or not S.lobbyById[id] then return end + S.lobbyById[id] = nil + for i = #S.lobby, 1, -1 do + if S.lobby[i].id == id then table.remove(S.lobby, i) end + end +end + +function Client.openRooms() + local mine = S.you and S.you.id or nil + local out = {} + for i = 1, #S.lobby do + local entry = S.lobby[i] + if entry.code and entry.open == true and entry.id ~= mine then + out[#out + 1] = entry + end + end + table.sort(out, lobbyLess) + return out +end + +function Client.watchable() + local mine = S.you and S.you.id or nil + local out = {} + for i = 1, #S.lobby do + local entry = S.lobby[i] + if entry.code and entry.id ~= mine then + local room = entry.stage == "battling" + and (entry.spectators or 0) < (entry.maxSpectators or 0) + if room or entry.intent == "tournament" then out[#out + 1] = entry end + end + end + table.sort(out, lobbyLess) + return out +end + +function Client.counts() + local seen, players = {}, 0 + for i = 1, #S.lobby do + local id = S.lobby[i].id + if id and not seen[id] then + seen[id] = true + players = players + 1 + end + end + if type(S.online) == "number" then players = S.online end + return { players = players, openRooms = #Client.openRooms() } +end + +-- ---------------------------------------------------------------- room model + +local function seats(room) + local players = room and room.players or {} + return players[1], players[2] +end + +local function myRole(room) + local me = S.you and S.you.id + local host, guest = seats(room) + if me then + if host and host.id == me then return "host" end + if guest and guest.id == me then return "guest" end + end + return "spectator" +end + +local function applyRoomState(msg) + local previous = S.room + local deadlines = {} + for _, d in ipairs(msg.deadlines or {}) do + if d.kind then deadlines[d.kind] = d.at end + end + if previous and previous.code == msg.code then + for kind, at in pairs(previous.deadlines or {}) do + if deadlines[kind] == nil then deadlines[kind] = at end + end + end + if not previous or previous.code ~= msg.code then + S.seq, S.ack, S.rxSeq = 0, 0, 0 + S.unacked = {} + S.unackedFloor = 0 + S.delivered = {} + S.roomInbox = {} + S.matchStarted = false + S.pendingReport = nil + end + S.room = { + code = msg.code, + players = msg.players or {}, + spectators = msg.spectators or {}, + stage = ROOM_STAGES[msg.stage or ""] and msg.stage or "waiting", + profile = msg.profile, + host = msg.host, + seed = msg.seed, + rule = msg.rule or (msg.profile and msg.profile.rule) or nil, + intent = msg.intent, + maxSpectators = msg.maxSpectators, + match = msg.match, + deadlines = deadlines, + } + S.match = msg.match or S.match + S.role = myRole(S.room) + if S.roomSession and S.roomSession.left then S.roomSession = nil end + if S.pending and S.pending.kind ~= "tournament" + and (S.pending.code == nil or S.pending.code == msg.code) then + S.pending.code = msg.code + S.pending.room = S.room + S.pending.done = true + end + if not MATCH_STAGES[S.room.stage] then S.matchStarted = false end + emit("room", S.room) + refreshRoomSession() +end + +clearRoom = function() + S.room = nil + S.match = nil + S.role = nil + S.matchStarted = false + S.seq, S.ack, S.rxSeq = 0, 0, 0 + S.unacked = {} + S.unackedFloor = 0 + S.delivered = {} + S.roomInbox = {} + S.pendingReport = nil + if S.roomSession then + S.roomSession.closed = true + S.roomSession.left = true + S.roomSession = nil + end +end + +clearTournament = function() + S.pendingReport = nil + S.tournament = nil + S.tourMatch = nil + S.tourFinished = {} +end + +resumeLost = function() + local code = S.room and S.room.code or nil + sendRaw(Protocol2.roomLeave()) + clearRoom() + emit("room", nil) + emit("error", { scope = "room", reason = "resume_incomplete", code = code, + text = Protocol2.roomClosedText({ + reason = "resume_incomplete" }) }) +end + +local function enterChildRoom(code, msg) + if not code then return end + if S.room and S.room.code == code then + S.room.match = msg and msg.match or S.room.match + return + end + clearRoom() + local tour = S.tournament + S.room = { + code = code, + players = {}, + spectators = {}, + stage = "waiting", + profile = tour and tour.profile or nil, + rule = tour and tour.rule or nil, + host = nil, + seed = nil, + intent = "tournament", + match = msg and msg.match or nil, + deadlines = {}, + } + S.match = S.room.match + emit("room", S.room) + refreshRoomSession() +end + +local function applyTourState(msg) + local previous = S.tournament + local deadlines = {} + if previous and previous.code == msg.code then + for kind, at in pairs(previous.deadlines or {}) do deadlines[kind] = at end + end + if not previous or previous.code ~= msg.code then S.tourFinished = {} end + S.tournament = { + code = msg.code, + creator = msg.creator, + stage = msg.stage or "registering", + players = msg.players or {}, + spectators = msg.spectators or {}, + profile = msg.profile, + rule = msg.rule or (msg.profile and msg.profile.rule) or nil, + shotClock = msg.shotClock, + round = msg.round, + bracket = msg.bracket or {}, + live = msg.live, + champion = msg.champion, + championId = msg.championId, + maxSpectators = msg.maxSpectators, + deadlines = deadlines, + } + if S.pending and S.pending.kind == "tournament" + and (S.pending.code == nil or S.pending.code == msg.code) then + S.pending.code = msg.code + S.pending.tournament = S.tournament + S.pending.done = true + end + emit("tournament", S.tournament) +end + +local function matchStart(msg) + local role = msg.role + if role ~= "host" and role ~= "guest" then role = "spectator" end + S.role = role + S.match = msg.match or S.match + if S.room then + S.room.seed = msg.seed or S.room.seed + S.room.match = S.match + if msg.rule then S.room.rule = msg.rule end + end + if S.reportSent ~= S.match then S.reportSent = nil end + if S.matchStarted then return end + S.matchStarted = true + emit("match_start", { + code = msg.code or (S.room and S.room.code) or nil, + match = S.match, + role = role, + seed = msg.seed, + profile = S.room and S.room.profile or nil, + ruleset = msg.ruleset, + rule = msg.rule, + peerName = msg.peerName, + hostName = msg.hostName, + guestName = msg.guestName, + myParty = nil, + theirParty = msg.theirParty, + hostParty = msg.hostParty, + guestParty = msg.guestParty, + }) +end + +local function acceptRoomMsg(entry) + local seq = entry.seq + if type(seq) == "number" and seq > 0 then + if seq <= S.rxSeq then + S.duplicates = S.duplicates + 1 + return + end + S.rxSeq = seq + end + S.roomInbox[#S.roomInbox + 1] = { seq = seq, side = entry.side, + msg = entry.msg } +end + +-- ---------------------------------------------------------------- dispatch + +local helloMessage + +local function handle(msg) + local kind = msg.type + if kind == "lobby_welcome" then + local resumed = msg.resumed == true + and (S.resuming or (S.sessionId ~= nil and msg.session == S.sessionId)) + S.sessionId = msg.session + S.you = { name = msg.you.name, verified = msg.you.verified, + id = msg.you.id, session = msg.session } + S.heartbeatMs = msg.heartbeatMs + S.serverTime = msg.serverTime + S.serverTimeAt = now() + S.attempt = 0 + S.retryAt = nil + S.err = nil + S.resuming = false + if not resumed then + clearRoom() + clearTournament() + end + setStatus("online") + elseif kind == "lobby_list" then + S.lobby, S.lobbyById = {}, {} + S.online = type(msg.online) == "number" and msg.online or nil + for _, entry in ipairs(msg.entries) do putEntry(entry) end + sortLobby() + emit("lobby", S.lobby) + elseif kind == "lobby_delta" then + for _, entry in ipairs(msg.added or {}) do putEntry(entry) end + for _, entry in ipairs(msg.changed or {}) do putEntry(entry) end + for _, id in ipairs(msg.removed or {}) do dropEntry(id) end + for _, entry in ipairs(msg.add or {}) do putEntry(entry) end + for _, entry in ipairs(msg.update or {}) do putEntry(entry) end + for _, id in ipairs(msg.remove or {}) do dropEntry(id) end + if msg.op == "add" or msg.op == "update" then putEntry(msg.entry) + elseif msg.op == "remove" then dropEntry(msg.id or (msg.entry and msg.entry.id)) end + sortLobby() + emit("lobby", S.lobby) + elseif kind == "room_state" then + if not (S.tournament and S.tourFinished[msg.code]) then + applyRoomState(msg) + end + elseif kind == "match_start" or kind == "match_start_spectate" then + matchStart(msg) + elseif kind == "room_replay" then + if type(msg.yourSeq) == "number" then + for i = #S.unacked, 1, -1 do + if (S.unacked[i].seq or 0) <= msg.yourSeq then table.remove(S.unacked, i) end + end + if S.unackedFloor > 0 and msg.yourSeq + 1 < S.unackedFloor then + resumeLost() + return + end + for i = 1, #S.unacked do sendRaw(S.unacked[i]) end + end + for _, entry in ipairs(msg.msgs) do acceptRoomMsg(entry) end + if S.rxSeq > S.ack then sendRaw(Protocol2.roomAck(S.ack)) end + refreshRoomSession() + elseif kind == "room_msg" then + acceptRoomMsg(msg) + elseif kind == "room_deadline" then + if S.room then + S.room.deadlines = S.room.deadlines or {} + S.room.deadlines[msg.kind] = msg.at + emit("room", S.room) + end + elseif kind == "room_result" then + S.matchStarted = false + if S.pendingReport + and (msg.match == nil or msg.match == S.pendingReport.match) then + S.pendingReport = nil + end + local me = S.you and S.you.id + emit("match_end", { + match = msg.match, + code = msg.code or (S.room and S.room.code) or nil, + winner = msg.winner, + winnerId = msg.winnerId, + how = msg.how, + youWon = me ~= nil and msg.winnerId ~= nil and msg.winnerId == me, + }) + if S.tournament then + local code = msg.code or (S.room and S.room.code) or nil + if code then S.tourFinished[code] = true end + S.tourMatch = nil + clearRoom() + emit("room", nil) + end + elseif kind == "room_closed" then + local code = msg.code or (S.room and S.room.code) or nil + clearRoom() + if S.pending and not S.pending.done then + S.pending.error = Protocol2.roomClosedText(msg) + S.pending.reason = msg.reason + S.pending.done = true + end + emit("room", nil) + emit("error", { scope = "room", reason = msg.reason, code = code, + text = Protocol2.roomClosedText(msg) }) + elseif kind == "tour_state" then + applyTourState(msg) + elseif kind == "tour_match" or kind == "tour_match_spectate" then + local payload = { match = msg.match, round = msg.round, code = msg.code, + role = kind == "tour_match" and "player" or "spectator" } + S.tourMatch = payload + S.tourFinished[msg.code] = nil + enterChildRoom(msg.code, msg) + emit(kind == "tour_match" and "tour_match" or "tour_spectate", payload) + elseif kind == "tour_bye" then + emit("tour_bye", { match = msg.match, round = msg.round }) + elseif kind == "tour_deadline" then + if S.tournament then + S.tournament.deadlines = S.tournament.deadlines or {} + S.tournament.deadlines[msg.kind] = msg.at + emit("tournament", S.tournament) + end + if S.room then + S.room.deadlines = S.room.deadlines or {} + S.room.deadlines[msg.kind] = msg.at + emit("room", S.room) + end + elseif kind == "tour_closed" then + local code = msg.code or (S.tournament and S.tournament.code) or nil + local text = Protocol2.tourClosedText(msg) + clearRoom() + clearTournament() + if S.pending and not S.pending.done then + S.pending.error = text + S.pending.reason = msg.reason + S.pending.done = true + end + emit("room", nil) + emit("tournament", nil) + emit("error", { scope = "tournament", reason = msg.reason, code = code, + text = text }) + elseif kind == "tour_over" then + if S.tournament and S.tournament.code == msg.code then + S.tournament.stage = "finished" + S.tournament.champion = msg.championId or S.tournament.champion + S.tournament.championName = msg.champion + emit("tournament", S.tournament) + end + S.tourMatch = nil + emit("tour_over", { code = msg.code, champion = msg.champion, + championId = msg.championId }) + elseif kind == "join_error" then + local text = Protocol2.joinErrorText(msg) + if S.resuming then + S.resuming = false + S.sessionId = nil + clearRoom() + sendRaw(helloMessage()) + return + end + if S.pending then + S.pending.error = text + S.pending.reason = msg.reason + S.pending.field = msg.field + S.pending.done = true + end + emit("error", { scope = "join", reason = msg.reason, field = msg.field, + detail = msg.detail, text = text }) + end +end + +-- ---------------------------------------------------------------- connection + +local function bind() + local transport, err = openTransport() + if not transport then return false, err end + S.net = transport + S.session = Session.new(transport, { role = "client", kind = "lobby" }) + return true +end + +helloMessage = function() + local opts = S.opts or {} + return Protocol2.lobbyHello({ + ticket = opts.ticket, + name = opts.name, + engineVersion = S.engineVersion, + platform = S.platform, + profiles = opts.profiles, + }) +end + +local function scheduleRetry() + S.attempt = S.attempt + 1 + local wait = BACKOFF[math.min(S.attempt, #BACKOFF)] + S.retryAt = now() + wait +end + +local function onDisconnected(detail) + S.session = nil + S.net = nil + if S.sessionId and S.opts and S.attempt < MAX_ATTEMPTS then + S.err = detail + setStatus("reconnecting") + scheduleRetry() + else + S.err = detail or S.err or "disconnected" + setStatus("error") + end + refreshRoomSession() +end + +local function tryReconnect() + S.retryAt = nil + local ok, err = bind() + if not ok then + if S.attempt >= MAX_ATTEMPTS then + S.err = err + setStatus("error") + return + end + S.err = err + scheduleRetry() + return + end + S.resuming = true + sendRaw(Protocol2.resume(S.sessionId, S.ack)) +end + +function Client.configure(opts) + opts = opts or {} + if opts.relayAddress ~= nil then S.relayAddress = opts.relayAddress end + if opts.connect ~= nil then S.connectFn = opts.connect end + if opts.platform ~= nil then S.platform = opts.platform end + if opts.engineVersion ~= nil then S.engineVersion = opts.engineVersion end + return S.relayAddress +end + +function Client.connect(opts) + opts = opts or {} + if S.session then Client.disconnect() end + S.opts = { name = opts.name, ticket = opts.ticket, + profiles = opts.profiles or {} } + S.err = nil + S.sessionId = nil + S.attempt = 0 + S.retryAt = nil + setStatus("connecting") + local ok, err = bind() + if not ok then + S.err = err + setStatus("error") + return false, err + end + sendRaw(helloMessage()) + return true +end + +function Client.disconnect() + if S.session then + if S.room then sendRaw(Protocol2.roomLeave()) end + pcall(function() S.session:close() end) + end + S.session = nil + S.net = nil + S.sessionId = nil + S.you = nil + S.opts = nil + S.lobby, S.lobbyById = {}, {} + S.attempt = 0 + S.retryAt = nil + S.resuming = false + S.advertised = nil + S.online = nil + clearRoom() + clearTournament() + setStatus("offline") +end + +-- ---------------------------------------------------------------- pump + +local function pump(dt) + if S.status == "reconnecting" then + if S.retryAt and now() >= S.retryAt then tryReconnect() end + end + local session = S.session + if not session then return end + session:update() + local messages = session:poll() + for i = 1, #messages do + local msg, reason = Protocol2.validate(messages[i]) + if msg then + handle(msg) + else + S.dropped = S.dropped + 1 + S.lastDrop = reason + end + end + S.dropped = S.dropped + (session.dropped or 0) + session.dropped = 0 + if session.closed then + onDisconnected(session.error or "the relay closed the connection") + return + end + local queued = S.pendingReport + if queued and S.status == "online" then + S.pendingReport = nil + if S.match and queued.match == S.match then + S.reportSent = queued.match + sendRaw(queued.msg) + end + end + refreshRoomSession() +end + +function Client.update(dt) + if S.status == "offline" then return end + local ok, err = pcall(pump, dt or 0) + if not ok then + S.err = tostring(err) + setStatus("error") + end +end + +-- ---------------------------------------------------------------- actions + +function Client.advertise(intent, profile, note) + S.advertised = { intent = intent, profile = profile, note = note } + return sendRaw(Protocol2.advertise(intent, profile, note)) +end + +function Client.unadvertise() + S.advertised = nil + return sendRaw(Protocol2.unadvertise()) +end + +local function newPending(code, kind) + S.pending = { code = code, room = nil, error = nil, done = false, + kind = kind or "room" } + return S.pending +end + +function Client.createRoom(opts) + local pending = newPending(nil) + sendRaw(Protocol2.roomCreate(opts)) + return pending +end + +local function defaultProfile() + local profiles = S.opts and S.opts.profiles or {} + return profiles[1] +end + +function Client.joinRoom(code, as, profile) + local pending = newPending(Wire.code(code)) + sendRaw(Protocol2.roomJoin(code, as, profile or defaultProfile())) + return pending +end + +function Client.leaveRoom() + local rs = S.roomSession + if rs then + rs:close() + S.roomSession = nil + return true + end + if not S.room then return false end + sendRaw(Protocol2.roomLeave()) + clearRoom() + return true +end + +function Client.ready(packedParty, digest) + return sendRaw(Protocol2.roomReady(packedParty, digest)) +end + +local function sendResult(msg) + if not msg then return false end + local match = S.match + if S.session and S.status == "online" then + if match then S.reportSent = match end + return sendRaw(msg) + end + if match and S.reportSent ~= match then + S.pendingReport = { match = match, msg = msg } + end + return false +end + +function Client.report(result) + if not Protocol2.RESULTS[result] then + return sendResult(Protocol2.forfeit(S.match)) + end + return sendResult(Protocol2.roomReport(S.match, result)) +end + +function Client.forfeit() + return sendResult(Protocol2.forfeit(S.match)) +end + +function Client.kick(id) + return sendRaw(Protocol2.roomKick(id)) +end + +function Client.closeRoom() + if not S.room then return false end + local ok = sendRaw(Protocol2.roomClose()) + return ok +end + +function Client.createTournament(opts) + opts = opts or {} + local pending = newPending(nil, "tournament") + sendRaw(Protocol2.tourCreate({ + profile = opts.profile or defaultProfile(), + rule = opts.rule, + playing = opts.playing, + shotClock = opts.shotClock, + maxSpectators = opts.maxSpectators, + party = opts.party, + partyDigest = opts.partyDigest, + public = opts.public, + note = opts.note, + })) + return pending +end + +function Client.joinTournament(code, as, packedParty, digest, profile) + local pending = newPending(Wire.code(code), "tournament") + sendRaw(Protocol2.tourJoin(code, as, profile or defaultProfile(), + packedParty, digest)) + return pending +end + +function Client.leaveTournament() + if not S.tournament then return false end + sendRaw(Protocol2.tourLeave()) + clearRoom() + clearTournament() + emit("room", nil) + emit("tournament", nil) + return true +end + +function Client.startTournament() + if not S.tournament then return false end + return sendRaw(Protocol2.tourStart()) +end + +function Client.kickFromTournament(id) + if not S.tournament then return false end + return sendRaw(Protocol2.tourKick(id)) +end + +function Client.closeTournament() + if not S.tournament then return false end + return sendRaw(Protocol2.tourClose()) +end + +return Client diff --git a/src/online/Convert.lua b/src/online/Convert.lua new file mode 100644 index 00000000..ac95b3e1 --- /dev/null +++ b/src/online/Convert.lua @@ -0,0 +1,435 @@ +-- engine/link/link.asm:1970 CheckTimeCapsuleCompatibility +-- engine/link/link.asm:640 Link_PrepPartyData_Gen1, :930 Link_ConvertPartyStruct1to2 + +local Mon = require("src.battle.gen2.Mon") +local Mail = require("src.core.gen2.Mail") +local Stats = require("src.pokemon.Stats") +local Growth = require("src.pokemon.Growth") + +local Convert = {} + +Convert.STATUS_1TO2 = { + SLP = "sleep", PSN = "poison", BRN = "burn", + PAR = "paralyze", FRZ = "freeze", +} + +-- engine/link/link.asm:980 +Convert.STATUS_2TO1 = { + sleep = "SLP", poison = "PSN", toxic = "PSN", burn = "BRN", + paralyze = "PAR", freeze = "FRZ", +} + +-- engine/link/time_capsule.asm:41 +Convert.TYPE_EXEMPT = { MAGNEMITE = true, MAGNETON = true } + +Convert.DEFAULT_HAPPINESS = 70 -- engine/link/link.asm:1067 + +local function displayName(registry, id) + local def = registry and registry[id] + local name = def and def.name + if type(name) == "string" and name ~= "" then return name end + return tostring(id):gsub("_", " ") +end + +local function baseStatsOf(def) + return (def and def.baseStats) or {} +end + +local function copyStatExp(statExp) + statExp = statExp or {} + local special = statExp.special + if special == nil then + special = statExp.specialAttack or statExp.specialDefense + end + return { + hp = statExp.hp or 0, + attack = statExp.attack or 0, + defense = statExp.defense or 0, + speed = statExp.speed or 0, + special = special or 0, + } +end + +local function copyDVs(dvs) + dvs = dvs or {} + local special = dvs.special + if special == nil then + special = dvs.specialAttack or dvs.specialDefense + end + local out = { + attack = dvs.attack or 0, + defense = dvs.defense or 0, + speed = dvs.speed or 0, + special = special or 0, + } + out.hp = Mon.hpDV(out) + return out +end + +local function scaleHp(hp, oldMax, newMax) + hp = tonumber(hp) + if hp == nil then return newMax end + if not oldMax or oldMax <= 0 then return newMax end + if hp <= 0 then return 0 end + if hp >= oldMax then return newMax end + local scaled = math.floor(hp * newMax / oldMax + 0.5) + return math.max(1, math.min(newMax, scaled)) +end + +local function ppBonus(base, ups) + return (base or 0) + (ups or 0) * math.floor((base or 0) / 5) +end + +-- engine/items/item_effects.asm:2736 ComputeMaxPP +local function ppUpsFrom(base, maxPp) + base = base or 0 + maxPp = tonumber(maxPp) + if not maxPp or base < 5 then return 0 end + local step = math.floor(base / 5) + if step <= 0 then return 0 end + local ups = math.floor((maxPp - base) / step + 0.5) + return math.max(0, math.min(3, ups)) +end + +local function entry(list, kind, text, extra) + local row = extra or {} + row.kind = kind + row.text = text + list[#list + 1] = row + return row +end + +local function newReport() + return { lost = {}, changed = {} } +end + +local function levelOf(mon) + return math.max(1, math.min(100, math.floor(tonumber(mon.level) or 1))) +end + +-- engine/link/link.asm:930 Link_ConvertPartyStruct1to2 + +function Convert.toGen2(mon, gen1Data, gen2Data) + if type(mon) ~= "table" then return nil, "not_a_mon" end + local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species] + if not def2 then return nil, "species_unknown" end + local def1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species] + + local report = newReport() + local level = levelOf(mon) + local dvs = copyDVs(mon.dvs) + local statExp = copyStatExp(mon.statExp) + local stats = Mon.stats(baseStatsOf(def2), dvs, level, statExp) + + local oldStats = mon.stats + if not (oldStats and oldStats.hp) then + oldStats = Stats.calc(def1 or { baseStats = baseStatsOf(def2) }, level, + dvs, statExp) + end + local oldSpecial = oldStats.special + if oldSpecial and (oldSpecial ~= stats.specialAttack + or oldSpecial ~= stats.specialDefense) then + -- engine/link/link.asm:1042 + entry(report.changed, "special_split", + ("SPECIAL SPLIT %d -> %d/%d"):format(oldSpecial, stats.specialAttack, + stats.specialDefense), + { from = oldSpecial, specialAttack = stats.specialAttack, + specialDefense = stats.specialDefense }) + end + + local hp = scaleHp(mon.hp, oldStats.hp, stats.hp) + if oldStats.hp ~= stats.hp then + entry(report.changed, "hp", + ("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0, + oldStats.hp), oldStats.hp, hp, stats.hp), + { from = mon.hp, fromMax = oldStats.hp, to = hp, toMax = stats.hp }) + end + + local growth = Mon.growthFor(gen2Data, def2.growthRate) + local experience = Mon.experienceForLevel(growth, level) + if tonumber(mon.exp) and math.floor(mon.exp) ~= experience then + entry(report.changed, "experience", + ("EXP %d -> %d"):format(math.floor(mon.exp), experience), + { from = math.floor(mon.exp), to = experience }) + end + + local moves = {} + for _, mv in ipairs(mon.moves or {}) do + local mdef = gen2Data.moves and gen2Data.moves[mv.id] + local base = (mdef and mdef.pp) or 0 + local maxPp = ppBonus(base, mv.ppUps) + moves[#moves + 1] = { + id = mv.id, + pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)), + maxPp = maxPp, + } + end + + local status = mon.status and Convert.STATUS_1TO2[mon.status] or nil + if mon.status and status ~= mon.status then + entry(report.changed, "status", + ("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)), + { from = mon.status, to = status }) + end + + local out = { + species = mon.species, + name = def2.name or mon.species, + nickname = mon.nickname, + level = level, + experience = experience, + dvs = dvs, + statExp = statExp, + stats = stats, + hp = hp, + maxHp = stats.hp, + types = def2.types, + moves = moves, + item = nil, + status = status, + -- engine/link/link.asm:1067 + happiness = Convert.DEFAULT_HAPPINESS, + pokerus = 0, + caughtLevel = level, + isEgg = false, + ot = mon.ot, + otName = mon.ot, + otId = mon.otId, + traded = mon.traded, + } + out.shiny = Mon.isShiny(dvs, + { species = mon.species, def = def2, level = level }) + out.gender = Mon.gender(def2, dvs, { species = mon.species, level = level }) + + entry(report.changed, "happiness", + ("FRIENDSHIP SET TO %d"):format(Convert.DEFAULT_HAPPINESS), + { to = Convert.DEFAULT_HAPPINESS }) + entry(report.changed, "caught_level", + ("MET AT LEVEL %d"):format(level), { to = level }) + + return out, report +end + +-- engine/link/link.asm:1985 species, :1999 mail, :2016 move +function Convert.refusalFor(mon, gen2Data, gen1Data) + if type(mon) ~= "table" then return "not_a_mon", {} end + if mon.isEgg then return "is_egg", {} end + local species1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species] + if not species1 then + return "species_too_new", { species = mon.species } + end + if mon.mail ~= nil or Mail.monHoldsMail(mon) then + return "has_mail", { item = mon.item } + end + for _, mv in ipairs(mon.moves or {}) do + if not (gen1Data.moves and gen1Data.moves[mv.id]) then + return "move_too_new", { move = mv.id } + end + end + return nil +end + +function Convert.toGen1(mon, gen2Data, gen1Data) + local reason, info = Convert.refusalFor(mon, gen2Data, gen1Data) + if reason then return nil, reason, info end + + local def1 = gen1Data.pokemon[mon.species] + local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species] + local report = newReport() + local level = levelOf(mon) + local dvs = copyDVs(mon.dvs) + local statExp = copyStatExp(mon.statExp) + + -- engine/link/link.asm:784, data/pokemon/gen1_base_special.asm:3 + local stats = Stats.calc(def1, level, dvs, statExp) + + local oldMax = tonumber(mon.maxHp) + if not oldMax then + local old = mon.stats + or Mon.stats(baseStatsOf(def2), dvs, level, statExp) + oldMax = old.hp + end + local oldSpA = mon.stats and mon.stats.specialAttack + local oldSpD = mon.stats and mon.stats.specialDefense + if oldSpA or oldSpD then + entry(report.changed, "special_fold", + ("SPECIAL FOLDED %s/%s -> %d"):format(tostring(oldSpA or "?"), + tostring(oldSpD or "?"), stats.special), + { specialAttack = oldSpA, specialDefense = oldSpD, to = stats.special }) + end + + local hp = scaleHp(mon.hp, oldMax, stats.hp) + if oldMax ~= stats.hp then + entry(report.changed, "hp", + ("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0, oldMax), + oldMax, hp, stats.hp), + { from = mon.hp, fromMax = oldMax, to = hp, toMax = stats.hp }) + end + + local exp = Growth.expForLevel(def1.growthRate, level, gen1Data.growth_rates) + if tonumber(mon.experience) and math.floor(mon.experience) ~= exp then + entry(report.changed, "experience", + ("EXP %d -> %d"):format(math.floor(mon.experience), exp), + { from = math.floor(mon.experience), to = exp }) + end + + local moves = {} + for _, mv in ipairs(mon.moves or {}) do + local base2 = gen2Data and gen2Data.moves and gen2Data.moves[mv.id] + and gen2Data.moves[mv.id].pp + local ups = ppUpsFrom(base2, mv.maxPp) + local base1 = (gen1Data.moves[mv.id] and gen1Data.moves[mv.id].pp) or 0 + local maxPp = ppBonus(base1, ups) + moves[#moves + 1] = { + id = mv.id, + pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)), + ppUps = ups, + } + end + + local status = mon.status and Convert.STATUS_2TO1[mon.status] or nil + if mon.status and status ~= mon.status then + entry(report.changed, "status", + ("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)), + { from = mon.status, to = status }) + end + + if mon.item then + -- engine/link/link.asm:756, :1078 TimeCapsule_ReplaceTeruSama + entry(report.lost, "item", + ("HELD ITEM LOST: %s"):format(displayName(gen2Data and gen2Data.items, + mon.item)), { item = mon.item }) + end + if tonumber(mon.happiness) then + entry(report.lost, "happiness", "FRIENDSHIP LOST", + { from = math.floor(mon.happiness) }) + end + if tonumber(mon.pokerus) and mon.pokerus ~= 0 then + entry(report.lost, "pokerus", "POKERUS LOST", { from = mon.pokerus }) + end + if tonumber(mon.caughtLevel) then + entry(report.lost, "caught_level", "MET DATA LOST", + { from = math.floor(mon.caughtLevel) }) + end + + local out = { + species = mon.species, + level = level, + exp = exp, + dvs = dvs, + statExp = statExp, + stats = stats, + hp = hp, + catchRate = def1.catchRate, + status = status, + moves = moves, + nickname = mon.nickname, + ot = mon.ot or mon.otName, + otId = mon.otId, + traded = mon.traded, + } + return out, report +end + +-- engine/link/time_capsule.asm:3 ValidateOTTrademon, :41 the type carve-out + +function Convert.validateArrival(mon, destData) + if type(mon) ~= "table" then return false, "not_a_mon" end + local def = destData and destData.pokemon and destData.pokemon[mon.species] + if not def then return false, "species_unknown" end + -- engine/link/time_capsule.asm:27 + local level = tonumber(mon.level) + if not level or level < 1 or level > 100 then return false, "level" end + if Convert.TYPE_EXEMPT[mon.species] then return true end + local claimed = mon.types + if type(claimed) ~= "table" then return true end + local want = def.types or {} + local w1, w2 = want[1], want[2] or want[1] + local c1, c2 = claimed[1], claimed[2] or claimed[1] + if c1 ~= w1 or c2 ~= w2 then return false, "types" end + return true +end + +local REFUSAL_TEXT = { + is_egg = function() return "EGGS CANNOT TRAVEL" end, + has_mail = function() return "MON IS HOLDING MAIL" end, + not_a_mon = function() return "NO MON" end, + species_unknown = function(_, info) + return ("SPECIES UNKNOWN: %s"):format(tostring(info.species or "?")) + end, + species_too_new = function(_, info) + return ("SPECIES NOT IN GEN 1: %s"):format( + tostring(info.species or "?"):gsub("_", " ")) + end, + move_too_new = function(_, info) + return ("MOVE ILLEGAL IN GEN 1: %s"):format( + tostring(info.move or "?"):gsub("_", " ")) + end, +} + +local function previewLines(mon, report, reason, info) + local lines = {} + if reason then + local fn = REFUSAL_TEXT[reason] + lines[1] = fn and fn(mon, info or {}) or reason:upper() + return lines, false + end + for _, row in ipairs(report.lost) do lines[#lines + 1] = row.text end + for _, row in ipairs(report.changed) do lines[#lines + 1] = row.text end + if #lines == 0 then lines[1] = "NOTHING IS LOST" end + return lines, true +end + +local function convertOne(mon, toGen, fromData, toData) + if toGen == 1 then + local out, second, info = Convert.toGen1(mon, fromData, toData) + if out then return out, second end + return nil, second, info or {} + end + local out, second = Convert.toGen2(mon, fromData, toData) + if out then return out, second end + return nil, second, { species = mon and mon.species } +end + +function Convert.preview(mon, fromGen, toGen, fromData, toData) + local out, second, info = convertOne(mon, toGen, fromData, toData) + if out then return previewLines(mon, second) end + return previewLines(mon, nil, second, info) +end + +-- engine/link/link.asm:2048 refuses the whole party; per-slot here instead +local function mapParty(party, toGen, fromData, toData) + local converted, results = {}, {} + for i, mon in ipairs(party or {}) do + local out, second, info = convertOne(mon, toGen, fromData, toData) + if out then + converted[#converted + 1] = out + results[i] = { index = i, ok = true, mon = out, report = second, + species = mon.species, + preview = previewLines(mon, second) } + else + results[i] = { index = i, ok = false, reason = second, info = info, + species = mon.species, + preview = previewLines(mon, nil, second, info) } + end + end + return converted, results +end + +function Convert.partyToGen1(party, gen2Data, gen1Data) + return mapParty(party, 1, gen2Data, gen1Data) +end + +function Convert.partyToGen2(party, gen1Data, gen2Data) + return mapParty(party, 2, gen1Data, gen2Data) +end + +function Convert.refusals(results) + local out = {} + for _, row in ipairs(results or {}) do + if not row.ok then out[#out + 1] = row end + end + return out +end + +return Convert diff --git a/src/online/OnlineSprites.lua b/src/online/OnlineSprites.lua new file mode 100644 index 00000000..2b997fe9 --- /dev/null +++ b/src/online/OnlineSprites.lua @@ -0,0 +1,234 @@ +-- engine/gfx/mon_icons.asm WriteSymmetricMonPartySpriteOAM +-- engine/gfx/load_pics.asm + +local CacheFs = require("src.import.CacheFs") +local GameVersion = require("src.core.GameVersion") + +local OnlineSprites = {} + +local catalogs = {} +local cache = {} + +-- engine/gfx/mon_icons.asm:246 +local function mirrors(name) + return name ~= nil and name ~= "HELIX" +end + +local SHADES = { { 255, 255, 255 }, { 170, 170, 170 }, + { 85, 85, 85 }, { 0, 0, 0 } } + +function OnlineSprites.key(version, species, shiny) + return ("%s|%s|%s"):format(tostring(version), tostring(species), + shiny and "shiny" or "normal") +end + +function OnlineSprites.reset() + catalogs, cache = {}, {} +end + +function OnlineSprites.keepOnly(versions) + local keep = {} + for _, version in ipairs(versions or {}) do keep[tostring(version)] = true end + for version in pairs(catalogs) do + if not keep[version] then catalogs[version] = nil end + end + for key in pairs(cache) do + local version = key:match("^([^|]*)|") + if version and not keep[version] then cache[key] = nil end + end +end + +function OnlineSprites.readBytes(version, path) + local prefix = GameVersion.cachePrefix(version) or "" + local bytes = CacheFs.readAt(prefix .. path) + if type(bytes) == "string" and bytes ~= "" then return bytes end + return nil +end + +local function loadTable(version, path) + local bytes = OnlineSprites.readBytes(version, path) + if not bytes then return nil end + local loader = loadstring or load + local chunk = loader(bytes, "@" .. path) + if not chunk then return nil end + local ok, value = pcall(chunk) + if not ok or type(value) ~= "table" then return nil end + return value +end + +local function catalog(version) + local hit = catalogs[version] + if hit then return hit end + hit = { + pokemon = loadTable(version, "data/generated/pokemon.lua") or {}, + icons = loadTable(version, "data/generated/icons.lua") or {}, + palettes = loadTable(version, "data/generated/palettes.lua") or {}, + generation = GameVersion.generation(version), + } + catalogs[version] = hit + return hit +end + +local function paletteFor(cat, species, shiny) + local entry = cat.palettes and cat.palettes.pokemon + and cat.palettes.pokemon[species] + if type(entry) == "string" then + local ramp = cat.palettes.palettes and cat.palettes.palettes[entry] + if type(ramp) == "table" and #ramp == 4 then return ramp end + return nil + end + if type(entry) ~= "table" then return nil end + local pair = entry[shiny and "shiny" or "normal"] or entry.normal + if type(pair) ~= "table" or #pair < 2 then return nil end + return { { 255, 255, 255 }, pair[1], pair[2], { 0, 0, 0 } } +end + +local function shadeIndex(r) + if r > 0.75 then return 1 end + if r > 0.5 then return 2 end + if r > 0.25 then return 3 end + return 4 +end + +function OnlineSprites.makeImage(bytes, path, palette) + if not (love and love.graphics and love.graphics.newImage) then return nil end + local ok, image = pcall(function() + local data = love.filesystem.newFileData(bytes, path) + if not (palette and love.image and love.image.newImageData) then + return love.graphics.newImage(data) + end + local pixels = love.image.newImageData(data) + if type(pixels.mapPixel) ~= "function" then + return love.graphics.newImage(data) + end + pixels:mapPixel(function(_, _, r, _, _, a) + local col = palette[shadeIndex(r)] or SHADES[shadeIndex(r)] + return (col[1] or 0) / 255, (col[2] or 0) / 255, (col[3] or 0) / 255, a + end) + return love.graphics.newImage(pixels) + end) + if not ok then return nil end + return image +end + +local function image(version, path, palette) + local bytes = OnlineSprites.readBytes(version, path) + if not bytes then return false end + return OnlineSprites.makeImage(bytes, path, palette) or false +end + +local function iconPath(cat, mon) + local icons = cat.icons or {} + if cat.generation == 2 then + local id = mon.isEgg and "ICON_EGG" + or (icons.species and icons.species[mon.species]) + local entry = id and icons.icons and icons.icons[id] + if type(entry) == "table" then return entry.image, id end + return nil, id + end + local def = cat.pokemon and cat.pokemon[mon.species] + local entry = (icons.bySpecies and icons.bySpecies[mon.species]) + or (def and def.icon) + local name, path + if type(entry) == "string" then + name = entry + path = icons.icons and icons.icons[entry] + elseif type(entry) == "table" then + path = entry.image + end + if not path then + name = def and def.dex and icons.byDex and icons.byDex[def.dex] + local hit = name and icons.icons and icons.icons[name] + path = (type(hit) == "table") and hit.image or hit + end + return path, name +end + +function OnlineSprites.get(version, mon) + if type(mon) ~= "table" then return nil end + return cache[OnlineSprites.key(version, mon.species, mon.shiny == true)] +end + +function OnlineSprites.ensure(version, mon) + if type(mon) ~= "table" or not version then return nil end + local key = OnlineSprites.key(version, mon.species, mon.shiny == true) + local hit = cache[key] + if hit then return hit end + local cat = catalog(version) + local def = cat.pokemon and cat.pokemon[mon.species] + local palette = paletteFor(cat, mon.species, mon.shiny == true) + local path, name = iconPath(cat, mon) + local entry = { key = key, mirror = false } + if path then + entry.icon = image(version, path, nil) + entry.mirror = (cat.generation ~= 2) and mirrors(name) or false + else + entry.icon = false + end + entry.front = def and def.spriteFront + and image(version, def.spriteFront, palette) or false + cache[key] = entry + return entry +end + +function OnlineSprites.prime(version, party) + if not version or type(party) ~= "table" then return 0 end + local n = 0 + for _, mon in ipairs(party) do + if OnlineSprites.ensure(version, mon) then n = n + 1 end + end + return n +end + +local function paintWhite() + if not (love.graphics and love.graphics.getColor) then return nil end + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(1, 1, 1, 1) + return { r, g, b, a } +end + +local function restore(saved) + if saved and love.graphics and love.graphics.setColor then + love.graphics.setColor(saved[1], saved[2], saved[3], saved[4]) + end +end + +function OnlineSprites.drawIcon(entry, x, y, size) + if type(entry) ~= "table" or not entry.icon then return false end + local img = entry.icon + if type(img.getDimensions) ~= "function" then return false end + local iw, ih = img:getDimensions() + if not iw or iw <= 0 or ih <= 0 then return false end + local scale = (size or 16) / 16 + local saved = paintWhite() + if ih > 16 and entry.mirror then + local half = love.graphics.newQuad(0, 0, 8, 16, iw, ih) + love.graphics.draw(img, half, x, y, 0, scale, scale) + love.graphics.draw(img, half, x + 16 * scale, y, 0, -scale, scale) + elseif ih > 16 then + local frame = love.graphics.newQuad(0, 0, 16, 16, iw, ih) + love.graphics.draw(img, frame, x, y, 0, scale, scale) + else + love.graphics.draw(img, x, y, 0, scale, scale) + end + restore(saved) + return true +end + +function OnlineSprites.drawFront(entry, x, y, box) + if type(entry) ~= "table" or not entry.front then return false end + local img = entry.front + if type(img.getDimensions) ~= "function" then return false end + local iw, ih = img:getDimensions() + if not iw or iw <= 0 or ih <= 0 then return false end + box = box or 56 + local scale = box / math.max(iw, ih) + if scale >= 1 then scale = math.floor(scale) end + local saved = paintWhite() + love.graphics.draw(img, math.floor(x + (box - iw * scale) / 2), + math.floor(y + (box - ih * scale) / 2), 0, scale, scale) + restore(saved) + return true +end + +return OnlineSprites diff --git a/src/online/Protocol2.lua b/src/online/Protocol2.lua new file mode 100644 index 00000000..ab5d5b69 --- /dev/null +++ b/src/online/Protocol2.lua @@ -0,0 +1,381 @@ +local Wire = require("src.link.Wire") + +local Protocol2 = {} + +Protocol2.VERSION = 2 + +Protocol2.INTENTS = { battle = true, trade = true, tournament = true } +Protocol2.JOIN_AS = { player = true, spectator = true } + +local function build(msg) + local out = Wire.sanitize(msg) + return out +end + +function Protocol2.lobbyHello(opts) + opts = opts or {} + return build({ + type = "lobby_hello", + ticket = opts.ticket, + name = opts.name, + engineVersion = opts.engineVersion, + platform = opts.platform, + profiles = opts.profiles or {}, + }) +end + +function Protocol2.resume(session, ack) + return build({ type = "resume", session = session, ack = ack or 0 }) +end + +function Protocol2.advertise(intent, profile, note) + return build({ type = "advertise", intent = intent, profile = profile, + note = note }) +end + +function Protocol2.unadvertise() + return build({ type = "unadvertise" }) +end + +function Protocol2.roomCreate(opts) + opts = opts or {} + return build({ + type = "room_create", + intent = opts.intent or "battle", + profile = opts.profile, + playing = opts.playing ~= false, + maxSpectators = opts.maxSpectators, + public = opts.public ~= false, + note = opts.note, + }) +end + +function Protocol2.roomJoin(code, as, profile) + return build({ type = "room_join", code = code, as = as or "player", + profile = profile }) +end + +function Protocol2.roomLeave() + return build({ type = "room_leave" }) +end + +function Protocol2.roomReady(party, partyDigest) + return build({ type = "room_ready", party = party or {}, + partyDigest = partyDigest }) +end + +function Protocol2.roomMsg(seq, msg) + return build({ type = "room_msg", seq = seq, msg = msg }) +end + +function Protocol2.roomAck(seq) + return build({ type = "room_ack", seq = seq }) +end + +function Protocol2.roomReport(match, result) + return build({ type = "room_report", match = match, result = result }) +end + +function Protocol2.forfeit(match) + return build({ type = "forfeit", match = match }) +end + +function Protocol2.roomKick(id) + return build({ type = "room_kick", id = id }) +end + +function Protocol2.roomClose() + return build({ type = "room_close" }) +end + +function Protocol2.lobbyQuery() + return build({ type = "lobby_query" }) +end + +Protocol2.SHOT_CLOCKS = { 3, 6, 9 } +Protocol2.TOUR_STAGES = { registering = true, running = true, finished = true } +Protocol2.TOUR_MATCH_STATES = { pending = true, live = true, done = true, + bye = true } + +function Protocol2.shotClock(value) + local want = tonumber(value) + if not want then return Protocol2.SHOT_CLOCKS[2] end + local best, bestGap = Protocol2.SHOT_CLOCKS[2], math.huge + for _, allowed in ipairs(Protocol2.SHOT_CLOCKS) do + local gap = math.abs(allowed - want) + if gap < bestGap then best, bestGap = allowed, gap end + end + return best +end + +function Protocol2.tourCreate(opts) + opts = opts or {} + return build({ + type = "tour_create", + profile = opts.profile, + rule = opts.rule, + playing = opts.playing ~= false, + shotClock = Protocol2.shotClock(opts.shotClock), + maxSpectators = opts.maxSpectators, + party = opts.party, + partyDigest = opts.partyDigest, + public = opts.public ~= false, + note = opts.note, + }) +end + +function Protocol2.tourJoin(code, as, profile, party, partyDigest) + return build({ + type = "tour_join", + code = code, + as = as or "player", + profile = profile, + party = party, + partyDigest = partyDigest, + }) +end + +function Protocol2.tourLeave() + return build({ type = "tour_leave" }) +end + +function Protocol2.tourStart() + return build({ type = "tour_start" }) +end + +function Protocol2.tourKick(id) + return build({ type = "tour_kick", id = id }) +end + +function Protocol2.tourClose() + return build({ type = "tour_close" }) +end + +Protocol2.CLIENT_TYPES = { + lobby_hello = true, ping = true, pong = true, resume = true, + advertise = true, unadvertise = true, room_create = true, + room_join = true, room_leave = true, room_ready = true, + room_msg = true, room_ack = true, room_report = true, forfeit = true, + room_kick = true, room_close = true, lobby_query = true, + tour_create = true, tour_join = true, tour_leave = true, + tour_start = true, tour_kick = true, tour_close = true, +} + +Protocol2.SERVER_TYPES = { + lobby_welcome = true, lobby_list = true, lobby_delta = true, + room_state = true, room_replay = true, room_msg = true, + room_deadline = true, room_result = true, room_closed = true, + match_start = true, match_start_spectate = true, join_error = true, + ping = true, pong = true, + tour_state = true, tour_match = true, tour_match_spectate = true, + tour_bye = true, tour_deadline = true, tour_over = true, + tour_closed = true, +} + +Protocol2.RESULTS = { win = true, lose = true, draw = true } + +local VALIDATORS = {} + +VALIDATORS.lobby_welcome = function(m) + if type(m.session) ~= "string" or m.session == "" then + return nil, "lobby_welcome without a session id" + end + if type(m.you) ~= "table" then return nil, "lobby_welcome without you" end + return m +end + +VALIDATORS.lobby_list = function(m) + if type(m.entries) ~= "table" then return nil, "lobby_list without entries" end + return m +end + +local DELTA_LISTS = { "added", "removed", "changed", "add", "update", "remove" } + +VALIDATORS.lobby_delta = function(m) + for _, key in ipairs(DELTA_LISTS) do + if type(m[key]) == "table" and #m[key] > 0 then return m end + end + if type(m.op) == "string" + and (type(m.entry) == "table" or type(m.id) == "string") then + return m + end + return nil, "empty lobby_delta" +end + +VALIDATORS.room_state = function(m) + if type(m.code) ~= "string" then return nil, "room_state without a code" end + if type(m.players) ~= "table" then return nil, "room_state without players" end + return m +end + +VALIDATORS.room_replay = function(m) + if type(m.msgs) ~= "table" then return nil, "room_replay without msgs" end + return m +end + +VALIDATORS.room_msg = function(m) + if type(m.msg) ~= "table" or type(m.msg.type) ~= "string" then + return nil, "room_msg without an inner message" + end + if type(m.seq) ~= "number" then return nil, "room_msg without a seq" end + return m +end + +VALIDATORS.room_deadline = function(m) + if type(m.kind) ~= "string" then return nil, "room_deadline without a kind" end + if type(m.at) ~= "number" then return nil, "room_deadline without a time" end + return m +end + +VALIDATORS.match_start = function(m) + if type(m.role) ~= "string" or m.role == "" then + return nil, "match_start without a role" + end + if type(m.match) ~= "string" or m.match == "" then + return nil, "match_start without a match token" + end + return m +end + +VALIDATORS.match_start_spectate = VALIDATORS.match_start + +VALIDATORS.room_closed = function(m) + if type(m.reason) ~= "string" or m.reason == "" then + return nil, "room_closed without a reason" + end + return m +end + +VALIDATORS.room_result = function(m) + if type(m.how) ~= "string" then return nil, "room_result without how" end + return m +end + +VALIDATORS.tour_state = function(m) + if type(m.code) ~= "string" or m.code == "" then + return nil, "tour_state without a code" + end + if type(m.players) ~= "table" then return nil, "tour_state without players" end + if type(m.bracket) ~= "table" then return nil, "tour_state without a bracket" end + if not Protocol2.TOUR_STAGES[m.stage or ""] then + return nil, "tour_state without a stage" + end + return m +end + +VALIDATORS.tour_match = function(m) + if type(m.match) ~= "string" or m.match == "" then + return nil, "tour_match without a match token" + end + if type(m.code) ~= "string" or m.code == "" then + return nil, "tour_match without a child room code" + end + return m +end + +VALIDATORS.tour_match_spectate = VALIDATORS.tour_match + +VALIDATORS.tour_bye = function(m) + if type(m.match) ~= "string" or m.match == "" then + return nil, "tour_bye without a match token" + end + return m +end + +VALIDATORS.tour_deadline = function(m) + if type(m.kind) ~= "string" or m.kind == "" then + return nil, "tour_deadline without a kind" + end + if type(m.at) ~= "number" then return nil, "tour_deadline without a time" end + return m +end + +VALIDATORS.tour_closed = function(m) + if type(m.reason) ~= "string" or m.reason == "" then + return nil, "tour_closed without a reason" + end + return m +end + +VALIDATORS.tour_over = function(m) + if type(m.code) ~= "string" or m.code == "" then + return nil, "tour_over without a code" + end + return m +end + +VALIDATORS.join_error = function(m) + if type(m.reason) ~= "string" or m.reason == "" then + return nil, "join_error without a reason" + end + return m +end + +Protocol2.VALIDATORS = VALIDATORS + +function Protocol2.validate(raw) + if type(raw) ~= "table" then return nil, "not a table" end + local msg = Wire.sanitize(raw) + if not msg then return nil, "failed sanitize" end + local validator = VALIDATORS[msg.type] + if not validator then return msg end + local ok, reason = validator(msg) + if not ok then return nil, reason end + return ok +end + +local REASONS = { + not_found = "That room code wasn't found.", + full = "That room is full.", + expired = "That room code has expired.", + profile_mismatch = "Your game doesn't match the room.", + rule_violation = "Your team doesn't meet the room's rule.", + spectate_late = "That match is too far along to watch.", + bad_ticket = "The relay didn't accept your sign-in.", + lobby_disabled = "This relay isn't running online play.", + resume_unknown = "The relay forgot your session.", + resume_expired = "You were away too long to rejoin.", + already_in_room = "You're already in a room.", + spectators_full = "That room has all the spectators it can take.", + bad_profile = "The relay couldn't read your game's profile.", + bad_party = "The relay couldn't read your team.", + party_ineligible = "Your team doesn't meet the room's rule.", + tour_not_found = "That tournament code wasn't found.", + tour_started = "That tournament has already started.", + tour_full = "That tournament is full.", + not_creator = "Only the tournament's creator can do that.", +} + +local CLOSED_REASONS = { + kicked = "The host removed you from the room.", + closed = "The host closed the room.", + idle = "The room was closed for being idle.", + backlog = "You fell too far behind to keep watching.", +} + +local TOUR_CLOSED_REASONS = { + kicked = "The creator removed you from the tournament.", + closed = "The creator closed the tournament.", + idle = "The tournament was closed for being idle.", +} + +function Protocol2.tourClosedText(msg) + local reason = type(msg) == "table" and msg.reason or tostring(msg) + return TOUR_CLOSED_REASONS[reason] + or ("The tournament closed: " .. tostring(reason)) +end + +function Protocol2.roomClosedText(msg) + local reason = type(msg) == "table" and msg.reason or tostring(msg) + return CLOSED_REASONS[reason] or ("The room closed: " .. tostring(reason)) +end + +function Protocol2.joinErrorText(msg) + local reason = type(msg) == "table" and msg.reason or tostring(msg) + local text = REASONS[reason] or ("Couldn't join: " .. tostring(reason)) + local detail = type(msg) == "table" and msg.detail or nil + if detail and detail ~= "" then return text .. " (" .. detail .. ")" end + return text +end + +return Protocol2 diff --git a/src/online/TeamPick.lua b/src/online/TeamPick.lua new file mode 100644 index 00000000..6a549438 --- /dev/null +++ b/src/online/TeamPick.lua @@ -0,0 +1,241 @@ +local GameVersion = require("src.core.GameVersion") +local Protocol = require("src.link.Protocol") +local SaveData = require("src.core.SaveData") + +local TeamPick = {} + +local function generationOf(version, save) + if type(save) == "table" and tonumber(save.generation) then + return tonumber(save.generation) + end + return GameVersion.generation(version) +end + +local function readCartSource(cartId, slotId) + local registered = false + for _, row in ipairs(SaveData.listCartSlots(cartId)) do + if row.id == slotId then registered = row.exists end + end + if not registered then return nil, "no save in that slot" end + local fs = SaveData.persistenceFs() + local main = "saves/cart_" .. cartId .. "/" .. slotId .. ".lua" + for _, name in ipairs({ main, main .. ".tmp", main .. ".bak" }) do + if fs.getInfo(name) then + local body = fs.read(name) + if type(body) == "string" and body ~= "" and SaveData.decode(body) then + return body + end + end + end + return nil, "no save in that slot" +end + +local function readSource(version, slotId, cartId) + if cartId then return readCartSource(cartId, slotId) end + return SaveData.readSlotSource(version, slotId) +end + +function TeamPick.readSlot(version, slotId, cartId) + if not GameVersion.VERSIONS[version] then return nil, "unknown game" end + if type(slotId) ~= "string" or slotId == "" then return nil, "no slot chosen" end + if cartId and SaveData.slotSealBroken(cartId, slotId) then + return nil, "this save's seal is broken" + end + local ok, source, err = pcall(readSource, version, slotId, cartId) + if not ok then return nil, tostring(source) end + if type(source) ~= "string" then + return nil, tostring(err or "no save in that slot") + end + local decoded, save = pcall(SaveData.decode, source) + if not decoded or type(save) ~= "table" then + return nil, "that save can't be read" + end + local name, meta = SaveData.slotSummary(save) + return { + save = save, + party = type(save.party) == "table" and save.party or {}, + trainerName = name, + badges = (meta and meta.badges) or 0, + generation = generationOf(version, save), + } +end + +-- engine/pokemon/bills_pc.asm +local GEN1_BOXES, GEN2_BOXES = 12, 14 + +local function boxName(save, generation, index) + if generation == 2 then + local names = type(save) == "table" and save.boxNames or nil + local given = type(names) == "table" and names[index] or nil + if type(given) == "string" and given ~= "" then return given end + return "BOX" .. tostring(index) + end + return "BOX " .. tostring(index) +end + +TeamPick.boxName = boxName + +function TeamPick.refKey(ref) + if type(ref) == "number" then return "party|" .. tostring(ref) end + if type(ref) ~= "table" then return "?" end + if ref.where == "box" then + return ("box|%s|%s"):format(tostring(ref.box), tostring(ref.index)) + end + return "party|" .. tostring(ref.index) +end + +function TeamPick.sameRef(a, b) + return TeamPick.refKey(a) == TeamPick.refKey(b) +end + +local function slotOf(source) + if type(source) ~= "table" then return { party = {} } end + if type(source.party) == "table" then return source end + return { party = source } +end + +TeamPick.slotOf = slotOf + +local function boxesOf(slot) + local save = type(slot.save) == "table" and slot.save or nil + if save and type(save.boxes) == "table" then return save.boxes end + if type(slot.boxes) == "table" then return slot.boxes end + return nil +end + +function TeamPick.monAt(source, ref) + local slot = slotOf(source) + if type(ref) == "number" then ref = { where = "party", index = ref } end + if type(ref) ~= "table" then return nil end + local index = tonumber(ref.index) + if not index or index < 1 or index ~= math.floor(index) then return nil end + if ref.where == "box" then + local boxes = boxesOf(slot) + if type(boxes) ~= "table" then return nil end + local list = boxes[tonumber(ref.box) or 0] + if type(list) ~= "table" then return nil end + local mon = list[index] + return type(mon) == "table" and mon or nil + end + local mon = (slot.party or {})[index] + return type(mon) == "table" and mon or nil +end + +function TeamPick.candidates(source) + local slot = slotOf(source) + local out = {} + for index, mon in ipairs(slot.party or {}) do + out[#out + 1] = { where = "party", index = index, mon = mon, + source = "Party" } + end + local boxes = boxesOf(slot) + if type(boxes) ~= "table" then return out end + local generation = tonumber(slot.generation) or 1 + local last = (generation == 2) and GEN2_BOXES or GEN1_BOXES + for index in pairs(boxes) do + local n = tonumber(index) + if n and n > last then last = n end + end + for box = 1, last do + local list = boxes[box] + if type(list) == "table" then + local name = boxName(slot.save, generation, box) + for index, mon in ipairs(list) do + if type(mon) == "table" then + out[#out + 1] = { where = "box", box = box, index = index, + mon = mon, source = name } + end + end + end + end + return out +end + +function TeamPick.validate(source, team, rule) + local slot = slotOf(source) + rule = type(rule) == "table" and rule or {} + if type(team) ~= "table" then return false, "pick a team first" end + local want = tonumber(rule.partySize) or #team + if #team ~= want then + return false, ("this arena needs %d Pokemon."):format(want) + end + local seen = {} + for _, ref in ipairs(team) do + local key = TeamPick.refKey(ref) + local mon = TeamPick.monAt(slot, ref) + if not mon then return false, "that's not in this save." end + if seen[key] then return false, "no doubles allowed." end + seen[key] = true + if mon.isEgg then return false, "an EGG can't battle." end + if not rule.forceLevel then + local level = tonumber(mon.level) or 0 + local min, max = tonumber(rule.minLevel), tonumber(rule.maxLevel) + if min and level < min then + return false, ("every Pokemon must be\nLv%d or higher."):format(min) + end + if max and level > max then + return false, ("every Pokemon must be\nLv%d or lower."):format(max) + end + end + end + return true +end + +function TeamPick.pack(source, team, generation) + local slot = slotOf(source) + team = type(team) == "table" and team or {} + local gen2 = tonumber(generation) == 2 + local mons = {} + for _, ref in ipairs(team) do + local mon = TeamPick.monAt(slot, ref) + if mon then + mons[#mons + 1] = gen2 and Protocol.packMon2(mon) or Protocol.packMon(mon) + end + end + return mons +end + +-- engine/link/time_capsule.asm:41 +function TeamPick.convert(source, toGeneration, fromData, toData) + local Convert = require("src.online.Convert") + toGeneration = tonumber(toGeneration) or 1 + local list = TeamPick.candidates(source) + local mons = {} + for i, row in ipairs(list) do mons[i] = row.mon end + local _, results + if toGeneration == 1 then + _, results = Convert.partyToGen1(mons, fromData, toData) + else + _, results = Convert.partyToGen2(mons, fromData, toData) + end + local byKey, rows, refusals = {}, {}, {} + for index, row in ipairs(results or {}) do + local ref = list[index] + local key = ref and TeamPick.refKey(ref) or tostring(index) + row.ref, row.key = ref, key + rows[key] = row + if row.ok then + byKey[key] = row.mon + else + refusals[key] = row.reason or "refused" + end + end + return byKey, rows, refusals, list +end + +function TeamPick.packConverted(converted, team, generation) + converted = type(converted) == "table" and converted or {} + team = type(team) == "table" and team or {} + local mons = {} + for _, ref in ipairs(team) do + local mon = converted[TeamPick.refKey(ref)] + if type(mon) ~= "table" then + return nil, "that Pokemon cannot cross generations." + end + mons[#mons + 1] = (tonumber(generation) == 2) + and Protocol.packMon2(mon) or Protocol.packMon(mon) + end + return mons +end + +return TeamPick diff --git a/src/online/Trade.lua b/src/online/Trade.lua new file mode 100644 index 00000000..4047b058 --- /dev/null +++ b/src/online/Trade.lua @@ -0,0 +1,681 @@ +local GameVersion = require("src.core.GameVersion") +local Protocol = require("src.link.Protocol") +local SaveData = require("src.core.SaveData") +local TeamPick = require("src.online.TeamPick") + +local Trade = {} + +local function disk() + return SaveData.persistenceFs() +end + +local function scopeKey(version, cartId) + if cartId then return "cart_" .. cartId end + return version +end + +function Trade.slotPath(version, slotId, cartId) + return "saves/" .. scopeKey(version, cartId) .. "/" .. slotId .. ".lua" +end + +Trade.mountDepth = 0 + +function Trade.mounted() + return (Trade.mountDepth or 0) > 0 +end + +Trade.hostIsLive = nil + +function Trade.gameIsLive() + if type(Trade.hostIsLive) == "function" then + return Trade.hostIsLive() == true + end + if Trade.mounted() then return false end + local Data = package.loaded["src.core.Data"] + return Data ~= nil and Data._pristineKeys ~= nil +end + +local function deepCopy(value, seen) + if type(value) ~= "table" then return value end + seen = seen or {} + if seen[value] then return seen[value] end + local out = {} + seen[value] = out + for k, v in pairs(value) do out[deepCopy(k, seen)] = deepCopy(v, seen) end + return out +end + +-- ------- dataset mounting (no Game) + +local function gen2Dataset() + local CacheFs = require("src.import.CacheFs") + local data = {} + local function load(name) + return CacheFs.loadActive("data/generated/" .. name .. ".lua") + end + data.pokemon = load("pokemon") or {} + data.items = load("items") or {} + data.moves = load("moves") or {} + data.type_chart = load("type_chart") or {} + data.gen2Constants = load("constants") + local ItemEffects = require("src.core.gen2.ItemEffects") + data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items) + return data +end + +local function mountDataset(version) + local CacheFs = require("src.import.CacheFs") + local generation = GameVersion.generation(version) + local prevVersion, prevPrefix = GameVersion.get(), CacheFs.prefix + local released = false + Trade.mountDepth = (Trade.mountDepth or 0) + 1 + local function release() + if released then return end + released = true + Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1) + if generation ~= 2 then + pcall(function() require("src.core.Data"):unloadGenerated() end) + end + pcall(CacheFs.unmountVersion, version) + GameVersion.set(prevVersion) + CacheFs.prefix = prevPrefix + end + local ok, data = pcall(function() + GameVersion.set(version) + CacheFs.prefix = GameVersion.cachePrefix(version) + CacheFs.mountVersion(version) + if not CacheFs.readActive("data/generated/pokemon.lua") then + error(("%s is not imported"):format(tostring(version)), 0) + end + if generation == 2 then return gen2Dataset() end + local Data = require("src.core.Data") + Data:load() + return Data + end) + if not ok then + release() + return nil, tostring(data) + end + return data, release +end + +function Trade.withDataset(version, fn) + local data, release = mountDataset(version) + if not data then return nil, release end + local ok, result = pcall(fn, data) + release() + if not ok then return nil, tostring(result) end + return result +end + +function Trade.withMountedData(data, fn) + Trade.mountDepth = (Trade.mountDepth or 0) + 1 + local ok, result = pcall(fn, data) + Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1) + if not ok then return nil, tostring(result) end + return result +end + +local function withData(handle, fn) + if handle.data then + local ok, result = pcall(fn, handle.data) + if not ok then return nil, tostring(result) end + return result + end + if Trade.gameIsLive() then return nil, "close the game first" end + return Trade.withDataset(handle.version, fn) +end + +-- ------- handles + +function Trade.openSlot(version, slotId, cartId, opts) + opts = type(opts) == "table" and opts or {} + if Trade.gameIsLive() then return nil, "close the game first" end + local slot, reason = TeamPick.readSlot(version, slotId, cartId) + if not slot then return nil, reason end + local save = slot.save + return { + version = version, + generation = slot.generation, + slotId = slotId, + cartId = cartId, + save = save, + path = Trade.slotPath(version, slotId, cartId), + party = slot.party, + boxes = type(save.boxes) == "table" and save.boxes or nil, + trainerName = slot.trainerName, + data = opts.data, + } +end + +local function refOf(index) + local where, box, at = "party", nil, index + if type(index) == "table" then + at = index.index + if index.where == "box" then + where = "box" + box = tonumber(index.box) + if not box or box < 1 or box ~= math.floor(box) then return nil end + end + end + at = tonumber(at) + if not at or at < 1 or at ~= math.floor(at) then return nil end + return { where = where, box = box, index = at } +end + +Trade.refOf = refOf + +-- engine/link/link.asm:810 +function Trade.holdsMail(handle, index) + if type(handle) ~= "table" or handle.generation ~= 2 then return false end + local ref = refOf(index) + if not ref or ref.where == "box" then return false end + local mon = (handle.party or {})[ref.index] + local Mail = require("src.core.gen2.Mail") + if Mail.monHoldsMail(mon) then return true end + local state = type(handle.save) == "table" and handle.save.mail or nil + local party = type(state) == "table" and state.party or nil + return type(party) == "table" and party[ref.index] ~= nil +end + +local function pickable(handle, index) + if type(handle) ~= "table" or type(handle.party) ~= "table" then + return nil, "that save can't be read" + end + local ref = refOf(index) + if not ref then + return nil, (type(index) == "table" and index.where == "box") + and "that's not in the PC" or "that's not in the party" + end + local mon = TeamPick.monAt(handle, ref) + if type(mon) ~= "table" then + return nil, (ref.where == "box") and "that's not in the PC" + or "that's not in the party" + end + if mon.isEgg then return nil, "an EGG can't be traded" end + if Trade.holdsMail(handle, ref) then return nil, "take the MAIL first" end + return mon, nil, ref +end + +-- ------- rebuild, evolve, dex + +local function packFor(generation, mon) + if generation == 2 then return Protocol.packMon2(mon) end + return Protocol.packMon(mon) +end + +local function packPartyFor(generation, party, indices) + if generation ~= 2 then return Protocol.packParty(party, indices) end + if Protocol.packParty2 then return Protocol.packParty2(party, indices) end + local mons = {} + if indices then + for _, i in ipairs(indices) do + mons[#mons + 1] = Protocol.packMon2(party[i]) + end + return mons + end + for _, mon in ipairs(party) do mons[#mons + 1] = Protocol.packMon2(mon) end + return mons +end + +-- engine/pokemon/evos_moves.asm +local function evolveGen1(data, record) + local def = data.pokemon and data.pokemon[record.species] + local into + for _, evo in ipairs((def and def.evolutions) or {}) do + if evo.method == "TRADE" then + into = evo.species + break + end + end + local newDef = into and data.pokemon and data.pokemon[into] + if not newDef then return nil end + local Stats = require("src.pokemon.Stats") + local evolved = deepCopy(record) + local previousMax = (record.stats and record.stats.hp) or record.hp or 0 + local lost = previousMax - (record.hp or previousMax) + evolved.species = into + evolved.stats = Stats.calc(newDef, evolved.level, evolved.dvs, evolved.statExp) + evolved.hp = math.max(1, evolved.stats.hp - lost) + if evolved.nickname and evolved.nickname == (def.name or record.species) then + evolved.nickname = nil + end + return into, evolved, false +end + +-- engine/pokemon/evolve.asm +local function evolveGen2(data, record) + local Evolution = require("src.core.gen2.Evolution") + local entry, consumes = Evolution.checkMon(data, record, { link = true }) + if not entry then return nil end + local evolved = Evolution.apply(data, record, entry) + if not evolved then return nil end + return entry.into, evolved, consumes == true +end + +local function markDex(save, generation, species) + local dex = save.pokedex + if type(dex) ~= "table" then + dex = {} + save.pokedex = dex + end + local ownedKey = generation == 2 and "caught" or "owned" + if type(dex.seen) ~= "table" then dex.seen = {} end + if type(dex[ownedKey]) ~= "table" then dex[ownedKey] = {} end + for _, id in ipairs(species) do + dex.seen[id] = true + dex[ownedKey][id] = true + end +end + +local function sideFor(handle, ref, packed, record, warnings) + return withData(handle, function(data) + local built, why = record, nil + if not built then + local unpacker = handle.generation == 2 + and Protocol.unpackMon2 or Protocol.unpackMon + built, why = unpacker(data, packed, { strict = true }) + if not built then return { error = why or "unknown POKéMON" } end + end + -- engine/battle/experience.asm:69 + built.traded = true + local into, evolved, consumed + if handle.generation == 2 then + into, evolved, consumed = evolveGen2(data, built) + else + into, evolved, consumed = evolveGen1(data, built) + end + local species = { built.species } + if into then species[#species + 1] = into end + if into then + warnings[#warnings + 1] = + { code = "evolve", slot = handle.slotId, species = into } + end + if consumed then + warnings[#warnings + 1] = + { code = "item_used", slot = handle.slotId, item = built.item } + end + return { + handle = handle, + ref = ref, + index = ref.index, + received = built, + record = evolved or built, + evolveTo = into, + dex = species, + sent = TeamPick.monAt(handle, ref), + } + end) +end + +-- ------- plan + +local function planFrom(sides, warnings) + local plan = { sides = sides, warnings = warnings } + for _, side in ipairs(sides) do + if side.role == "a" then + plan.get = side.record + plan.evolveA = side.evolveTo + plan.give = plan.give or side.sent + elseif side.role == "b" then + plan.give = side.record + plan.evolveB = side.evolveTo + end + end + return plan +end + +function Trade.plan(req) + req = type(req) == "table" and req or {} + local from, to = req.from, req.to + if type(from) ~= "table" or type(to) ~= "table" then + return nil, "two saves are needed" + end + if from.path == to.path then return nil, "that's the same save" end + local monA, reasonA, refA = pickable(from, req.fromIndex) + if not monA then return nil, reasonA end + local monB, reasonB, refB = pickable(to, req.toIndex) + if not monB then return nil, reasonB end + + local packA = packFor(from.generation, monA) + local packB = packFor(to.generation, monB) + if from.generation ~= to.generation then + if type(req.convert) ~= "function" then return nil, "needs_conversion" end + local intoB, whyB = req.convert(packA, from.generation, to.generation) + if not intoB then return nil, tostring(whyB or "needs_conversion") end + local intoA, whyA = req.convert(packB, to.generation, from.generation) + if not intoA then return nil, tostring(whyA or "needs_conversion") end + packA, packB = intoB, intoA + end + + local warnings = {} + local sideB, whyB = sideFor(to, refB, packA, nil, warnings) + if not sideB then return nil, tostring(whyB) end + if sideB.error then return nil, sideB.error end + local sideA, whyA = sideFor(from, refA, packB, nil, warnings) + if not sideA then return nil, tostring(whyA) end + if sideA.error then return nil, sideA.error end + sideA.role, sideB.role = "a", "b" + return planFrom({ sideA, sideB }, warnings) +end + +function Trade.planIncoming(req) + req = type(req) == "table" and req or {} + local to = req.to + if type(to) ~= "table" then return nil, "no save" end + local mon, reason, ref = pickable(to, req.toIndex) + if not mon then return nil, reason end + local warnings = {} + local side, why = sideFor(to, ref, req.mon, req.record, warnings) + if not side then return nil, tostring(why) end + if side.error then return nil, side.error end + side.role = "a" + return planFrom({ side }, warnings) +end + +-- ------- commit + +local function validateSave(save, generation, data) + if generation == 2 then + local Save2 = require("src.core.gen2.Save") + local report = Save2.validate(save) + if not Save2.emptyReport(report) then return false, "that save didn't validate" end + return true + end + local report = SaveData.validate(save, data) + if not SaveData.emptyReport(report) then return false, "that save didn't validate" end + return true +end + +local function buildSave(side) + local handle = side.handle + local save = deepCopy(handle.save) + local ref = side.ref or { where = "party", index = side.index } + if ref.where == "box" then + if type(save.boxes) ~= "table" then save.boxes = {} end + if type(save.boxes[ref.box]) ~= "table" then save.boxes[ref.box] = {} end + save.boxes[ref.box][ref.index] = side.record + else + if type(save.party) ~= "table" then save.party = {} end + save.party[ref.index] = side.record + end + markDex(save, handle.generation, side.dex) + if handle.generation == 2 then + local state = ref.where ~= "box" and type(save.mail) == "table" + and save.mail or nil + if state and type(state.party) == "table" then + state.party[ref.index] = nil + end + elseif handle.version == "yellow" and side.sent then + -- engine/link/cable_club.asm:801 + local previous = GameVersion.get() + GameVersion.set("yellow") + pcall(function() + require("src.world.PikachuFollower") + .modifyHappiness(save, "TRADE", side.sent) + end) + GameVersion.set(previous) + end + return save +end + +function Trade.commit(plan) + if type(plan) ~= "table" or type(plan.sides) ~= "table" + or #plan.sides == 0 then + return false, "no trade to make" + end + if Trade.gameIsLive() then return false, "close the game first" end + + local jobs = {} + for _, side in ipairs(plan.sides) do + local built, why = withData(side.handle, function(data) + local save = buildSave(side) + local encoded = SaveData.encode(save) + local decoded = SaveData.decode(encoded) + if type(decoded) ~= "table" then + return { error = "that save didn't encode" } + end + local ok, reason = validateSave(decoded, side.handle.generation, data) + if not ok then return { error = reason } end + return { save = save, encoded = encoded } + end) + if not built then return false, tostring(why) end + if built.error then return false, built.error end + jobs[#jobs + 1] = { side = side, path = side.handle.path, + save = built.save, encoded = built.encoded } + end + + local fs = disk() + if not fs then return false, "no filesystem" end + local stamp = tostring(os.time()) + local backups = {} + for _, job in ipairs(jobs) do + job.prevMain = fs.getInfo(job.path) and fs.read(job.path) or nil + job.prevBak = fs.getInfo(job.path .. ".bak") and fs.read(job.path .. ".bak") + or nil + job.backup = job.path .. ".trade-bak-" .. stamp + if job.prevMain then + local ok = fs.write(job.backup, job.prevMain) + if not ok then return false, "couldn't back that save up" end + backups[#backups + 1] = job.backup + end + end + + local function restore() + for _, job in ipairs(jobs) do + if job.prevMain then + fs.write(job.path, job.prevMain) + elseif fs.remove then + fs.remove(job.path) + end + if job.prevBak then + fs.write(job.path .. ".bak", job.prevBak) + elseif fs.remove then + fs.remove(job.path .. ".bak") + end + if fs.remove then fs.remove(job.path .. ".tmp") end + end + end + + for _, job in ipairs(jobs) do + local dir = job.path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then fs.createDirectory(dir) end + local ok, err = fs.write(job.path .. ".tmp", job.encoded) + if not ok then + restore() + return false, tostring(err or "couldn't write that save") + end + local body = fs.read(job.path .. ".tmp") + if body ~= job.encoded or type(SaveData.decode(body or "")) ~= "table" then + restore() + return false, "that save didn't write" + end + end + + for _, job in ipairs(jobs) do + if job.prevMain then fs.write(job.path .. ".bak", job.prevMain) end + if fs.remove then fs.remove(job.path) end + local ok, err = fs.write(job.path, job.encoded) + if not ok then + restore() + return false, tostring(err or "couldn't write that save") + end + end + + local handles = {} + for i, job in ipairs(jobs) do + if fs.remove then fs.remove(job.path .. ".tmp") end + local handle = job.side.handle + handle.save = job.save + handle.party = job.save.party + handle.boxes = type(job.save.boxes) == "table" and job.save.boxes or nil + handles[i] = handle + end + return true, handles, backups +end + +function Trade.pruneBackups(path, keep) + keep = math.max(0, tonumber(keep) or 3) + if type(path) ~= "string" or path == "" then return 0 end + local fs = disk() + if not fs or type(fs.getDirectoryItems) ~= "function" + or type(fs.remove) ~= "function" then + return 0 + end + local dir, base = path:match("^(.*)/([^/]+)$") + if not dir then dir, base = "", path end + local ok, items = pcall(fs.getDirectoryItems, dir) + if not ok or type(items) ~= "table" then return 0 end + local prefix = base .. ".trade-bak-" + local found = {} + for _, name in ipairs(items) do + if type(name) == "string" and name:sub(1, #prefix) == prefix then + local stamp = tonumber(name:sub(#prefix + 1)) + if stamp then found[#found + 1] = { name = name, stamp = stamp } end + end + end + table.sort(found, function(a, b) + if a.stamp ~= b.stamp then return a.stamp > b.stamp end + return a.name > b.name + end) + local removed = 0 + for i = keep + 1, #found do + local full = (dir == "") and found[i].name or (dir .. "/" .. found[i].name) + if fs.remove(full) then removed = removed + 1 end + end + return removed +end + +-- ------- remote + +local Remote = {} +Remote.__index = Remote + +function Trade.remote(handle, link, opts) + if type(handle) ~= "table" then return nil, "no save" end + if type(link) ~= "table" or type(link.send) ~= "function" then + return nil, "no room" + end + opts = type(opts) == "table" and opts or {} + local data, release = handle.data, nil + if not data then + if Trade.gameIsLive() then return nil, "close the game first" end + local mounted, freeOrReason = mountDataset(handle.version) + if not mounted then return nil, tostring(freeOrReason) end + data, release = mounted, freeOrReason + end + local session = Protocol.TradeSession.new(data, handle.party, { + subset = opts.subset, strict = opts.strict, peerName = opts.peerName, + }) + return setmetatable({ + handle = handle, + link = link, + data = data, + session = session, + release = release, + game = { data = data, save = handle.save }, + }, Remote) +end + +function Remote:_send(msg) + if type(msg) ~= "table" then return end + if msg.type == "party" and self.handle.generation == 2 then + msg = { type = "party", + mons = packPartyFor(2, self.session.party, + self.session.sendIndices) } + end + self.link:send(msg) +end + +function Remote:start() + self:_send(self.session:opening()) +end + +function Remote:_theirParty(msg) + local session = self.session + session.theirParty = {} + for _, packed in ipairs(msg.mons or {}) do + local mon, why = Protocol.unpackMon2(self.data, packed, + { strict = session.strict }) + if mon then + session.theirParty[#session.theirParty + 1] = mon + elseif session.strict then + session.stage = "cancelled" + session.error = why or "the other game sent an unknown POKéMON" + return + end + end + if session.stage == "waitParty" then session.stage = "picking" end +end + +function Remote:update() + if self.link.update then self.link:update() end + local messages = self.link:poll() or {} + for _, msg in ipairs(messages) do + if type(msg) == "table" and type(msg.type) == "string" then + if msg.type == "party" and self.handle.generation == 2 then + self:_theirParty(msg) + else + local reply = self.session:handle(msg) + if reply then self:_send(reply) end + end + end + end + return self.session.stage +end + +function Remote:stage() + return self.session.stage +end + +function Remote:canPick(index) + return self.session:canPick(index) +end + +function Remote:pick(index) + local mon, reason = pickable(self.handle, index) + if not mon then return false, reason end + self:_send(self.session:pick(index)) + return true +end + +function Remote:confirm(ok) + self:_send(self.session:confirm(ok and true or false)) + return true +end + +function Remote:plan() + local session = self.session + if session.stage ~= "done" then return nil, "the trade isn't finished" end + local record = session.theirParty[session.theirPick] + if type(record) ~= "table" then return nil, "the other game sent nothing" end + local handle = self.handle + local injected = handle.data + handle.data = self.data + local plan, reason = Trade.planIncoming({ + to = handle, toIndex = session.myPick, record = record, + }) + handle.data = injected + return plan, reason +end + +function Remote:commit() + local plan, reason = self:plan() + if not plan then return false, reason end + local handle = self.handle + local injected = handle.data + handle.data = self.data + local ok, result, backups = Trade.commit(plan) + handle.data = injected + return ok, result, backups +end + +function Remote:close() + if self.release then + self.release() + self.release = nil + end + if self.link.close then pcall(function() self.link:close() end) end +end + +return Trade diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 7cf4c752..5e9ba51c 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -787,6 +787,9 @@ end -- leaves the original. PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 } +-- engine/gfx/screen_effects.asm:1-12 +PaletteFX.POISON_BGP = { [0] = 2, [1] = 1, [2] = 2, [3] = 3 } + local shadeMap = nil function PaletteFX.setShadeMap(map) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 56722092..99c1175f 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -394,6 +394,13 @@ function TextBox:update(dt) return end if self.auto then + -- engine/items/item_effects.asm:1794 (#1880) + if self.auto.promptFirst and not self.autoPrompted then + if not (input:wasPressed("a") or input:wasPressed("b")) then return end + require("src.core.Sound").play(self.game.data, "Press_AB") + self.autoPrompted = true + return + end if not self.autoStarted then self.autoStarted = true self.autoSrc = self.auto.sound and self.auto.sound() or nil @@ -608,7 +615,15 @@ function TextBox:draw() Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE) else Font.drawBox(11, 0, 9, 3) + -- data/text_boxes.asm:35 + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8) love.graphics.setColor(0, 0, 0, 1) + local cap = 13 * 8 + for _, code in ipairs(Font.encode("MONEY")) do + drawGlyph(code, cap, 0) + cap = cap + Font.advanceOf(code) + end end local money = ("¥%d"):format(self.money() or 0) local pen = 152 - Font.width(money) @@ -617,7 +632,9 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - if (self.waiting or (self.done and not self.choice and not self.auto + if (self.waiting or (self.done and not self.choice + and (not self.auto + or (self.auto.promptFirst and not self.autoPrompted)) and (not self.stay or (self.stay.prompt and not self.stayShown)))) and self.blink < 30 then diff --git a/src/save_convert/Gen2Layout.lua b/src/save_convert/Gen2Layout.lua index d60511e1..b01f5f2e 100644 --- a/src/save_convert/Gen2Layout.lua +++ b/src/save_convert/Gen2Layout.lua @@ -39,6 +39,9 @@ Gen2Layout.goldSilver = { wYCoord = 0x286A, wEventFlags = 0x261F, wPlayerState = 0x24EA, + wStatusFlags = 0x23D9, + wStatusFlags2 = 0x23DA, + wPokegearFlags = 0x24E4, wGameTimeHours = 0x2053, wGameTimeMinutes = 0x2055, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -81,6 +84,9 @@ Gen2Layout.crystal = { wYCoord = 0x2845, wEventFlags = 0x2600, wPlayerState = 0x24EB, + wStatusFlags = 0x23DA, + wStatusFlags2 = 0x23DB, + wPokegearFlags = 0x24E5, wGameTimeHours = 0x2052, wGameTimeMinutes = 0x2054, -- The 14 archived boxes, listed rather than strided (see BOX_COUNT). @@ -123,6 +129,9 @@ Gen2Layout.crystal = { wYCoord = 0x1A45, wEventFlags = 0x1800, wPlayerState = 0x16EB, + wStatusFlags = 0x15DA, + wStatusFlags2 = 0x15DB, + wPokegearFlags = 0x16E5, wGameTimeHours = 0x1252, wGameTimeMinutes = 0x1254, boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 }, diff --git a/src/save_convert/Gen2Save.lua b/src/save_convert/Gen2Save.lua index 63369452..c6f3a43b 100644 --- a/src/save_convert/Gen2Save.lua +++ b/src/save_convert/Gen2Save.lua @@ -95,6 +95,7 @@ function Gen2Save.crosswalks(data) itemIndex = toIndex(data.items), mapIds = mapIds, itemDefs = data.items or {}, + moveDefs = data.moves or {}, } end @@ -147,18 +148,36 @@ local function decodeStatExp(bytes, o) } end +-- constants/pokemon_data_constants.asm:216, :218 +local function decodePPByte(b) return b % 64, math.floor(b / 64) % 4 end +local function encodePPByte(pp, ppUps) + return ((ppUps or 0) % 4) * 64 + ((pp or 0) % 64) +end + +-- engine/items/item_effects.asm:2736 +local function maxPPOf(base, ppUps) + if not base then return nil end + return base + (ppUps or 0) * math.min(math.floor(base / 5), 7) +end + -- The first 32 bytes, which a box mon and a party mon share. local function decodeSharedMon(bytes, o, x) - local moves, pp = {}, {} + local moves = {} for i = 0, 3 do local id = named(x.moves, u8(bytes, o + 2 + i)) - if id then moves[#moves + 1] = id end + if id then + local pp, ppUps = decodePPByte(u8(bytes, o + 0x17 + i)) + local def = x.moveDefs[id] + local base = type(def) == "table" and def.pp or nil + moves[#moves + 1] = { + id = id, pp = pp, ppUps = ppUps, maxPp = maxPPOf(base, ppUps), + } + end end - for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end return { species = named(x.pokemon, u8(bytes, o)), item = named(x.items, u8(bytes, o + 1)), - moves = moves, pp = pp, + moves = moves, otId = be(bytes, o + 6, 2), experience = be(bytes, o + 8, 3), statExp = decodeStatExp(bytes, o + 0x0B), @@ -299,6 +318,29 @@ local function decodeBadges(byte, order) return out end +-- data/events/engine_flags.asm:11-40 +local ENGINE_FLAG_BITS = { + wPokegearFlags = { { 1, 0 }, { 0, 1 }, { 2, 2 }, { 3, 3 }, { 4, 7 } }, + wStatusFlags = { { 11, 0 }, { 12, 1 }, { 13, 3 }, { 14, 4 }, { 15, 6 } }, + wStatusFlags2 = { + { 18, 0 }, { 17, 1 }, { 19, 4 }, { 20, 5 }, { 21, 6 }, { 22, 7 }, + }, +} + +local function decodeEngineFlags(bytes, L) + local out = {} + for field, rows in pairs(ENGINE_FLAG_BITS) do + local at = L[field] + if at then + local byte = u8(bytes, at) + for _, row in ipairs(rows) do + if math.floor(byte / 2 ^ row[2]) % 2 == 1 then out[row[1]] = true end + end + end + end + return out +end + Gen2Save.NUM_SPECIES = 251 Gen2Save.EVENT_BYTES = 256 @@ -369,6 +411,7 @@ function Gen2Save.decode(bytes, gameVersion, data) seen = decodeDex(bytes, L.wPokedexSeen, x), }, events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES), + engineFlags = decodeEngineFlags(bytes, L), -- Save.summary does `save.position.map or save.spawn`. position = { map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)], @@ -479,7 +522,9 @@ local function putSharedMon(t, o, mon, x) putU8(t, o, indexOf(x.pokemonIndex, mon.species)) putU8(t, o + 1, indexOf(x.itemIndex, mon.item)) for i = 0, 3 do - putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1])) + local mv = (mon.moves or {})[i + 1] + putU8(t, o + 2 + i, + indexOf(x.moveIndex, type(mv) == "table" and mv.id or mv)) end putBE(t, o + 6, mon.otId or 0, 2) putBE(t, o + 8, mon.experience or 0, 3) @@ -491,7 +536,12 @@ local function putSharedMon(t, o, mon, x) putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0)) putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0)) for i = 0, 3 do - putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + local mv = (mon.moves or {})[i + 1] + if type(mv) == "table" then + putU8(t, o + 0x17 + i, encodePPByte(mv.pp, mv.ppUps)) + else + putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0) + end end putU8(t, o + 0x1B, mon.happiness or 0) -- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template @@ -551,6 +601,22 @@ local function putBag(t, L, inventory, x) return overflow end +local function putEngineFlags(t, L, flags) + for field, rows in pairs(ENGINE_FLAG_BITS) do + local at = L[field] + if at then + local byte = t[at] or 0 + for _, row in ipairs(rows) do + local mask = 2 ^ row[2] + local on = math.floor(byte / mask) % 2 == 1 + local want = flags[row[1]] == true + if on ~= want then byte = want and (byte + mask) or (byte - mask) end + end + t[at] = byte + end + end +end + local function putFlagSet(t, at, set, count, indexFor) for i = 0, count - 1 do local byteAt = at + math.floor(i / 8) @@ -657,6 +723,9 @@ function Gen2Save.encode(save, gameVersion, template, data) local byte = (save.events or {})[i] if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end end + if type(save.engineFlags) == "table" then + putEngineFlags(t, L, save.engineFlags) + end local pos = save.position if pos then diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index d7319953..c0232d1d 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -23,8 +23,7 @@ -- ORIGINAL imported bytes as a template when available (GenSave.decode -- stashes them) so that scratch state round-trips untouched instead of -- being invented; with no template (a save that originated in this --- project) those bytes stay zero-filled, which is safe because the real --- game regenerates all of it from wCurMap on the next map load anyway. +-- project) src/save_convert/MapContext.lua rebuilds it (home/overworld.asm:2016). local bit = require("bit") local MapContext = require("src.save_convert.MapContext") @@ -1069,20 +1068,21 @@ function GenSave.encode(save, data, template) rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF) end if rebuild then - local ctx = MapContext.build(data, mapId, + local ctx, why = MapContext.build(data, mapId, (save.player and save.player.x) or 0, (save.player and save.player.y) or 0) - if ctx then - for offset, values in pairs(ctx.writes) do - for i, value in ipairs(values) do - setByte(buf, O.mainData + offset + i - 1, value) - end - end - for i, value in ipairs(ctx.spriteData) do - setByte(buf, O.spriteData + i - 1, value) - end - -- sTileAnimations, the byte between sCurBoxData and the checksum - setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) + -- home/overworld.asm:2016 (#1691) + if not ctx then + error(("this save cannot be exported: %s"):format(tostring(why)), 0) end + for offset, values in pairs(ctx.writes) do + for i, value in ipairs(values) do + setByte(buf, O.mainData + offset + i - 1, value) + end + end + for i, value in ipairs(ctx.spriteData) do + setByte(buf, O.spriteData + i - 1, value) + end + setByte(buf, O.checksumEnd - 1, ctx.tileAnimations) end end diff --git a/src/save_convert/MapContext.lua b/src/save_convert/MapContext.lua index 0443a992..7289dbe2 100644 --- a/src/save_convert/MapContext.lua +++ b/src/save_convert/MapContext.lua @@ -207,13 +207,13 @@ function MapContext.build(data, mapId, x, y) -- The tileset header, as predef LoadTilesetHeader would have copied it. local tilesets = data.tilesets or {} local tilesetDef = tilesets[map.tileset] - local tileAnimations = 0 - if tilesetDef and tilesetDef.header then - local row = {} - for i = 1, 11 do row[i] = tilesetDef.header[i] end - writes[O.tilesetHeader] = row - tileAnimations = tilesetDef.header[12] or 0 + if not (tilesetDef and tilesetDef.header) then + return nil, ("no tileset bytes for %s (re-import the ROM)"):format(tostring(mapId)) end + local row = {} + for i = 1, 11 do row[i] = tilesetDef.header[i] end + writes[O.tilesetHeader] = row + local tileAnimations = tilesetDef.header[12] or 0 -- MapSongBanks: without it the game continues with sound id 0 and audio -- bank 0, which is what actually hangs a Continue on a white screen. @@ -221,10 +221,11 @@ function MapContext.build(data, mapId, x, y) local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId] local song = songLabel and audio.songs and audio.songs[songLabel] local id = song and soundId(song.address) - if id and song.bank then - writes[O.mapMusicSoundID] = { id } - writes[O.mapMusicROMBank] = { song.bank % 256 } + if not (id and song.bank) then + return nil, ("no map music for %s (re-import the ROM)"):format(tostring(mapId)) end + writes[O.mapMusicSoundID] = { id } + writes[O.mapMusicROMBank] = { song.bank % 256 } -- Player position within its block, and the upper-left corner of the view. -- The pointer is the same expression the warp_to tables are assembled with diff --git a/src/sync/SyncClient.lua b/src/sync/SyncClient.lua index e0874152..3faa44f6 100644 --- a/src/sync/SyncClient.lua +++ b/src/sync/SyncClient.lua @@ -190,6 +190,17 @@ function SyncClient:fetchShare(code) { noAuth = true, params = { code = trimmed } }) end +function SyncClient:lobbyTicket() + return self:send("POST", "/lobby/ticket", {}) +end + +function SyncClient:setDisplayName(name) + if type(name) ~= "string" or name == "" then + return nil, "pick a display name first" + end + return self:send("POST", "/sync/displayname", { displayName = name }) +end + function SyncClient:unlink(device) return self:send("POST", "/sync/unlink", { device = device }) end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index d2d4313a..bc7142a5 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -13,6 +13,16 @@ SyncEngine.MAX_STEPS_PER_UPDATE = 8 local IDLE_STATUS = "Ready" local UNLINKED_STATUS = "Not set up" +local function unixSeconds(v) + local n = tonumber(v) + if not n or n ~= n or n <= 0 or n == math.huge or n == -math.huge then + return nil + end + return n +end + +SyncEngine.unixSeconds = unixSeconds + local function saveApi() return require("src.core.SaveData") end @@ -56,8 +66,9 @@ function SyncEngine.defaultSaves() playthroughId = id, blob = source, meta = { - savedAt = tonumber(meta.savedAt), - sessionStart = tonumber(meta.sessionStart), + savedAt = unixSeconds(meta.savedAt) + or unixSeconds(save.savedAt), + sessionStart = unixSeconds(meta.sessionStart), playthroughId = id, format = meta.format, engine = meta.engine, @@ -110,8 +121,8 @@ end function SyncEngine.overlaps(a, b) if type(a) ~= "table" or type(b) ~= "table" then return false end - local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt) - local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt) + local aStart, aEnd = unixSeconds(a.sessionStart), unixSeconds(a.savedAt) + local bStart, bEnd = unixSeconds(b.sessionStart), unixSeconds(b.savedAt) if not (aStart and aEnd and bStart and bEnd) then return false end return aStart <= bEnd and bStart <= aEnd end @@ -135,7 +146,7 @@ local function playedMinutes(meta) if hours then return tonumber(hours) * 60 + tonumber(minutes) end end local seconds = tonumber(meta.playTime) - if seconds then return math.floor(seconds / 60) end + if seconds and seconds > 0 then return math.floor(seconds / 60) end return nil end @@ -146,6 +157,26 @@ function SyncEngine.samePlaytime(a, b) return left ~= nil and left == right end +function SyncEngine.sameProgress(a, b) + if SyncEngine.samePlaytime(a, b) then return true end + if type(a) ~= "table" or type(b) ~= "table" then return false end + local left = type(a.summary) == "table" and a.summary or nil + local right = type(b.summary) == "table" and b.summary or nil + if not (left and right) then return false end + if type(left.name) ~= "string" or left.name == "" then return false end + return left.name == right.name and left.badges == right.badges + and left.timeText == right.timeText and left.dexCount == right.dexCount +end + +function SyncEngine.displayMeta(meta) + if type(meta) ~= "table" then return meta end + local out = {} + for k, v in pairs(meta) do out[k] = v end + out.savedAt = unixSeconds(meta.savedAt) + out.sessionStart = unixSeconds(meta.sessionStart) + return out +end + function SyncEngine.new(opts) opts = opts or {} local eng = setmetatable({}, SyncEngine) @@ -383,19 +414,20 @@ function SyncEngine:unlink() self:_forgetLocal() return true end - if self:busy() then return false, "sync is busy" end + if self:busy() then self:cancel() end self.phase = "checking" self.status = "Unlinking this device..." self.error = nil local handle, err = self.client:unlink(self.state.deviceId) + if not handle then + self:_forgetLocal() + return true + end return self:_request(handle, err, function(eng) eng:_forgetLocal() - end, function(eng, res) - if res.code == 401 or res.code == 404 then - eng:_forgetLocal() - return true - end - return false + end, function(eng) + eng:_forgetLocal() + return true end) end @@ -479,15 +511,20 @@ function SyncEngine:_planFrom(remoteState) seen[key] = true local row = remote[key] local knownRev = SyncState.rev(self.state, key) - local stamp = SyncState.stamp(self.state, key) - local localChanged = stamp == nil - or tonumber(entry.meta and entry.meta.savedAt) ~= stamp + local stamp = unixSeconds(SyncState.stamp(self.state, key)) + local liveStamp = unixSeconds(entry.meta and entry.meta.savedAt) + local localChanged + if liveStamp == nil and stamp == nil then + localChanged = knownRev == nil + else + localChanged = liveStamp ~= stamp + end local remoteRev = row and tonumber(row.rev) local remoteChanged = row ~= nil and remoteRev ~= knownRev if not row then self:_queueUpload(entry, key, false) elseif localChanged and remoteChanged then - if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + if SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then self:_queueUpload(entry, key, true) else self:_addConflict(entry, key, row) @@ -511,14 +548,14 @@ function SyncEngine:_planFrom(remoteState) end function SyncEngine:_addConflict(entry, key, row) - local remoteMeta = SyncEngine.metaOf(row) + local remoteMeta = SyncEngine.displayMeta(SyncEngine.metaOf(row)) self.conflicts[#self.conflicts + 1] = { key = key, version = entry.version, playthroughId = entry.playthroughId, slot = entry.slot, entry = entry, - localMeta = entry.meta, + localMeta = SyncEngine.displayMeta(entry.meta), remoteMeta = remoteMeta, remoteRev = tonumber(row.rev), overlap = SyncEngine.overlaps(entry.meta, remoteMeta), @@ -551,7 +588,7 @@ function SyncEngine:_queueUpload(entry, key, force) eng:_request(handle, err, function(e, res) local data = res.data or {} SyncState.setRev(e.state, key, tonumber(data.rev), - entry.meta and entry.meta.savedAt) + unixSeconds(entry.meta and entry.meta.savedAt)) e:_persist() if not e:busy() then e:_finish() end end, function(e, res) @@ -560,7 +597,7 @@ function SyncEngine:_queueUpload(entry, key, force) -- Retried with force only once: a forced write that still 409s is a -- real refusal, and retrying it would spin. if not force - and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + and SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then e:_queueUpload(entry, key, true) else e:_addConflict(entry, key, row) @@ -592,7 +629,7 @@ function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev) if mode ~= "new" then local meta = type(data.meta) == "table" and data.meta or {} SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev, - tonumber(meta.savedAt)) + unixSeconds(meta.savedAt)) end e:_persist() if not e:busy() then e:_finish() end diff --git a/src/sync/SyncState.lua b/src/sync/SyncState.lua index 53e4e840..8040f8bc 100644 --- a/src/sync/SyncState.lua +++ b/src/sync/SyncState.lua @@ -35,6 +35,7 @@ function SyncState.sanitize(raw) out.deviceToken = str(raw.deviceToken) out.deviceId = str(raw.deviceId) out.deviceLabel = str(raw.deviceLabel) + out.displayName = str(raw.displayName) out.lastSyncAt = num(raw.lastSyncAt) or 0 if type(raw.revs) == "table" then for key, rev in pairs(raw.revs) do diff --git a/src/ui/ArenaState.lua b/src/ui/ArenaState.lua new file mode 100644 index 00000000..43b8dd7e --- /dev/null +++ b/src/ui/ArenaState.lua @@ -0,0 +1,100 @@ +local ArenaBoot = require("src.online.ArenaBoot") +local LinkBattle = require("src.link.LinkBattle") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") + +local ArenaState = {} +ArenaState.__index = ArenaState +ArenaState.isOpaque = true +ArenaState.screenId = "ArenaState" + +function ArenaState.new(game, spec) + local self = setmetatable({}, ArenaState) + self.game = game + self.spec = spec + self.stage = "boot" + self.battle = nil + self.finished = false + return self +end + +function ArenaState:report(result) + if self.finished then return end + self.finished = true + local onDone = self.spec and self.spec.onDone + if onDone then pcall(onDone, result) end +end + +function ArenaState:leave() + local game = self.game + game.linkNet = nil + game.linkSession = nil + if game.stack and game.stack:top() == self then game.stack:pop() end + if type(game.returnToLauncher) == "function" then + game.returnToLauncher({ tab = "online" }) + end +end + +function ArenaState:fail(reason) + Logger.error("arena: battle could not start (%s)", tostring(reason)) + self.stage = "done" + self:report("error") + self:leave() +end + +function ArenaState:enter() + local game, spec = self.game, self.spec + game.linkNet = spec.session + game.linkSession = true + + if spec.role ~= "spectator" then + local packed, packErr = ArenaBoot.packOwnParty(game, spec) + if not packed then return self:fail(packErr) end + end + + local opts, optsErr = ArenaBoot.battleOpts(spec) + if not opts then return self:fail(optsErr) end + + local battle, why + if spec.role == "spectator" then + battle, why = LinkBattle.newSpectator(game, spec.session, opts) + elseif spec.role == "guest" then + battle, why = LinkBattle.newGuest(game, spec.session, opts) + else + battle, why = LinkBattle.newHost(game, spec.session, opts) + end + if not battle then return self:fail(why) end + + self.battle = battle + self.stage = "running" + game.stack:push(battle) +end + +function ArenaState:update(_dt) + if self.stage ~= "running" then return end + if self.game.stack:top() ~= self then return end + self.stage = "done" + local battle = self.battle + local result = (battle and battle.result) or "ended" + if battle and Runtime.wants("link.battle_ended") then + Runtime.emit("link.battle_ended", { + result = result, + myParty = battle.playerParty, + theirParty = battle.enemyParty, + peerName = self.spec.peerName, + role = self.spec.role, + }) + end + self.battle = nil + self:report(result) + self:leave() +end + +function ArenaState:draw() + local G = love.graphics + G.setColor(0, 0, 0, 1) + G.rectangle("fill", 0, 0, 160, 144) + G.setColor(1, 1, 1, 1) +end + +return ArenaState diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index d810ce0c..a952f80c 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -116,14 +116,16 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker) -- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake) if result == "flute_wake" then closeBag() - require("src.core.Sound").play(game.data, "Pokeflute") + -- engine/items/item_effects.asm:1794 (#1880) + local opts = TextBox.soundOpts(game, "Pokeflute", + { auto = { wait = false, delay = 0, promptFirst = true } }) showMessages(game, payload, function() local ow = game.overworld local mod = ow and require("data.scripts.init").get(extra.mapId) if ow and mod and mod.snorlaxWake then ow.runner:run(mod.snorlaxWake.script, { npc = extra.npc }) end - end) + end, opts) return end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 41f16fd6..fcc85d71 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -5,6 +5,7 @@ local Font = require("src.render.Font") local Runtime = require("src.mods.Runtime") local Theme = require("src.ui.Theme") +local MenuRepeat = require("src.ui.MenuRepeat") local Strings = require("src.core.Strings") local ListMenu = {} @@ -54,14 +55,18 @@ local ITEM_CURSOR_X = 40 local ITEM_QTY_X, ITEM_QTY_END = 112, 136 local ITEM_MORE_X, ITEM_MORE_Y = 144, 88 -- frames to wait before key-repeat kicks in, then between repeats -local REPEAT_DELAY = 16 -local REPEAT_RATE = 4 +local REPEAT_DELAY = MenuRepeat.GEN1_DELAY +local REPEAT_RATE = MenuRepeat.GEN1_RATE +local UPDOWN_DIRS = { "up", "down" } +local NAV_DIRS = { "up", "down", "left", "right" } -- ui.list_menu identity: unhooked opts pass through unchanged local function sameOpts(opts) return opts end function ListMenu.new(game, title, items, opts) opts = opts or {} + -- home/list_menu.asm:8 + opts.keyRepeat = opts.keyRepeat ~= false -- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat if Runtime.wantsHook("ui.list_menu") then local hooked = Runtime.call("ui.list_menu", sameOpts, { @@ -99,8 +104,7 @@ function ListMenu.new(game, title, items, opts) self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll self.repeatDelay = opts.repeatDelay or REPEAT_DELAY self.repeatRate = opts.repeatRate or REPEAT_RATE - self.holdDir = nil - self.holdFrames = 0 + self.hold = MenuRepeat.new(self.repeatDelay, self.repeatRate, self.keyRepeat) self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item -- scripted mode (the old man tutorial): update() runs the script -- every frame INSTEAD of reading input -- DisplayListMenuID's old-man @@ -201,18 +205,10 @@ function ListMenu:update(dt) end local moved = false - if input:wasPressed("up") then - moved = navPressed(self, "up") - self.holdDir, self.holdFrames = "up", 0 - elseif input:wasPressed("down") then - moved = navPressed(self, "down") - self.holdDir, self.holdFrames = "down", 0 - elseif self.pageJump and input:wasPressed("left") then - moved = navPressed(self, "left") - self.holdDir, self.holdFrames = "left", 0 - elseif self.pageJump and input:wasPressed("right") then - moved = navPressed(self, "right") - self.holdDir, self.holdFrames = "right", 0 + local dir = MenuRepeat.direction(self.hold, input, + self.pageJump and NAV_DIRS or UPDOWN_DIRS) + if dir then + moved = navPressed(self, dir) elseif self.onSelectKey and input:wasPressed("select") then self.onSelectKey(self.items[self.index], self) elseif input:wasPressed("b") then @@ -229,20 +225,6 @@ function ListMenu:update(dt) return end - -- hold-to-scroll (opt-in via ui.list_menu keyRepeat) - if self.keyRepeat then - local dir = self.holdDir - if dir and input:isDown(dir) then - self.holdFrames = self.holdFrames + 1 - local afterDelay = self.holdFrames - self.repeatDelay - if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then - navPressed(self, dir) - end - else - self.holdDir, self.holdFrames = nil, 0 - end - end - if not moved then syncScroll(self) end end @@ -306,6 +288,9 @@ function ListMenu:drawItemBox() if item.sub then -- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461) Font.draw(item.sub, ITEM_QTY_X, y + 8) + elseif item.price then + -- home/list_menu.asm:410-424 + Font.draw(item.price, ITEM_QTY_END - Font.width(item.price), y + 8) elseif item.right then -- '×' at column 14, PrintNumber's two right-aligned digits after it -- (home/list_menu.asm:479-490) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 00079f18..3ba64d19 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -34,6 +34,8 @@ function Menu.new(game, items, opts) if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end end self.rowStep = opts.rowStep or 2 + -- home/window.asm:56-83 + self.noWrap = opts.noWrap or false -- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox) self.title = opts.title self.itemY = opts.itemY @@ -82,9 +84,11 @@ end function Menu:update(dt) local input = self.game.input if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or #self.items + self.index = self.index > 1 and self.index - 1 + or (self.noWrap and 1 or #self.items) elseif input:wasPressed("down") then - self.index = self.index < #self.items and self.index + 1 or 1 + self.index = self.index < #self.items and self.index + 1 + or (self.noWrap and #self.items or 1) elseif input:wasPressed("a") then -- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press if not self.noSound then diff --git a/src/ui/MenuRepeat.lua b/src/ui/MenuRepeat.lua new file mode 100644 index 00000000..d58dde3b --- /dev/null +++ b/src/ui/MenuRepeat.lua @@ -0,0 +1,43 @@ +-- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5) +-- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5) + +local MenuRepeat = {} + +MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE = 30, 5 +MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE = 15, 5 + +local ALL_DIRS = { "up", "down", "left", "right" } + +function MenuRepeat.new(delay, rate, enabled) + return { + delay = tonumber(delay) or MenuRepeat.GEN1_DELAY, + rate = math.max(1, tonumber(rate) or MenuRepeat.GEN1_RATE), + enabled = enabled ~= false, + dir = nil, + frames = 0, + } +end + +function MenuRepeat.reset(state) + state.dir, state.frames = nil, 0 +end + +function MenuRepeat.direction(state, input, dirs) + for _, dir in ipairs(dirs or ALL_DIRS) do + if input:wasPressed(dir) then + state.dir, state.frames = dir, 0 + return dir, true + end + end + local dir = state.dir + if not (dir and state.enabled and input.isDown and input:isDown(dir)) then + MenuRepeat.reset(state) + return nil, false + end + state.frames = state.frames + 1 + local afterDelay = state.frames - state.delay + if afterDelay >= 0 and afterDelay % state.rate == 0 then return dir, false end + return nil, false +end + +return MenuRepeat diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 34b1a700..f9719d54 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -731,14 +731,7 @@ function PartyMenu:update(dt) end end --- The bottom-of-screen context message for the current menu state --- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_): --- the party menu always prints a message in the bottom text box. With the --- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") --- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / --- item / TM-HM ids print their own strings, and EVO_STONE shares --- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers). --- Pure (no side effects) so drivers can assert it. #147 #1610 +-- engine/menus/party_menu.asm:229 (#147 #1610 #1901) function PartyMenu:bottomMessage() if self.swapFrom then return self.game.data.text._PartyMenuSwapMonText @@ -749,7 +742,7 @@ function PartyMenu:bottomMessage() elseif self.softboiledFrom or self.itemUse then return self.game.data.text._PartyMenuItemUseText or Strings("Use item on which\nPOKéMON?") - elseif self.battle then + elseif self.forceSwitch then return self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") else diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 821eb49e..e59f5769 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -4,6 +4,7 @@ local Font = require("src.render.Font") local Strings = require("src.core.Strings") local Theme = require("src.ui.Theme") +local MenuRepeat = require("src.ui.MenuRepeat") local PokedexMenu = {} PokedexMenu.__index = PokedexMenu @@ -46,6 +47,8 @@ function PokedexMenu.new(game, opts) self.onCancel = opts.onCancel -- B returns to the start menu when opened from it self.index = 1 self.scroll = 0 + -- engine/menus/pokedex.asm:13 + self.hold = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE) self.rowsAt = require("src.core.GameVersion").isYellow() and YELLOW_ROWS or RED_ROWS local dex = game.save.pokedex or { seen = {}, owned = {} } @@ -153,13 +156,14 @@ function PokedexMenu:update(dt) end return end - if input:wasPressed("up") then + local dir = MenuRepeat.direction(self.hold, input) + if dir == "up" then self.index = self.index - 1 - elseif input:wasPressed("down") then + elseif dir == "down" then self.index = self.index + 1 - elseif input:wasPressed("left") then + elseif dir == "left" then self:pageScroll(-1) - elseif input:wasPressed("right") then + elseif dir == "right" then self:pageScroll(1) elseif input:wasPressed("b") then beep(self) diff --git a/src/ui/PrizeCounter.lua b/src/ui/PrizeCounter.lua new file mode 100644 index 00000000..9ac6c78b --- /dev/null +++ b/src/ui/PrizeCounter.lua @@ -0,0 +1,66 @@ +-- engine/events/prize_menu.asm CeladonPrizeMenu + +local Font = require("src.render.Font") +local Strings = require("src.core.Strings") +local Theme = require("src.ui.Theme") + +local PrizeCounter = {} +PrizeCounter.__index = PrizeCounter + +local NAME_X, TOP_Y, ROW_STEP = 16, 32, 16 +local CURSOR_X = 8 +local FIELD_END = 136 +local ROWS = 4 + +function PrizeCounter.new(game, prizes, opts) + opts = opts or {} + return setmetatable({ + game = game, + prizes = prizes, + index = 1, + onPick = opts.onPick, + onCancel = opts.onCancel, + }, PrizeCounter) +end + +function PrizeCounter:update(dt) + local input = self.game.input + -- home/window.asm:56-83 + if input:wasPressed("up") then + if self.index > 1 then self.index = self.index - 1 end + elseif input:wasPressed("down") then + if self.index < ROWS then self.index = self.index + 1 end + elseif input:wasPressed("a") or input:wasPressed("b") then + require("src.core.Sound").play(self.game.data, "Press_AB") + if input:wasPressed("b") or self.index >= ROWS then + if self.onCancel then self.onCancel() end + elseif self.onPick then + self.onPick(self.prizes[self.index], self.index) + end + end +end + +function PrizeCounter:draw() + local coins = tostring((self.game.save and self.game.save.coins) or 0) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(11, 0, 9, 3) + love.graphics.rectangle("fill", 12 * 8, 0, 4 * 8, 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("COIN"), 12 * 8, 0) + Font.draw(coins, FIELD_END - Font.width(coins), 8) + love.graphics.setColor(1, 1, 1, 1) + Font.drawBox(0, 2, 18, 10) + love.graphics.setColor(0, 0, 0, 1) + for i, p in ipairs(self.prizes) do + local y = TOP_Y + (i - 1) * ROW_STEP + Font.draw(p.name, NAME_X, y) + local cost = tostring(p.cost) + Font.draw(cost, FIELD_END - Font.width(cost), y + 8) + end + -- data/events/prizes.asm + Font.draw(Strings("NO THANKS"), NAME_X, TOP_Y + 3 * ROW_STEP) + Font.drawCode(Theme.cursor, CURSOR_X, TOP_Y + (self.index - 1) * ROW_STEP) + love.graphics.setColor(1, 1, 1, 1) +end + +return PrizeCounter diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index 7d8b22d0..ccbb113b 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -45,22 +45,31 @@ local function buy(game, stock, menu) table.insert(items, { value = id, label = def.name, - right = ("¥%d"):format(def.price), + price = ("¥%d"):format(def.price), }) end end + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local notEnough = txt(game, "_PokemartNotEnoughMoneyText", Strings("You don't have\nenough money.")) local bagFull = txt(game, "_PokemartItemBagFullText", Strings("You can't carry\nany more items.")) local list - list = ListMenu.new(game, "BUY", items, { + list = ListMenu.new(game, nil, items, { dialogue = true, + -- home/list_menu.asm:29-31 + itemBox = true, money = function() return game.save.money end, footer = greet, onCancel = function() menu.footer = anythingElse(game) end, onChoose = function(item) + -- home/list_menu.asm:105-110, 523-528 + if item.cancel then + list:close() + menu.footer = anythingElse(game) + return + end local def = game.data.items[item.value] if game.save.money < def.price then list.footer = notEnough @@ -109,24 +118,32 @@ local function buy(game, stock, menu) game.stack:push(list) end +-- home/list_menu.asm:472-477 +local function sellItems(game) + local items = {} + for _, id in ipairs(Bag.order(game.save)) do + local def = game.data.items[id] + local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil + table.insert(items, { + value = id, + label = def and def.name or id, + right = (not keyed) and ("x" .. game.save.inventory[id]) or nil, + }) + end + items[#items + 1] = { cancel = true, label = Strings("CANCEL") } + return items +end + local function sell(game, menu) -- Sell list is ITEMLISTMENU with wPrintItemPrices cleared -- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows -- in the quantity chooser. Stuffing "xN" into the label next to a -- right-aligned ¥ price made long names overlap (issue #116). - local items = {} - for _, id in ipairs(Bag.order(game.save)) do - local def = game.data.items[id] - table.insert(items, { - value = id, - label = def and def.name or id, - right = "x" .. game.save.inventory[id], - }) - end + local items = sellItems(game) -- engine/events/pokemart.asm:50 local greet = txt(game, "_PokemonSellingGreetingText", Strings("What would you\nlike to sell?")) - if #items == 0 then + if #Bag.order(game.save) == 0 then refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText", Strings("You don't have\nanything to sell."))) return @@ -134,13 +151,16 @@ local function sell(game, menu) local unsellable = txt(game, "_PokemartUnsellableItemText", Strings("I can't put a\nprice on that.")) local list - list = ListMenu.new(game, "SELL", items, { + list = ListMenu.new(game, nil, items, { dialogue = true, + -- home/list_menu.asm:29-31 + itemBox = true, money = function() return game.save.money end, footer = greet, onCancel = function() menu.footer = anythingElse(game) end, onSelectKey = function(item, l) - if not item then return end + -- swap_items.asm:19-22 + if not item or item.cancel then return end if not l.swapIndex then l.swapIndex = l.index return @@ -149,18 +169,15 @@ local function sell(game, menu) order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex] l.swapIndex = nil require("src.core.Sound").play(game.data, "Swap") - local rebuilt = {} - for _, id in ipairs(order) do - local def = game.data.items[id] - rebuilt[#rebuilt + 1] = { - value = id, - label = def and def.name or id, - right = "x" .. game.save.inventory[id], - } - end - l.items = rebuilt + l.items = sellItems(game) end, onChoose = function(item) + -- home/list_menu.asm:105-110, 523-528 + if item.cancel then + list:close() + menu.footer = anythingElse(game) + return + end local def = game.data.items[item.value] -- only key items and HMs are unsellable (pokemart.asm IsKeyItem / -- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id @@ -214,7 +231,10 @@ local function drawClerk(menu) local game = menu.game love.graphics.setColor(1, 1, 1, 1) Font.drawBox(11, 0, 9, 3) + -- data/text_boxes.asm:35 + love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8) love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("MONEY"), 13 * 8, 0) local money = ("¥%d"):format((game.save and game.save.money) or 0) Font.draw(money, 152 - Font.width(money), 8) love.graphics.setColor(1, 1, 1, 1) @@ -247,7 +267,8 @@ function ShopMenu.new(game, stock, onQuit) { label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end }, { label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end }, { label = Strings("QUIT"), onSelect = farewell }, - }, { tx = 0, ty = 0, tw = 8, th = 8 }) + -- data/text_boxes.asm:34 + }, { tx = 0, ty = 0, tw = 11, th = 7 }) menu.onCancel = farewell menu.footer = txt(game, "_PokemartGreetingText", Strings("Hi there!\nMay I help you?")) diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 2537bf28..75885878 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -1,6 +1,6 @@ -- The START menu (engine/menus/start_menu.asm): entries appear as they -- become usable -- POKéDEX once Oak gives it, POKéMON once you have any, --- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. The built +-- SAVE with a confirmation, plus ITEM / OPTION / QUIT. The built -- item list runs through the ui.start_menu.items hook before the menu -- opens, so mods insert or remove rows without patching this file. @@ -142,14 +142,6 @@ function StartMenu.new(game) Screens.push(game, "OptionsMenu", { onCancel = reopen }) end }) - -- LINK needs a party - if #game.save.party > 0 then - table.insert(items, { label = Strings("LINK"), onSelect = function() - local LinkState = require("src.link.LinkState") - game.stack:push(LinkState.new(game)) - end }) - end - -- the manager's pause-menu entry (18-mod-manager-ux): gated on at least -- one discovered mod so a vanilla install's menu is unchanged local status = game.modStatus @@ -184,7 +176,7 @@ function StartMenu.new(game) -- (engine/menus/draw_start_menu.asm), so START closes it back to the -- overworld -- unlike most menus, whose masks omit PAD_START. -- - -- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state, + -- item count isn't fixed: POKéDEX/MODS come and go with save state, -- and mods can append their own rows through the hook above, so the -- double-spaced box (the original's style) can grow past the 18-tile -- canvas. Cap it at however many rows actually fit and scroll the rest, diff --git a/src/ui/TownMap.lua b/src/ui/TownMap.lua index a0a57460..ca4a3f00 100644 --- a/src/ui/TownMap.lua +++ b/src/ui/TownMap.lua @@ -18,11 +18,15 @@ local GameVersion = require("src.core.GameVersion") local PaletteFX = require("src.render.PaletteFX") local Sound = require("src.core.Sound") local SpriteRenderer = require("src.render.SpriteRenderer") +local Theme = require("src.ui.Theme") local TownMap = {} TownMap.__index = TownMap TownMap.isOpaque = true +-- engine/items/town_map.asm:183 +local ARROW_DELAY = 15 + -- SGB: PalPacket_TownMap, whole screen function TownMap:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "TOWNMAP") @@ -47,12 +51,27 @@ local function isRoute(loc) return loc.name:find("ROUTE", 1, true) ~= nil end +-- data/maps/town_map_order.asm:1 +local function orderByCursorOrder(byMap, order) + if type(order) ~= "table" then return nil end + local out, seen = {}, {} + for _, mapId in ipairs(order) do + local loc = byMap[mapId] + if loc and not seen[loc] then + seen[loc] = true + out[#out + 1] = loc + end + end + return #out >= 2 and out or nil +end + -- Build the ordered location list. Grid mode dedupes shared entries -- (interior maps point at their town's square); list mode falls back to -- the fly towns so the screen still works without townMap data. local function buildLocations(game) local field = game.data.field or {} local townMap = field.townMap + local cursorOrder = type(townMap) == "table" and townMap.cursorOrder or nil -- the extractor nests the per-map entries under .locations if type(townMap) == "table" and type(townMap.locations) == "table" then townMap = townMap.locations @@ -80,7 +99,7 @@ local function buildLocations(game) if a.x ~= b.x then return a.x < b.x end return a.name < b.name end) - return locs, byMap, "grid" + return orderByCursorOrder(byMap, cursorOrder) or locs, byMap, "grid", locs end end -- fallback: towns from the fly order (deduped, outdoor maps only) @@ -193,7 +212,7 @@ function TownMap.new(game, opts) local self = setmetatable({}, TownMap) self.game = game self.bg = loadBackground(game) - self.locs, self.byMap, self.mode = buildLocations(game) + self.locs, self.byMap, self.mode, self.allLocs = buildLocations(game) if opts.nestSpecies then self.nestSpecies = opts.nestSpecies self.nests = {} @@ -207,7 +226,8 @@ function TownMap.new(game, opts) if found then break end end local loc = found and self.byMap[mapId] - if loc and not seen[loc] then + -- engine/items/town_map.asm:388 + if loc and not seen[loc] and not (loc.x == 9 and loc.y == 1) then seen[loc] = true table.insert(self.nests, loc) end @@ -255,41 +275,34 @@ function TownMap.new(game, opts) self.birdSheet, self.birdQuad = markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"] or sprites.SPRITE_BIRD, "bird") + -- engine/items/town_map.asm:150 + local art = ((game.data.field or {}).townMap or {}).upArrow + local okArrow, arrow = pcall(love.graphics.newImage, + (art and art.path) + or "assets/generated/townmap/up_arrow.png") + self.upArrow = okArrow and arrow or nil + -- engine/items/town_map.asm:170, 183 + self.arrowHide, self.arrowDelay = "up", ARROW_DELAY end self.sel = 1 -- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST -- fly destination (PALLET_TOWN), never the player's current town (#795). -- Only the plain viewer snaps the cursor to where the player stands. if not self.fly then + local found = false for i, loc in ipairs(self.locs) do - if loc == self.playerLoc then self.sel = i break end + if loc == self.playerLoc then self.sel = i found = true break end + end + -- engine/items/town_map.asm:29 + if self.playerLoc and not found then + table.insert(self.locs, self.playerLoc) + self.sel = #self.locs end end self.blink = 0 return self end --- snap the cursor to the nearest location in the pressed direction -function TownMap:moveGrid(dx, dy) - local cur = self.locs[self.sel] - local best, bestScore - for i, loc in ipairs(self.locs) do - if i ~= self.sel then - local ddx, ddy = loc.x - cur.x, loc.y - cur.y - local fwd = ddx * dx + ddy * dy -- progress along the d-pad axis - local side = math.abs(ddx * dy) + math.abs(ddy * dx) - if fwd > 0 then - local score = fwd + side * 3 -- prefer staying on-axis - if not best or score < bestScore then best, bestScore = i, score end - end - end - end - if best then - self.sel = best - Sound.play(self.game.data, "Tink") - end -end - function TownMap:moveList(step) local n = #self.locs if n < 2 then return end @@ -300,6 +313,10 @@ end function TownMap:update(dt) local cycle = GameVersion.generation() == 2 and 32 or 50 self.blink = (self.blink + 1) % cycle + if self.arrowDelay and self.arrowDelay > 0 then + self.arrowDelay = self.arrowDelay - 1 + if self.arrowDelay == 0 then self.arrowHide = nil end + end local input = self.game.input if input:wasPressed("b") then Sound.play(self.game.data, "Press_AB") @@ -318,8 +335,12 @@ function TownMap:update(dt) self.game.stack:pop() if mapId and self.onFly then self.onFly(mapId) end return - elseif input:wasPressed("up") then self:moveList(1) - elseif input:wasPressed("down") then self:moveList(-1) + elseif input:wasPressed("up") then + self:moveList(1) + self.arrowHide, self.arrowDelay = "up", ARROW_DELAY + elseif input:wasPressed("down") then + self:moveList(-1) + self.arrowHide, self.arrowDelay = "down", ARROW_DELAY end elseif self.nestSpecies then if input:wasPressed("a") then @@ -327,10 +348,9 @@ function TownMap:update(dt) self.game.stack:pop() end elseif self.mode == "grid" then - if input:wasPressed("up") then self:moveGrid(0, -1) - elseif input:wasPressed("down") then self:moveGrid(0, 1) - elseif input:wasPressed("left") then self:moveGrid(-1, 0) - elseif input:wasPressed("right") then self:moveGrid(1, 0) + -- engine/items/town_map.asm:74 + if input:wasPressed("up") then self:moveList(1) + elseif input:wasPressed("down") then self:moveList(-1) end else if input:wasPressed("up") then self:moveList(-1) @@ -345,6 +365,22 @@ function TownMap:markPlayerRedraw(x, y) PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y) end +-- engine/items/town_map.asm:170, 185 +function TownMap:drawFlyArrows() + if self.arrowHide ~= "up" then + if self.upArrow then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(self.upArrow, 144, 0) + love.graphics.setColor(0, 0, 0, 1) + else + love.graphics.polygon("fill", 148, 1, 152, 7, 144, 7) + end + end + if self.arrowHide ~= "down" then + Font.drawCode(Theme.moreArrow, 152, 0) + end +end + local function drawSquare(loc) if isRoute(loc) then love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter @@ -385,12 +421,26 @@ function TownMap:draw() end end end + if #self.nests > 0 then + -- engine/items/town_map.asm:399 + if self.playerLoc and self.playerSheet then + local x, y = markerXY(self.playerLoc) + love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) + self:markPlayerRedraw(x - 4, y - 3) + end + else + -- engine/items/town_map.asm:403 + Font.drawBox(1, 7, 17, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(" AREA UNKNOWN", 16, 72) + love.graphics.setColor(1, 1, 1, 1) + end love.graphics.rectangle("fill", 0, 0, 160, 8) love.graphics.setColor(0, 0, 0, 1) local def = self.game.data.pokemon[self.nestSpecies] local name = def and def.name or self.nestSpecies - Font.draw(#self.nests > 0 and (name .. "'s NEST") - or (name .. " AREA UNKNOWN"), 8, 0) + -- engine/items/town_map.asm:124 + Font.draw(name .. "'s NEST", 8, 0) love.graphics.setColor(1, 1, 1, 1) return end @@ -437,7 +487,14 @@ function TownMap:draw() -- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name) love.graphics.rectangle("fill", 0, 0, 160, 8) love.graphics.setColor(0, 0, 0, 1) - if selected then Font.draw(self:bannerText(selected), 8, 0) end + if self.fly then + -- engine/items/town_map.asm:167, 176, 185 + Font.draw("To", 0, 0) + if selected then Font.draw(selected.name, 24, 0) end + self:drawFlyArrows() + elseif selected then + Font.draw(self:bannerText(selected), 8, 0) + end love.graphics.setColor(1, 1, 1, 1) return end @@ -446,7 +503,7 @@ function TownMap:draw() Font.drawBox(0, 0, 20, 18) if self.mode == "grid" then -- stale assets (no background art): the old abstract squares - for _, loc in ipairs(self.locs) do + for _, loc in ipairs(self.allLocs or self.locs) do drawSquare(loc) end -- player marker is static in both Gen 1 and 2 diff --git a/src/ui/gen2/ArenaState.lua b/src/ui/gen2/ArenaState.lua new file mode 100644 index 00000000..33fcf917 --- /dev/null +++ b/src/ui/gen2/ArenaState.lua @@ -0,0 +1,137 @@ +local ArenaBoot = require("src.online.ArenaBoot") +local Chrome = require("src.ui.gen2.Chrome") +local LinkBattle2 = require("src.link.LinkBattle2") +local Logger = require("src.core.Logger") +local Runtime = require("src.mods.Runtime") +local Strings = require("src.core.Strings") + +local ArenaState = {} +ArenaState.__index = ArenaState +ArenaState.isOpaque = true +ArenaState.screenId = "Gen2ArenaState" + +local HOLD_FRAMES = 300 + +function ArenaState:wantsFillScale() return true end +function ArenaState:drawsWidescreen() return true end + +function ArenaState.new(game, spec) + local self = setmetatable({}, ArenaState) + self.game = game + self.spec = spec + self.stage = "boot" + self.battle = nil + self.frames = 0 + self.finished = false + self.message = nil + return self +end + +function ArenaState:report(result) + if self.finished then return end + self.finished = true + local onDone = self.spec and self.spec.onDone + if onDone then pcall(onDone, result) end +end + +function ArenaState:leave() + local game = self.game + game.linkNet = nil + game.linkSession = nil + if game.stack and game.stack:top() == self then game.stack:pop() end + if type(game.returnToLauncher) == "function" then + game.returnToLauncher({ tab = "online" }) + end +end + +function ArenaState:fail(reason) + Logger.error("arena2: battle could not start (%s)", tostring(reason)) + self.stage = "failed" + self.frames = 0 + self.message = tostring(reason or Strings("The battle could not start.")) +end + +function ArenaState:enter() + local game, spec = self.game, self.spec + game.linkNet = spec.session + game.linkSession = true + + if spec.role ~= "spectator" then + local packed, packErr = ArenaBoot.packOwnParty(game, spec) + if not packed then return self:fail(packErr) end + end + + local opts, optsErr = ArenaBoot.battleOpts(spec) + if not opts then return self:fail(optsErr) end + + local battle, why + if spec.role == "spectator" then + battle, why = LinkBattle2.newSpectator(game, spec.session, opts) + elseif spec.role == "guest" then + battle, why = LinkBattle2.newGuest(game, spec.session, opts) + else + battle, why = LinkBattle2.newHost(game, spec.session, opts) + end + if not battle then return self:fail(why) end + + self.battle = battle + self.stage = "running" + game.stack:push(battle) +end + +function ArenaState:update(_dt) + if self.stage == "failed" then + self.frames = self.frames + 1 + local input = self.game.input + local dismissed = input and (input:wasPressed("a") or input:wasPressed("b") + or input:wasPressed("start")) + if dismissed or self.frames >= HOLD_FRAMES then + self.stage = "done" + self:report("error") + self:leave() + end + return + end + if self.stage ~= "running" then return end + if self.game.stack:top() ~= self then return end + self.stage = "done" + local battle = self.battle + local result = (battle and battle.result) or "ended" + if battle and Runtime.wants("link.battle_ended") then + Runtime.emit("link.battle_ended", { + result = result, + myParty = battle.playerParty, + theirParty = battle.enemyParty, + peerName = self.spec.peerName, + role = self.spec.role, + }) + end + self.battle = nil + self:report(result) + self:leave() +end + +function ArenaState:drawPanel() + Chrome.clear() + if self.message then + Chrome.textbox(0, 12, 18, 4) + Chrome.printWrapped(self.message, 1, 13, 18, 4) + end +end + +function ArenaState:draw() + self:drawPanel() +end + +function ArenaState:drawWidescreen(winW, winH) + local G = love.graphics + Chrome.letterbox(winW, winH) + local scale = Chrome.fitScale(winW, winH) + G.push() + G.translate(Chrome.fitOrigin(winW, winH, scale)) + G.scale(scale, scale) + self:drawPanel() + G.pop() +end + +return ArenaState diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 7a34938b..53ddf7ec 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -172,7 +172,7 @@ end -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn -- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: -- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. -function BattleAnimView:blitRow(row, dx, dy) +function BattleAnimView:blitRowAt(srcRow, destRow, dx) local canvas = self.canvas if not self.blitQuad then self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) @@ -184,8 +184,12 @@ function BattleAnimView:blitRow(row, dx, dy) srcX, width, destX = -dx, SCREEN_W + dx, 0 end if width <= 0 then return end - self.blitQuad:setViewport(srcX, row, width, 1, SCREEN_W, SCREEN_H) - love.graphics.draw(canvas, self.blitQuad, destX, row + dy) + self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H) + love.graphics.draw(canvas, self.blitQuad, destX, destRow) +end + +function BattleAnimView:blitRow(row, dx, dy) + self:blitRowAt(row, row + dy, dx) end -- Draw the battle panel into the blit canvas, optionally with an rBGP byte @@ -207,7 +211,10 @@ function BattleAnimView:bake(drawBg, palByte) end local previousCanvas = G.getCanvas() local previousBgp = GbcPalette.setBgp(palByte) + local sx, sy, sw, sh + if G.getScissor then sx, sy, sw, sh = G.getScissor() end G.setCanvas(self.canvas) + G.setScissor() G.clear(0, 0, 0, 0) -- A love canvas does NOT reset the transform: without this the panel is -- drawn at whatever scale and offset the caller was already under, and then @@ -217,6 +224,7 @@ function BattleAnimView:bake(drawBg, palByte) local ok, err = pcall(drawBg) G.pop() G.setCanvas(previousCanvas) + if sx then G.setScissor(sx, sy, sw, sh) end GbcPalette.setBgp(previousBgp) if not ok then error(err, 0) end end @@ -286,6 +294,26 @@ function BattleAnimView:panelPalettes(battle) return list end +-- engine/battle_anims/bg_effects.asm:2638 +function BattleAnimView.scanlines(bg) + local lines = {} + local scy = signed(bg.scy) + for row = 0, SCREEN_H - 1 do + local dx, src = 0, row + scy + local inWindow = bg.lcdc and bg.lcdc ~= "BGP" + and row >= bg.lyStart and row < bg.lyEnd + local byte = bg.lyBackup[row] or 0 + if inWindow then + local value = signed(byte) + if bg.lcdc == "SCX" then dx = -value else src = src + value end + end + if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then + lines[#lines + 1] = { src = src, dest = row, dx = dx } + end + end + return lines +end + -- Runs `drawBg` (the battle panel) and puts it on screen through the -- animation's BG registers; skips the canvas when nothing needs one. function BattleAnimView:present(runner, drawBg, battle) @@ -332,20 +360,9 @@ function BattleAnimView:present(runner, drawBg, battle) G.setColor(1, 1, 1, 1) -- hSCX / hSCY move the whole background; the per-scanline overrides only -- apply inside the effect's own window. - local baseX, baseY = -signed(bg.scx), -signed(bg.scy) - for row = 0, SCREEN_H - 1 do - local dx, dy = baseX, baseY - local inWindow = bg.lcdc and row >= bg.lyStart and row < bg.lyEnd - if inWindow and bg.lcdc ~= "BGP" then - local value = signed(bg.lyBackup[row] or 0) - if bg.lcdc == "SCX" then dx = -value else dy = -value end - end - -- A row scrolled to $90 is showing a blank part of the map: skip it, which - -- is what makes Withdraw and Dig look like the mon sinking out of sight. - if (bg.lyBackup[row] or 0) ~= 0x90 or not bg.lcdc or bg.lcdc == "BGP" - or not inWindow then - self:blitRow(row, dx, dy) - end + local baseX = -signed(bg.scx) + for _, line in ipairs(BattleAnimView.scanlines(bg)) do + self:blitRowAt(line.src, line.dest, baseX + line.dx) end if remapped then GbcPalette.clear() end -- Shaderless boot: the panel is raw grayscale, so there are no palettes to diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index c22733f4..b07505c9 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -206,7 +206,33 @@ local function paginate(text) return pages end -function BattleState:wantsFillScale() return true end +function BattleState.fillScale(winW, winH) + local w, h = winW or 0, winH or 0 + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, _, _, pw, ph = pcall(Playfield.rect, winW, winH) + if okv and pw and pw >= 1 and ph and ph >= 1 then + w, h = pw, ph + end + end + return math.max(1, math.min(w / (Chrome.SCREEN_W * 8), + h / (Chrome.SCREEN_H * 8))) +end + +function BattleState.panelScale(winW, winH, fill) + if not fill then return Chrome.fitScale(winW, winH) end + return BattleState.fillScale(winW, winH) +end + +function BattleState:wantsFillScale() + local options = self.game and self.game.options + return (options and options.battleFit) == "fill" +end + +function BattleState:battlePanelScale(winW, winH) + return BattleState.panelScale(winW, winH, self:wantsFillScale()) +end + function BattleState:drawsWidescreen() return true end -- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars. @@ -251,6 +277,7 @@ function BattleState.new(game, opts) self.pokemon = data.pokemon self.battle = opts.battle self.onDone = opts.onDone + self.link = opts.link -- What PlayVictoryMusic needs to know about the opponent (the class the -- trainer belongs to); nil for a wild battle. self.music = opts.music @@ -462,6 +489,15 @@ function BattleState.new(game, opts) self.shownLevel = (player and player.level) or 1 self.shownExp = player and self:expPixels(player, player.level, player.experience) or 0 + local enemy = self.battle and self.battle.enemy + Runtime.emit("battle.started", { + battle = self, + kind = (self.battle and self.battle.wild and "wild") + or (self.link and "link") or "trainer", + trainerId = self.battle and self.battle.trainer and self.battle.trainer.id, + species = enemy and enemy.species, + level = enemy and enemy.level, + }) return self end @@ -1952,6 +1988,9 @@ function BattleState:finishBattle() -- The party tables it wrote them on are the save's own, so this has to run -- before the overworld (and the next save write) sees them again. if self.battle then self.battle:clearAllVolatiles() end + Runtime.emit("battle.ended", { + battle = self, result = self.battle and self.battle.outcome, + }) if self.onDone then self.onDone(self.battle and self.battle.outcome, self.battle) end @@ -2004,6 +2043,9 @@ function BattleState:playVictoryMusic() end function BattleState:submit(action) + if self.link and self.link.submit then + return self.link.submit(self, action) + end self.phase = "resolving" self:pushAll(self.battle:takeTurn(action)) self.message = nil @@ -2018,6 +2060,9 @@ end -- One semantic path for the native command menu and mod.battle intents. function BattleState:chooseMenu(choice) if self.phase ~= "menu" then return nil, "battle menu is not active" end + if self.link and self.link.menuChoice and self.link.menuChoice(self, choice) then + return true + end if choice == "fight" then -- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle. local fighter = self.battle and self.battle.player @@ -2415,6 +2460,9 @@ function BattleState:update(_dt) end if self.phase == "forced-switch" then + if self.link and self.link.forcedPrompt and self.link.forcedPrompt(self) then + return + end -- Reuse the party list so the layout and controls match the start menu's. self:openParty(true) return @@ -2454,6 +2502,18 @@ function BattleState:update(_dt) return end + if self.phase == "refuse-menu" then + if self.messageTimer > 0 then + if input:wasPressed("a") or input:wasPressed("b") then + self.messageTimer = 0 + end + return + end + self.message = nil + self.phase = "menu" + return + end + if self.phase == "learn-intro" then if self.messageTimer > 0 then if input:wasPressed("a") or input:wasPressed("b") then @@ -2541,6 +2601,8 @@ function BattleState:openParty(forced) prompt = forced and "which" or "choose", battle = true, battleSubmenu = not forced, + party = (self.battle and self.battle.party) or nil, + save = self.save, onCancel = function() stack:pop() -- A forced switch cannot be cancelled. @@ -2577,6 +2639,9 @@ function BattleState:openParty(forced) -- ForcePickPartyMonInBattle loops on carry: a pick the engine will not -- take has to come back as the list again, never as the battle menu -- with a fainted mon standing on the field. + if self.link and self.link.forcedSwitch then + return self.link.forcedSwitch(self, index) + end if not self.battle:switch(index) then return self:refuseSwitch(true) end @@ -2607,6 +2672,12 @@ function BattleState:refuseMove(text) self.messageTimer = MESSAGE_FRAMES end +function BattleState:refuseMenu(text) + self.phase = "refuse-menu" + self.message = text + self.messageTimer = MESSAGE_FRAMES +end + -- BattleMenu_Pack: `farcall BattlePack`, which is a different jumptable from -- the field PACK's -- it dispatches on the item's BATTLE menu nibble and never -- reaches a field effect. The empty world is the same guard MartMenu:enterSell @@ -2828,6 +2899,26 @@ function BattleState:hasPokedex() or save.pokedexReceived == true end +-- caught_data.asm:168-199 +function BattleState:stampCaughtData(mon, bugContest) + local save = self.save + local world = self.game and self.game.world + local map = world and world.map + local battle = self.battle + Catching.stampCaughtData(mon, { + version = save and save.version, + save = save, + data = self.game and self.game.data, + bugContest = bugContest, + timeOfDay = (battle and battle.timeOfDay) + or (world and world.timeOfDayId and world:timeOfDayId()), + map = map and map.def, + backupMap = world and world.backupMapId and world.maps + and world.maps[world.backupMapId], + playerGender = save and save.player and save.player.gender, + }) +end + -- PokeBallEffect's caught tail, in the cart's order (item_effects.asm:514-676): -- Text_GotchaMonWasCaught, CheckCaughtMon / SetSeenAndCaughtMon, the new-entry -- line and NewPokedexEntry, the party add or .SendToPC, then @@ -2879,6 +2970,8 @@ function BattleState:pushCaught(enemy, itemId) return self:pushPayDay() end save.party = save.party or {} + -- item_effects.asm:556-558, :612-614 + self:stampCaughtData(enemy) local toPc = #save.party >= Boxes.PARTY_SIZE if toPc then -- `.SendToPC` / `predef SendMonIntoBox` (item_effects.asm:548-550, 604): @@ -2956,6 +3049,7 @@ end -- (engine/battle/core.asm:3269-3295, engine/menus/options_menu.asm:249-256). function BattleState:shiftOfferAllowed() local battle = self.battle + if self.link then return false end if not (battle and battle.player and battle.trainer) then return false end if #(battle.party or {}) < 2 then return false end if (battle.player.hp or 0) <= 0 then return false end @@ -3156,6 +3250,8 @@ end -- already in stock the player is shown the comparison and asked, and the NO arm -- -- which is also what B does -- keeps the mon they already had. function BattleState:contestCatch(mon) + -- engine/pokemon/caught_data.asm:72-81 + self:stampCaughtData(mon, true) local kind, stock, fresh = BugContest.catch(self.save, mon) if kind ~= BugContest.ASK_SWITCH then self:push({ kind = "message", text = "Caught " .. self:name(mon) .. "!" }) @@ -3902,7 +3998,10 @@ function BattleState:drawLiftedRows() self.liftCanvas:setFilter("nearest", "nearest") end local previous = G.getCanvas() + local sx, sy, sw, sh + if G.getScissor then sx, sy, sw, sh = G.getScissor() end G.setCanvas(self.liftCanvas) + G.setScissor() G.clear(0, 0, 0, 0) G.push() G.origin() @@ -3912,6 +4011,7 @@ function BattleState:drawLiftedRows() self.liftedPass = nil G.pop() G.setCanvas(previous) + if sx then G.setScissor(sx, sy, sw, sh) end G.setColor(1, 1, 1, 1) G.draw(self.liftCanvas, 0, 0) end @@ -3945,19 +4045,12 @@ function BattleState:drawSceneBody() end function BattleState:draw() - self:drawScene() + Chrome.withClip(function() self:drawScene() end) end function BattleState:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 1, 1, 1) - local scale = Chrome.fitScale(winW, winH) - local ox, oy = Chrome.fitOrigin(winW, winH, scale) - G.push() - G.translate(ox, oy) - G.scale(scale, scale) - self:drawScene() - G.pop() + Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawScene() end, + self:battlePanelScale(winW, winH)) end BattleState.MENU = MENU diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 58141124..419aa8e5 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -103,6 +103,34 @@ function Chrome.positionLift(winW, winH, scale) or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) end +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + local G = love.graphics + Chrome.letterbox(winW, winH, r, g, b) + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + drawFn() + G.pop() +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + drawFn() + G.pop() +end + Chrome.DEFAULT_BOX_PALETTE = { { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, } diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 11405ca9..13dc2ad6 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -836,18 +836,11 @@ function Credits:drawPanel() end function Credits:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function Credits:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 0, 0, 0) - local scale = Chrome.fitScale(winW, winH) - G.push() - G.translate(Chrome.fitOrigin(winW, winH, scale)) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end) end Credits.TILES_W = TILES_W diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index 761e9dea..fa6ae2fe 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -663,18 +663,11 @@ function HallOfFame:drawPanel() end function HallOfFame:draw() - self:drawPanel() + Chrome.withClip(function() self:drawPanel() end) end function HallOfFame:drawWidescreen(winW, winH) - local G = love.graphics - Chrome.letterbox(winW, winH, 0, 0, 0) - local scale = Chrome.fitScale(winW, winH) - G.push() - G.translate(Chrome.fitOrigin(winW, winH, scale)) - G.scale(scale, scale) - self:drawPanel() - G.pop() + Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end) end HallOfFame.WHITE = WHITE diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index c2a9b269..ed9b2d7f 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -336,6 +336,9 @@ local ROWS = { text = function(options) return require("src.core.FrameCap").label(options.fpsCap) end }, + { label = Strings.source("BATTLE SIZE"), key = "battleFit", port = true, + values = { "fixed", "fill" }, + display = { fixed = "FIXED", fill = "FILL " } }, -- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE -- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only. { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, @@ -364,7 +367,7 @@ local GROUPS = { { id = "group.audio", label = Strings.source("AUDIO"), members = { "sound", "musicVol", "sfxVol", "musicFilter" } }, { id = "group.battle", label = Strings.source("BATTLE OPTIONS"), - members = { "battleScene", "battleStyle", "battleBg" } }, + members = { "battleScene", "battleStyle", "battleFit", "battleBg" } }, { id = "group.extras", label = Strings.source("EXTRAS"), members = { "zoom", "voidFill", "tilt" } }, } diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 4c17968c..62e442b2 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -15,11 +15,15 @@ local Chrome = require("src.ui.gen2.Chrome") local Gen2Save = require("src.core.gen2.Save") local PackGfx = require("src.ui.gen2.PackGfx") local Screens = require("src.ui.Screens") +local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local MenuRepeat = require("src.ui.MenuRepeat") -- constants/sfx_constants.asm:3, :28 local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25 +local LIST_DIRS = { "up", "down" } + local PackMenu = {} PackMenu.__index = PackMenu PackMenu.isOpaque = true @@ -161,6 +165,8 @@ function PackMenu.new(game, opts) self.give = opts.give and true or false self.battle = opts.battle and true or false self.cursorStore = cursorStore(game) + -- engine/menus/scrolling_menu.asm:6 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) self.pocketIndex = 1 -- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes -- ITEM_POCKET over it, so an explicit pocket still wins. @@ -301,6 +307,13 @@ function PackMenu:isCancel() return self.index > #self.rows end +-- home/menu.asm:746, :758 +function PackMenu:playSfx(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + function PackMenu:ensureVisible() if self.index <= self.scroll then self.scroll = self.index - 1 @@ -319,6 +332,8 @@ function PackMenu:switchPocket(delta) self:restoreCursor() self:rebuild() self:storeCursor() + -- engine/items/pack.asm:1268 + self:playSfx("Sfx_SwitchPockets") end -- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE @@ -759,25 +774,26 @@ function PackMenu:update(_dt) self:updateSubmenu(input) return end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) if input:wasPressed("left") then self:switchPocket(-1) return elseif input:wasPressed("right") then self:switchPocket(1) return - elseif input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or self:total() - self:ensureVisible() + elseif dir == "up" then + self:stepCursor(-1, edge) return - elseif input:wasPressed("down") then - self.index = self.index < self:total() and self.index + 1 or 1 - self:ensureVisible() + elseif dir == "down" then + self:stepCursor(1, edge) return elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self:storeCursor() if self.onClose then self.onClose() end return elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") if self:isCancel() then self:storeCursor() if self.onClose then self.onClose() end @@ -795,6 +811,19 @@ function PackMenu:update(_dt) end end +-- engine/menus/scrolling_menu.asm +function PackMenu:stepCursor(delta, edge) + local total = self:total() + local next = self.index + delta + if next < 1 then + next = edge and total or 1 + elseif next > total then + next = edge and 1 or total + end + self.index = next + self:ensureVisible() +end + -- engine/items/pack.asm:1290 Pack_InterpretJoypad .select -- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT. function PackMenu:armSwitch() @@ -808,12 +837,11 @@ end -- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs -- out, and left/right cannot leave the pocket mid-move. function PackMenu:updateSwitch(input) - if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or self:total() - self:ensureVisible() - elseif input:wasPressed("down") then - self.index = self.index < self:total() and self.index + 1 or 1 - self:ensureVisible() + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) + if dir == "up" then + self:stepCursor(-1, edge) + elseif dir == "down" then + self:stepCursor(1, edge) elseif input:wasPressed("a") or input:wasPressed("select") then self:placeSwitch() elseif input:wasPressed("b") then @@ -830,6 +858,8 @@ function PackMenu:placeSwitch() self:rebuild() self:storeCursor() end + -- engine/items/pack.asm:1309 + self:playSfx("Sfx_SwitchPokemon") self:endSwitch() end @@ -848,8 +878,10 @@ function PackMenu:updateSubmenu(input) elseif input:wasPressed("down") then menu.index = menu.index < total and menu.index + 1 or 1 elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self:chooseSubmenu() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self:closeSubmenu() end end @@ -867,9 +899,11 @@ function PackMenu:updateQuantity(input) elseif input:wasPressed("left") then state.qty = qtyStep(state.qty, state.max, -10) elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") self.message = nil self:confirmToss() elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.qtyState = nil self.message = nil end @@ -881,9 +915,11 @@ function PackMenu:updateConfirm(input) if input:wasPressed("up") or input:wasPressed("down") then confirm.choice = confirm.choice == 1 and 2 or 1 elseif input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") self.confirm = nil if confirm.onNo then confirm.onNo() end elseif input:wasPressed("a") then + self:playSfx("Sfx_ReadText2") local yes = confirm.choice == 1 self.confirm = nil if yes then @@ -908,6 +944,8 @@ function PackMenu:registerSelected() local world = not self:inBattle() and self.world or nil local ok = world and world.registerItem and world:registerItem(row.id) if ok then + -- engine/items/pack.asm:551 + self:playSfx("Sfx_FullHeal") -- RegisteredItemText: "Registered the\n." self.message = { Strings("Registered the"), row.name .. "." } else diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 9a1b3b70..17d6483a 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -32,12 +32,14 @@ local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") +local HallOfFame = require("src.core.gen2.HallOfFame") local Palettes = require("src.world.gen2.Palettes") local TileSheet = require("src.ui.gen2.TileSheet") local Nests = require("src.core.gen2.Nests") local Sound = require("src.core.Sound") local Unown = require("src.core.gen2.Unown") local Strings = require("src.core.Strings") +local MenuRepeat = require("src.ui.MenuRepeat") -- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles -- drawn by drawOption/drawSearch below, declared here (rather than inline) @@ -45,6 +47,8 @@ local Strings = require("src.core.Strings") local OPTION_LABEL = Strings.source(" OPTION ") local SEARCH_LABEL = Strings.source(" SEARCH ") +local LIST_DIRS = { "up", "down" } + local PokedexMenu = {} PokedexMenu.__index = PokedexMenu PokedexMenu.isOpaque = true @@ -164,6 +168,8 @@ function PokedexMenu.new(game, opts) end self.index = 1 self.scroll = 0 + -- engine/pokedex/pokedex.asm:36-39 + self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE) self.view = "list" -- list | entry | area | option | search | results | unown self.page = 1 self.entryAction = 1 @@ -384,6 +390,7 @@ function PokedexMenu:update(_dt) if self.view == "option" then return self:updateOption(input) end if self.view == "search" then return self:updateSearch(input) end if self.view == "unown" then return self:updateUnown(input) end + local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS) if input:wasPressed("b") then self:close() return @@ -399,12 +406,21 @@ function PokedexMenu:update(_dt) self.searchType = self.searchType or { 1, 0 } self.searchResults = nil return - elseif input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or #self.rows + elseif dir == "up" then + -- pokedex.asm:982-1011 + if self.index > 1 then + self.index = self.index - 1 + elseif edge then + self.index = #self.rows + end self:ensureVisible() return - elseif input:wasPressed("down") then - self.index = self.index < #self.rows and self.index + 1 or 1 + elseif dir == "down" then + if self.index < #self.rows then + self.index = self.index + 1 + elseif edge then + self.index = 1 + end self:ensureVisible() return elseif input:wasPressed("a") then @@ -807,22 +823,9 @@ end -- ------------------------------------------------------------------- AREA -- --- Pokedex_GetArea (engine/pokegear/pokegear.asm) borrows the Pokegear's town --- map and overlays FindNest's landmarks. The region shown starts as the one the --- player is standing in; LEFT/RIGHT swap it, which is how you see a Kanto mon's --- nests from Johto. B goes back to the entry. +-- engine/pokegear/pokegear.asm:2285, :2322 function PokedexMenu:areaRegionName() - if self.areaRegion then return self.areaRegion end - local landmark = self:playerLandmark() - return Nests.regionOf(landmark) or "johto" -end - --- The player's landmark index, which is what decides the starting region. -function PokedexMenu:playerLandmark() - local save = self.game and self.game.save - local mapId = save and save.position and save.position.map - local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId] - return def and def.landmark + return self.areaRegion or "johto" end -- The Pokegear's own tilemap blit: a flat list of tile ids, row-major over the @@ -849,11 +852,70 @@ function PokedexMenu:updateArea(input) self.view = "entry" return end - if input:wasPressed("left") or input:wasPressed("right") then - self.areaRegion = (self:areaRegionName() == "johto") and "kanto" or "johto" + if input:wasPressed("left") then + self.areaRegion = "johto" + elseif input:wasPressed("right") then + -- pokegear.asm:2373 + if HallOfFame.hasEntered(self.game and self.game.save) then + self.areaRegion = "kanto" + end end end +-- engine/pokegear/pokegear.asm:2451 +function PokedexMenu:nestIconColors() + local set = self.palettes and Palettes.objectSet(self.palettes, "DAY") + return (set and set[1]) + or (self.mapGfx and self.mapGfx.palettes and self.mapGfx.palettes[1]) +end + +-- engine/pokegear/pokegear.asm:2298 +function PokedexMenu:drawNestIcon(x, y) + if self.nestIcon == nil then + self.nestIcon = false + local path = self.mapGfx and self.mapGfx.nestIcon + if path then + local ok, image = pcall(Assets.image, path) + if ok and image then self.nestIcon = image end + end + end + local G = love.graphics + G.setColor(1, 1, 1, 1) + if not self.nestIcon then + local ink = self:nestIconColors() + local dark = ink and GbcPalette.color(ink, 4) or { 0, 0, 0 } + G.setColor(dark[1] / 255, dark[2] / 255, dark[3] / 255, 1) + G.rectangle("fill", x + 1, y + 1, 6, 6) + G.setColor(1, 1, 1, 1) + return + end + local function body() G.draw(self.nestIcon, x, y) end + local colors = self:nestIconColors() + if colors and GbcPalette.available() then + GbcPalette.withRaw(colors, body) + else + body() + end +end + +-- engine/pokegear/pokegear.asm:2403 +function PokedexMenu:drawAreaHeader(title) + local pals = self.mapGfx and self.mapGfx.palettes + local pal = pals and pals[1] + local paper = pal and GbcPalette.color(pal, 1) or { 255, 255, 255 } + local G = love.graphics + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, 8) + G.setColor(1, 1, 1, 1) + local sheet = self.mapSheet + if sheet then + sheet:draw(0x06, 0, 1) + for x = 1, Chrome.SCREEN_W - 2 do sheet:draw(0x07, x, 1) end + sheet:draw(0x17, Chrome.SCREEN_W - 1, 1) + end + Chrome.printThrough(title, 2, 0, pal, false, true) +end + function PokedexMenu:drawArea() local row = self:current() if not row then return end @@ -861,54 +923,22 @@ function PokedexMenu:drawArea() local save = self.game and self.game.save local nests = Nests.find(self.data, row.species, region, save) - self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H) - - -- The map itself is the Pokegear's, drawn through the same gfx the MAP card - -- uses. Without it (a cache imported before the town map was extracted) the - -- page still lists the landmark NAMES, which is the information the screen - -- exists to convey. local maps = self.mapGfx and self.mapGfx.maps local cells = maps and maps[region] if cells then self:drawTilemap(cells) end - self:blank(0, 0, Chrome.SCREEN_W, 2) - self:text(self:monName(row.species) .. "'S NEST", 1, 0) - self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1) + self:drawAreaHeader(self:monName(row.species) .. "'S NEST") - local G = love.graphics - - if #nests == 0 then - -- A species with no grass, water or roamer entry in this region. The cart - -- simply shows the map with nothing blinking on it. - self:text("AREA UNKNOWN", 4, 16) - return - end - - -- engine/pokegear/pokegear.asm:2427 - local on = ((self.areaBlink or 0) % 32) < 20 - if cells and on then - for _, index in ipairs(nests) do - local mark = Nests.landmark(self.data, index) - if mark and mark.x and mark.y then - G.setColor(0, 0, 0, 1) - G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5) - G.setColor(1, 1, 1, 1) - G.rectangle("fill", mark.x - 1, mark.y - 1, 3, 3) - end - end - end - - -- Name the first one in words as well as on the map: the flashing dot is - -- unreadable at this size on a modern display, and the landmark name is what - -- a player actually wants off this screen. - local first = Nests.landmark(self.data, nests[1]) - if first and first.name then - local name = tostring(first.name):gsub("\n", " ") - self:text(name, 1, 16) - if #nests > 1 then - self:text(("+%d"):format(#nests - 1), 17, 16) + -- engine/pokegear/pokegear.asm:2385 + local on = ((self.areaBlink or 0) % 32) < 16 + if not on then return end + for _, index in ipairs(nests) do + local mark = Nests.landmark(self.data, index) + if mark and mark.x and mark.y then + -- engine/pokegear/pokegear.asm:2444 + self:drawNestIcon(mark.x - 4, mark.y - 4) end end end diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index e5ac4eca..bdce385e 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -22,6 +22,7 @@ local Chrome = require("src.ui.gen2.Chrome") local FieldMoves = require("src.world.gen2.FieldMoves") +local FlagNames = require("src.core.gen2.FlagNames") local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") @@ -1004,6 +1005,11 @@ function Pokegear:text(str, tx, ty) return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true) end +function Pokegear:cursor(tx, ty) + local pals = self:pals() + return Chrome.cursorThrough(tx, ty, pals and pals[1], false, false, true) +end + -- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are -- POKEGEAR_RADIO/MAP/PHONE/EXPN_CARD_F (pokegold data/events/engine_flags.asm, -- constants/engine_flags.asm const order), so the scripts' `setflag` -- the @@ -1014,6 +1020,14 @@ end -- a world. local CARD_ENGINE_FLAGS = { radio = 0, map = 1, phone = 2, expn = 3 } +-- ../pokecrystal/constants/engine_flags.asm:25 +function Pokegear:engineFlag(name, goldId) + local world = self.game and self.game.world + local id = goldId + if world and world.engineFlagId then id = world:engineFlagId(name, goldId) end + return ((self.save or {}).engineFlags or {})[id] == true +end + function Pokegear:flags() local save = self.save or {} local flags = {} @@ -1164,7 +1178,18 @@ function Pokegear:update(_dt) self.phoneSubmenu = nil return end + -- engine/pokegear/pokegear.asm:454 + if card and card.id == "clock" then + if input:wasPressed("right") then self:switchCard("map", "phone", "radio") end + return + end if card and card.id == "radio" then + -- engine/pokegear/pokegear.asm:740 + if input:wasPressed("left") then + self:stopRadio() + self:switchCard("phone", "map", "clock") + return + end self:ensureTuned() -- AnimateTuningKnob.TuningKnob: up winds the knob towards 80 and down -- back towards 0, and it stops dead at either end rather than wrapping. @@ -1220,7 +1245,8 @@ function Pokegear:radioContext() -- POKEGEAR_EXPN_CARD_F, the Kanto radio upgrade. expnCard = flags.expn or false, -- STATUSFLAGS_ROCKET_SIGNAL_F, set while Team Rocket holds Mahogany. - rocketSignal = (save.flags or {}).ROCKET_SIGNAL or false, + rocketSignal = self:engineFlag("ENGINE_ROCKET_SIGNAL_ON_CH20", + FlagNames.engine.ENGINE_ROCKET_SIGNAL_ON_CH20), } end @@ -1386,7 +1412,10 @@ function Pokegear:radioData() -- Number Man in Radio Tower has never rolled one, so 00000 is the honest -- reading, not a stand-in for unfinished work. out.luckyNumber = save.luckyNumber or 0 - out.rocketsInRadioTower = (save.flags or {}).ROCKETS_IN_RADIO_TOWER or false + -- ../pokegold/engine/events/std_scripts.asm:255 + out.rocketsInRadioTower = + self:engineFlag("ENGINE_ROCKETS_IN_RADIO_TOWER", + FlagNames.engine.ENGINE_ROCKETS_IN_RADIO_TOWER) self.radioDataCache = out return out end @@ -1949,8 +1978,8 @@ function Pokegear:drawClock() local hour, minute, weekday = self:clockParts() self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.clock) self:drawStrip() - self:text("SWITCH", 13, 1) - Chrome.cursor(19, 1) + self:text(" SWITCH", 12, 1) + self:cursor(19, 1) -- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and -- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at @@ -1981,7 +2010,7 @@ end function Pokegear:printBoxText(text) local lines = Chrome.wrap(text, 18) for i = 1, math.min(#lines, 2) do - Chrome.print(lines[i], 1, 14 + (i - 1) * 2) + self:text(lines[i], 1, 14 + (i - 1) * 2) end end @@ -2056,17 +2085,19 @@ end -- TownMapBubble: the plate the fly screen wears instead of the card strip. -- Three rows from (1,0) to (18,2), "Where?" at (2,0), the flypoint's landmark --- name at (2,1) and the up/down scroller at (18,1). The four rounded corners --- come from FlyMapLabelBorderGFX, a six-tile 1bpp set loaded over vTiles2 tile --- $30 for this screen only -- the extractor carries the town map's own $30-$33 --- instead, so the plate is drawn square rather than with the wrong art in its --- corners. +-- name at (2,1) and the up/down scroller at (18,1). function Pokegear:drawFlyBubble() - self:drawPlate(1, 0, 18, 3) + self:tile(0x30, 1, 0) + for x = 2, 17 do self:tile(SPACE_TILE, x, 0) end + self:tile(0x31, 18, 0) + for x = 1, 18 do self:tile(SPACE_TILE, x, 1) end + self:tile(0x32, 1, 2) + for x = 2, 17 do self:tile(SPACE_TILE, x, 2) end + self:tile(0x33, 18, 2) self:text("Where?", 2, 0) local row = self:flyRow() self:text(flatName(row and row.name), 2, 1) - Chrome.cursor(18, 1) + self:tile(0x34, 18, 1) end -- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918): @@ -2273,8 +2304,8 @@ function Pokegear:drawRadio() self:textbox(0, 12, 18, 4) local radio = self.radio if not (station and station.station and radio) then return end - if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end - if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end + if radio.top ~= "" then self:text(radio.top, 1, 14) end + if radio.bottom ~= "" then self:text(radio.bottom, 1, 16) end end function Pokegear:drawPhone() @@ -2294,8 +2325,9 @@ function Pokegear:drawPhone() -- A call in progress replaces the prompt with what the caller is saying; -- otherwise the box holds PokegearAskWhoCallText the whole time. if self.call then - Chrome.printWrapped(self.call.text or self:phoneText("GearEllipse"), - 1, 14, 18, 3) + local lines = Chrome.wrap(self.call.text + or self:phoneText("GearEllipse"), 18) + for i = 1, math.min(#lines, 3) do self:text(lines[i], 1, 13 + i) end else self:printBoxText(self:phoneText("AskWhoCall")) end @@ -2312,7 +2344,7 @@ function Pokegear:drawPhone() if className then self:text(className, 5, ty + 1) end end -- PokegearPhone_UpdateCursor draws the cursor at (1, 4 + 2 * cursor). - Chrome.cursor(1, 4 + self.phoneCursor * 2) + self:cursor(1, 4 + self.phoneCursor * 2) self:drawPhoneSubmenu() end @@ -2328,7 +2360,7 @@ function Pokegear:drawPhoneSubmenu() local ty = menu.textY + (index - 1) * 2 self:text(label, menu.textX, ty) end - Chrome.cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2) + self:cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2) end -- ------------------------------------------------------------------ fallback diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index fe1a9424..406bdc43 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -45,6 +45,7 @@ local Theme = require("src.ui.kit.Theme") local PAL = Theme.PAL local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard") local FileBrowser = require("src.ui.kit.FileBrowser") +local Transition = require("src.ui.kit.Transition") local Kit = { scale = 1, @@ -53,6 +54,7 @@ local Kit = { focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows) VirtualKeyboard = VirtualKeyboard, FileBrowser = FileBrowser, + Transition = Transition, } Kit.mouseX, Kit.mouseY = 0, 0 @@ -334,6 +336,8 @@ function Kit.beginFrame(mx, my, clicked, wheel) if love and love.timer and love.timer.getTime then Kit.time = love.timer.getTime() end + Transition.update(Kit.time) + if Transition.active() then Kit.blockClicks = true end -- Resolve any queued focus-ring movement against LAST frame's geometry. -- Immediate mode has no geometry until the frame is built, and the ring -- must move before widgets test themselves against it. @@ -716,14 +720,16 @@ function Kit.button(x, y, w, h, label, opts) strokeA = invert and Theme.A.focus or Theme.A.hairline doRing = focused and not hot elseif face == "tab" then - invert = active or focused or hot + invert = active and true or false local tint = opts.color or opts.fill or PAL.ink fill = invert and tint or PAL.surface ink = invert and PAL.inverse or (opts.color or PAL.text) if not invert then stroke = tint - strokeA = opts.color and Theme.A.hover or Theme.A.hairline + strokeA = (focused or hot) and Theme.A.focus + or (opts.color and Theme.A.hover or Theme.A.hairline) end + doRing = focused or hot elseif face == "chip" then local c = opts.color or PAL.line invert = active and true or false diff --git a/src/ui/kit/Transition.lua b/src/ui/kit/Transition.lua new file mode 100644 index 00000000..ec3ab19d --- /dev/null +++ b/src/ui/kit/Transition.lua @@ -0,0 +1,125 @@ +local Transition = {} + +Transition.reduceMotion = false +Transition.armed = false + +Transition.DURATIONS = { + ["in"] = 0.12, + out = 0.09, + tab = 0.18, + push = 0.16, + pop = 0.16, +} + +Transition.LAYERS = { "tabs", "online", "modal" } + +local layers = {} +for i = 1, #Transition.LAYERS do + layers[Transition.LAYERS[i]] = { + active = false, kind = nil, dir = 1, + t0 = 0, dur = 0, p = 1, + from = nil, fromAt = nil, to = nil, + } +end + +local function now() + if love and love.timer and love.timer.getTime then + return love.timer.getTime() + end + return 0 +end +Transition.now = now + +local function ease(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + local u = 1 - t + return 1 - u * u * u +end +Transition.ease = ease + +function Transition.update(t) + t = t or now() + for i = 1, #Transition.LAYERS do + local L = layers[Transition.LAYERS[i]] + if L.active then + local raw = 1 + if L.dur > 0 then raw = (t - L.t0) / L.dur end + if raw >= 1 then + L.active, L.p = false, 1 + elseif raw <= 0 then + L.p = 0 + else + L.p = ease(raw) + end + end + end +end + +function Transition.start(layer, kind, opts) + local L = layers[layer] + if not L then return false end + L.kind = kind + L.dir = (opts and opts.dir) or 1 + L.from = opts and opts.from or nil + L.fromAt = opts and opts.fromAt or nil + L.to = opts and opts.to or nil + local dur = (opts and opts.duration) or Transition.DURATIONS[kind] or 0.15 + if Transition.reduceMotion or not Transition.armed or dur <= 0 then + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + return false + end + L.active, L.p, L.dur, L.t0 = true, 0, dur, now() + return true +end + +function Transition.get(layer) + local L = layers[layer] + if L and L.active then return L end + return nil +end + +function Transition.progress(layer) + local L = layers[layer] + if not L or not L.active then return 1 end + return L.p +end + +function Transition.kind(layer) + local L = layers[layer] + if L and L.active then return L.kind end + return nil +end + +function Transition.dir(layer) + local L = layers[layer] + if L and L.active then return L.dir end + return 0 +end + +function Transition.active(layer) + if layer then + local L = layers[layer] + return (L and L.active) == true + end + for i = 1, #Transition.LAYERS do + if layers[Transition.LAYERS[i]].active then return true end + end + return false +end + +function Transition.clear(layer) + local L = layers[layer] + if not L then return false end + L.active, L.p, L.dur, L.t0 = false, 1, 0, 0 + L.kind, L.from, L.fromAt, L.to = nil, nil, nil, nil + return true +end + +function Transition.reset() + for i = 1, #Transition.LAYERS do + Transition.clear(Transition.LAYERS[i]) + end +end + +return Transition diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua index 63f65f77..8b820bc4 100755 --- a/src/ui/kit/VirtualKeyboard.lua +++ b/src/ui/kit/VirtualKeyboard.lua @@ -203,6 +203,7 @@ function VirtualKeyboard.draw(m) -- Scrim background Theme.fill(0, 0, W, H, PAL.bg, 0.92) + local shielded = Kit.blockClicks Kit.blockClicks = true local pad = math.floor(12 * s) @@ -212,6 +213,7 @@ function VirtualKeyboard.draw(m) local py = math.floor((H - modalH) / 2) Kit.card(px, py, modalW, modalH, true) + Kit.blockClicks = shielded local cy = py + pad -- Title & hint @@ -276,7 +278,7 @@ function VirtualKeyboard.draw(m) cy = cy + rowH + gap end - Kit.blockClicks = false + Kit.blockClicks = shielded end return VirtualKeyboard diff --git a/src/update/PatchNotes.lua b/src/update/PatchNotes.lua index 5fb503b0..e70662fd 100644 --- a/src/update/PatchNotes.lua +++ b/src/update/PatchNotes.lua @@ -58,7 +58,7 @@ function PatchNotes.fromCache(engine) if text then local ok, doc = pcall(Json.decode, text) if ok and type(doc) == "table" then - if engine and engine ~= "0.0.0-dev" then + if engine and not engine:match("^0%.0%.0%-dev") then if doc[engine] and nonempty(doc[engine]) then return doc[engine], engine end @@ -109,7 +109,7 @@ function PatchNotes.fromRepo(engine) if text then local list = PatchNotes.parseRepo(text) if #list == 0 then return nil, nil end - if engine and engine ~= "0.0.0-dev" then + if engine and not engine:match("^0%.0%.0%-dev") then for _, row in ipairs(list) do if row.version == engine then return row.notes, row.version @@ -128,7 +128,7 @@ function PatchNotes.body(Check) local engine = (Version and Version.engine) or "?" local notes, ver = PatchNotes.fromCheck(Check) - if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then + if notes and (engine:match("^0%.0%.0%-dev") or ver == engine or ver == nil) then return notes, ver or engine end diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 7b8fdeb7..62c6402c 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -238,7 +238,7 @@ local function doCheck(target) -- Unstamped dev build: the working tree always looks "newer", so never -- pester the developer with an update (contract item, Check design). local currentEngine = (Version and Version.engine) or "0.0.0-dev" - if currentEngine == "0.0.0-dev" then + if currentEngine:match("^0%.0%.0%-dev") then post({ status = "uptodate", latest = rel.version }) return end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6bd33408..b2ffba7b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -528,6 +528,8 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- one-shot set by play_music opts.keep. local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil + -- home/overworld.asm ln 2340, player_animations.asm ln 64 + if opts and opts.via == "fly" then keepMusic = true end if not keepMusic then -- ..(home/overworld.asm ln 2346) local Music = require("src.core.Music") @@ -1245,6 +1247,9 @@ function OverworldState:update(dt) if self.flyArrive.t >= #FLY_PATH_IN * 3 then self.flyArrive = nil self.player.inputLocked = false + -- engine/overworld/player_animations.asm ln 64 + require("src.core.Music").playMap( + Game.data, self.map.id, Game.save.onBike, self.player.surfing, nil) end end if self.spinArrive and not self.player.spinFrames then @@ -4026,7 +4031,8 @@ function OverworldState:applyFieldPoison() end if not anyPoisoned then return false end require("src.core.Sound").play(Game.data, "Poisoned") - self.poisonFlash = 12 + -- engine/gfx/screen_effects.asm:7-8 + self.poisonFlash = 4 local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name @@ -5777,12 +5783,18 @@ function OverworldState:drawUI() end end - -- poison step flicker (ChangeBGPalColor0_4Frames: dark for two - -- 4-frame pulses) + -- engine/gfx/screen_effects.asm:1-12 if self.poisonFlash and self.poisonFlash > 0 then self.poisonFlash = self.poisonFlash - 1 - local pulse = math.floor(self.poisonFlash / 4) % 2 == 1 - if pulse then + local r = Game and Game.renderer + if PaletteFX.shader() then + -- home/fade.asm:66 + if not PaletteFX.shadeMap() then + PaletteFX.setShadeMap(PaletteFX.POISON_BGP) + end + elseif r then + r.screenVeil = { 0, 0.45 } + else love.graphics.setColor(0, 0, 0, 0.45) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index 3ad40933..7f7ec7e9 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -125,10 +125,9 @@ local SPIN_NEXT = { -- each quarter, no Random anywhere in the loop. local SPIN_TURN_FRAMES = 16 --- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a --- mon object holds each icon frame for eight -- map_object_action.asm:184-201 -local BOUNCE_PERIOD = 16 -local BOUNCE_HALF = 8 +-- map_object_action.asm:184-201, events.asm:175-189 +local BOUNCE_PERIOD = 32 +local BOUNCE_HALF = 16 local function rand(a, b) if love and love.math and love.math.random then diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 5a0734e4..94bc9057 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -63,10 +63,12 @@ function Player.new(cx, cy, facing, spriteDef) end function Player:setSprite(spriteDef) - if spriteDef then - self.spriteDef = spriteDef - self.sprite = SpriteRenderer.new(spriteDef, "player") - end + if not spriteDef then return end + -- pokegold engine/overworld/overworld.asm:55-64 + local ok, sprite = pcall(SpriteRenderer.new, spriteDef, "player") + if not (ok and sprite) then return end + self.spriteDef = spriteDef + self.sprite = sprite end -- the movement.collision chain sees the boolean; a wrapper that flips it @@ -265,15 +267,6 @@ function Player:draw(ox, oy, scale) -- it moves the sprite without moving the player off the tile they are -- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte. local yOffset = self.spriteYOffset or 0 - if self.jumping then - -- engine/overworld/map_objects.asm:1995 - local gx = ox + self.px * scale - local gy = oy + self.py * scale - local s = 16 * scale - G.setColor(0, 0, 0, 0.4) - G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12) - G.setColor(1, 1, 1, 1) - end if self.sprite then G.push() G.translate(ox, oy) diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index a20a321a..5c9bf69d 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -20,6 +20,7 @@ local CallAsm = require("src.script.gen2.CallAsm") local Camera = require("src.render.Camera") local Clock = require("src.core.gen2.Clock") local CatchTutorial = require("src.core.gen2.CatchTutorial") +local Catching = require("src.battle.gen2.Catching") local Decorations = require("src.core.gen2.Decorations") local MomShopping = require("src.core.gen2.MomShopping") local CmdQueue = require("src.world.gen2.CmdQueue") @@ -812,6 +813,19 @@ function World:load() local okImg, img = pcall(Assets.image, emotes.grassRustle) if okImg then self.grassRustleImage = img end end + -- data/sprites/emotes.asm:19 + if emotes.jumpShadow then + local okImg, img = pcall(Assets.image, emotes.jumpShadow) + if okImg then self.jumpShadowImage = img end + end + -- engine/events/field_moves.asm:390-407 + if emotes.cutGrass then + local okImg, img = pcall(Assets.image, emotes.cutGrass) + if okImg then + self.cutGrassImage = img + self.cutGrassQuad = love.graphics.newQuad(0, 0, 8, 8, 32, 8) + end + end -- LoadFishingGFX's two sheets -- (../pokecrystal/engine/events/fishing_gfx.asm:7-12) if emotes.fishing and pcall(Assets.image, emotes.fishing) then @@ -1053,6 +1067,14 @@ function World:load() end -- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149). Mon.stampOT(save, mon) + -- move_mon.asm:1734, :1761 + if Mon.hasCaughtData(save.version) then + if opts and opts.otName then + Mon.setGiftCaughtData(mon, opts.caughtBy or "unknown") + else + Catching.stampCaughtData(mon, self:caughtDataOpts()) + end + end Party.add(save.party, mon) -- GivePoke ends in SetSeenAndCaughtMon, which is why the STARTER is -- already ticked off in the #DEX before the first battle. @@ -1518,6 +1540,22 @@ function World:timeOfDayId() return TIME_OF_DAY_ID[self.tod or self.daytime or "DAY"] or 1 end +-- caught_data.asm:168-199 +function World:caughtDataOpts() + local save = self.game and self.game.save + local opts = { + version = save and save.version, + save = save, + data = self.game and self.game.data, + timeOfDay = self:timeOfDayId(), + map = self.map and self.map.def, + backupMap = self.backupMapId and self.maps and self.maps[self.backupMapId], + playerGender = save and save.player and save.player.gender, + } + opts.landmark = Catching.caughtLandmark(opts) + return opts +end + -- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the -- same numbering os.date("%w") answers, so no remap. `clockDay` overrides the -- host clock the way `clockHour` overrides the hour, so a driver can stand on @@ -4925,6 +4963,8 @@ function World:playBikeMusic() local audio = data and data.audio if not (audio and audio.runtime) then return false end if not (audio.songs and audio.songs[Bike.MUSIC_BICYCLE]) then return false end + -- engine/events/overworld.asm:1621-1630 + Music.stop() Music.play(data, Bike.MUSIC_BICYCLE, true, { reason = "bike" }) return true end @@ -5997,6 +6037,10 @@ end local FLY = { FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40, AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84, + -- engine/sprite_anims/functions.asm:1389-1416 + LEAF_DEATH_X = 184, LEAF_AMP = 0x40, + -- constants/sprite_anim_constants.asm:20 + LEAF_MAX = 9, } -- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390): @@ -6036,6 +6080,7 @@ function World:startFlyAnim(phase, mon, onDone) self.flyAnim = { phase = phase, icon = icon, onDone = onDone, t = 0, px = p.px, py = p.py, xoff = 0, wave = 0, + leaves = {}, left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES, hover = landing and 0 or FLY.HOVER, amp = landing and FLY.TO_AMP or 0, @@ -6056,6 +6101,7 @@ function World:stepFlyAnim() if done then done() end return end + self:spawnFlyLeaves(fa) fa.left = left - 1 if left >= 0x40 and left % 8 == 0 then self:playSfxNamed("Sfx_Fly", SFX.FLY) @@ -6079,6 +6125,59 @@ function World:stepFlyAnim() fa.wave = fa.wave + 1 end +-- engine/events/field_moves.asm:429-446 +-- engine/sprite_anims/functions.asm:1389-1416 +function World:spawnFlyLeaves(fa) + local leaves = fa.leaves + if not leaves then + leaves = {} + fa.leaves = leaves + end + local counter = self.flyLeafCounter or 0 + self.flyLeafCounter = (counter + 1) % 256 + if counter % 8 == 0 and #leaves < FLY.LEAF_MAX then + local row = math.floor(self.flyLeafCounter / 8) % 4 + leaves[#leaves + 1] = { x = 0, y = row * 16 + 0x40, wave = 0, xoff = 0 } + end + for i = #leaves, 1, -1 do + local leaf = leaves[i] + if leaf.x >= FLY.LEAF_DEATH_X then + table.remove(leaves, i) + else + leaf.x = leaf.x + 2 + leaf.y = leaf.y - 1 + leaf.xoff = math.floor(FLY.LEAF_AMP + * math.cos((leaf.wave % 64) * math.pi / 32)) + leaf.wave = leaf.wave + 1 + end + end +end + +-- data/sprite_anims/oam.asm:485-487 +function World:drawFlyLeaves(s) + local fa = self.flyAnim + local sheet = self.cutGrassImage + if not (fa and fa.leaves and sheet and self.cutGrassQuad) then return end + local G = love.graphics + local colors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 6 }) + local function blit() + G.setColor(1, 1, 1, 1) + for _, leaf in ipairs(fa.leaves) do + G.draw(sheet, self.cutGrassQuad, + math.floor((leaf.x + (leaf.xoff or 0) - 4) * s), + math.floor((leaf.y - 4) * s), 0, s, s) + end + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, blit) + else + blit() + end +end + -- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored -- -- data/sprite_anims/framesets.asm:81-86 function World:drawFlyAnim(s, billboard) @@ -6104,6 +6203,7 @@ function World:drawFlyAnim(s, billboard) else body() end + self:drawFlyLeaves(s) end -- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`, @@ -6548,6 +6648,11 @@ function World:startScriptedBattle(record, wild, onDone) end end if not (opts.trainer and #opts.trainer.party > 0) and not opts.wild then + if record then + require("src.core.Logger").warn( + "trainer %s (class %s, member %s) built no party; no battle ran", + tostring(record.name), tostring(record.class), tostring(record.member)) + end if onDone then onDone("win") end return false end @@ -8399,6 +8504,30 @@ function World:drawGrassShake(entity, ox, oy, s) end end +-- engine/overworld/map_objects.asm:1995, :879-893, facings.asm:161-164 +function World:drawJumpShadow(entity, ox, oy, s) + local sheet = self.jumpShadowImage + if not (sheet and entity and entity.jumping) then return end + local G = love.graphics + local facing = entity.facing + local dy = (facing == "left" or facing == "right") and 8 or 10 + local colors = Palettes.spritePalette(self.palettes, + self.daytime or Palettes.daytimeFor(self.map and self.map.def, + self:hour(), self.flashUsed), + { paletteId = 5 }) + local function blit() + G.setColor(1, 1, 1, 1) + local y = math.floor(oy + (entity.py + dy) * s) + G.draw(sheet, math.floor(ox + entity.px * s), y, 0, s, s) + G.draw(sheet, math.floor(ox + (entity.px + 16) * s), y, 0, -s, s) + end + if colors and GbcPalette.available() then + GbcPalette.with(colors, blit) + else + blit() + end +end + -- This frame's AnimateWaterTile graphic for a map's border block, or nil when -- the block holds no water at all (engine/tilesets/tileset_anims.asm:167). function World:borderWaterFrame(def, tileset) @@ -9823,7 +9952,8 @@ function World:hatchEggs() return end self:showText("Huh?", function() - local hatched, effects = Breeding.hatch(data, save, index) + local hatched, effects = + Breeding.hatch(data, save, index, nil, self:caughtDataOpts()) if not hatched then return nextEgg() end -- Breeding.hatch already ran SetSeenAndCaughtMon; the Togepi flag is the -- one side effect it hands back rather than setting, because wEventFlags @@ -10321,6 +10451,8 @@ function World:drawPeople(s, billboard) local oy = math.floor((entry.oy - cam.y) * s) local entity = entry.kind == "player" and p or entry.npc local function body() + -- map_objects.asm:221-227 + self:drawJumpShadow(entity, ox, oy, s) if entry.kind == "player" then self.player:draw(ox, oy, s) else diff --git a/tests/drivers/arena_boot_gen2_loopback.lua b/tests/drivers/arena_boot_gen2_loopback.lua new file mode 100644 index 00000000..524aeee6 --- /dev/null +++ b/tests/drivers/arena_boot_gen2_loopback.lua @@ -0,0 +1,179 @@ +-- Driver: boots Gold straight into an online arena battle +-- (POKEPORT_ARENA_SPEC), plays it against an in-process headless guest over +-- Net.loopbackPair, and screenshots the battle and the launcher the arena +-- returns to. The Gen 2 peer of tests/drivers/arena_boot_loopback.lua. +-- +-- SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_VERSION=gold \ +-- POKEPORT_IDENTITY= POKEPORT_SPEED=10 \ +-- POKEPORT_ARENA_SPEC=tests/drivers/arena_boot_gen2_spec.lua \ +-- POKEPORT_DRIVER=tests/drivers/arena_boot_gen2_loopback.lua love . +-- +-- POKEPORT_SPEED matters here: the headless guest takes that many fixed steps +-- per rendered frame, and it is the pacing side of the lockstep, so at 1 the +-- match runs at a fraction of real speed. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + + local ctx = _G.POKEPORT_ARENA_TEST + if not ctx then + U.log("FAIL POKEPORT_ARENA_SPEC did not run (no loopback context)") + love.event.quit(1) + return + end + + local arena = game.stack.states[1] + U.log("arena state:", tostring(arena and arena.screenId), + "stage:", tostring(arena and arena.stage)) + if not (arena and arena.screenId == "Gen2ArenaState") then + U.log("FAIL the game did not boot into the Gen 2 ArenaState") + love.event.quit(1) + return + end + if arena.stage ~= "running" then + U.log("FAIL the arena battle did not construct:", tostring(arena.message)) + love.event.quit(1) + return + end + + local shots = { + [90] = DIR .. "/arena2_0_battle.png", + [400] = DIR .. "/arena2_1_turns.png", + } + + local function verify() + local ok = true + for _, path in pairs(shots) do + local f = io.open(path, "rb") + if f then + f:close() + U.log("captured", path) + else + ok = false + U.log("FAIL screenshot did not reach disk:", path) + end + end + U.log("arena finished: host result", tostring(ctx.result), + "guest result", tostring(ctx.guestResult)) + if ctx.result ~= "win" and ctx.result ~= "lose" and ctx.result ~= "draw" then + ok = false + U.log("FAIL the arena reported no battle result") + end + if ctx.result == "win" and ctx.guestResult ~= "lose" then ok = false end + if ctx.result == "lose" and ctx.guestResult ~= "win" then ok = false end + if not ok then U.log("FAIL the two sides disagree on the outcome") end + return ok + end + + local baseDraw = love.draw + local launcherShot, launcherFrames = false, 0 + love.draw = function() + baseDraw() + -- ctx.readyForShot: the guest finished too (or ran out of grace), so the + -- launcher behind the arena is settled and both results can be compared. + if ctx.readyForShot and not launcherShot then + launcherFrames = launcherFrames + 1 + if launcherFrames > 45 then + launcherShot = true + love.graphics.captureScreenshot(function(imagedata) + local encoded = imagedata:encode("png") + local path = DIR .. "/arena2_2_launcher.png" + local f = io.open(path, "wb") + if f then + f:write(encoded:getString()) + f:close() + U.log("captured", path) + else + U.log("FAIL screenshot did not reach disk:", path) + end + love.event.quit(verify() and 0 or 1) + end) + end + end + end + + local LinkBattle2 = require("src.link.LinkBattle2") + + -- The guest has no window: a bare pad, a bare stack and no save of its own, + -- which is everything src/ui/gen2/BattleState.lua asks a game for. + local guestInput = { + pressed = { a = true }, + state = {}, + pressQueue = {}, + wasPressed = function(_, button) return button == "a" end, + isDown = function() return false end, + step = function() end, + } + local guestStack = { list = {} } + function guestStack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function guestStack:pop() return table.remove(self.list) end + function guestStack:top() return self.list[#self.list] end + function guestStack:clear() self.list = {} end + function guestStack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + + local guestGame = { + data = game.data, + input = guestInput, + stack = guestStack, + options = {}, + save = { party = {}, player = { name = "SILVER" }, inventory = {}, + pokedex = { seen = {}, caught = {} } }, + } + local guest, why = LinkBattle2.newGuest(guestGame, ctx.guestNet, { + myParty = ctx.guestParty, + theirParty = ctx.hostParty, + theirName = "GOLD", + seed = ctx.seed, + verdict = "full", + strict = true, + keepNetOpen = true, + }) + if not guest then + U.log("FAIL headless guest could not start:", tostring(why)) + love.event.quit(1) + return + end + guest.onFinish = function(result) ctx.guestResult = result end + guestStack:push(guest) + U.log("headless guest running") + + -- The guest runs several fixed steps per rendered frame: it is the pacing + -- side of the lockstep (the host waits for its action), so at one step a + -- frame the whole match would crawl at half real speed. + local GUEST_STEPS = tonumber(os.getenv("POKEPORT_SPEED")) or 6 + for step = 1, 6000 do + table.insert(game.input.pressQueue, "a") + for _ = 1, GUEST_STEPS do guestStack:update(1 / 60) end + if shots[step] then game.capturePath = shots[step] end + coroutine.yield() + if ctx.result then break end + end + + if not ctx.result then + U.log("FAIL the arena battle never finished") + love.event.quit(1) + return + end + + -- The host reports the moment its own screen pops; the guest is still + -- draining the last of its message queue, so give it its own frames rather + -- than reading a result it has not written yet. + for _ = 1, 600 do + if ctx.guestResult then break end + for _ = 1, GUEST_STEPS do guestStack:update(1 / 60) end + coroutine.yield() + end + ctx.readyForShot = true + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/arena_boot_gen2_spec.lua b/tests/drivers/arena_boot_gen2_spec.lua new file mode 100644 index 00000000..30697066 --- /dev/null +++ b/tests/drivers/arena_boot_gen2_spec.lua @@ -0,0 +1,61 @@ +-- POKEPORT_ARENA_SPEC payload for tests/drivers/arena_boot_gen2_loopback.lua. + +local Net = require("src.link.Net") +local ArenaBoot = require("src.online.ArenaBoot") + +local hostNet, guestNet = Net.loopbackPair() + +-- Protocol.packMon2's shape: four rolled DVs (hp is derived), Gen 2's +-- `experience`, and a held item. +local function packedMon(species, item) + return { + species = species, + level = 20, + experience = 8000, + hp = 60, + moves = { { id = "TACKLE", pp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + item = item, + happiness = 70, + } +end + +local hostParty = { packedMon("CYNDAQUIL", "LEFTOVERS") } +local guestParty = { packedMon("TOTODILE") } +local seed = 515151 + +local ctx = { + hostNet = hostNet, + guestNet = guestNet, + hostParty = hostParty, + guestParty = guestParty, + seed = seed, + result = nil, +} +_G.POKEPORT_ARENA_TEST = ctx + +local spec, err = ArenaBoot.spec({ + profile = { + engine = 2, + version = "gold", + engineVersion = "test", + apiVersion = 2, + fingerprint = "loopback", + rulesetId = "gen2", + kind = "vanilla", + rule = { partySize = 1 }, + }, + role = "host", + slotId = "slot1", + team = { 1 }, + seed = seed, + peerName = "SILVER", + myParty = hostParty, + theirParty = guestParty, + session = hostNet, + onDone = function(result) ctx.result = result or "ended" end, +}) + +if not spec then error("gen 2 arena spec invalid: " .. tostring(err)) end +return spec diff --git a/tests/drivers/arena_boot_loopback.lua b/tests/drivers/arena_boot_loopback.lua new file mode 100644 index 00000000..7d26bb90 --- /dev/null +++ b/tests/drivers/arena_boot_loopback.lua @@ -0,0 +1,149 @@ +-- Driver: boots straight into an online arena battle (POKEPORT_ARENA_SPEC), +-- plays it against an in-process headless guest over Net.loopbackPair, and +-- screenshots the battle and the launcher the arena returns to. +-- +-- SHOT_DIR=/tmp/shots POKEPORT_ARENA_SPEC=tests/drivers/arena_boot_spec.lua \ +-- POKEPORT_DRIVER=tests/drivers/arena_boot_loopback.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null') + + local ctx = _G.POKEPORT_ARENA_TEST + if not ctx then + U.log("FAIL POKEPORT_ARENA_SPEC did not run (no loopback context)") + love.event.quit(1) + return + end + + local arena = game.stack.states[1] + U.log("arena state:", tostring(arena and arena.screenId), + "stage:", tostring(arena and arena.stage)) + if not (arena and arena.screenId == "ArenaState") then + U.log("FAIL the game did not boot into ArenaState") + love.event.quit(1) + return + end + if arena.stage ~= "running" then + U.log("FAIL the arena battle did not construct") + love.event.quit(1) + return + end + + local shots = { + [60] = DIR .. "/arena_0_battle.png", + [400] = DIR .. "/arena_1_turns.png", + } + + local function verify() + local ok = true + for _, path in pairs(shots) do + local f = io.open(path, "rb") + if f then + f:close() + U.log("captured", path) + else + ok = false + U.log("FAIL screenshot did not reach disk:", path) + end + end + U.log("arena finished: host result", tostring(ctx.result), + "guest result", tostring(ctx.guestResult)) + if ctx.result ~= "win" and ctx.result ~= "lose" and ctx.result ~= "draw" then + ok = false + U.log("FAIL the arena reported no battle result") + end + return ok + end + + local baseDraw = love.draw + local launcherShot, launcherFrames = false, 0 + love.draw = function() + baseDraw() + if ctx.result and not launcherShot then + launcherFrames = launcherFrames + 1 + if launcherFrames > 45 then + launcherShot = true + love.graphics.captureScreenshot(function(imagedata) + local encoded = imagedata:encode("png") + local path = DIR .. "/arena_2_launcher.png" + local f = io.open(path, "wb") + if f then + f:write(encoded:getString()) + f:close() + U.log("captured", path) + else + U.log("FAIL screenshot did not reach disk:", path) + end + love.event.quit(verify() and 0 or 1) + end) + end + end + end + + local LinkBattle = require("src.link.LinkBattle") + local SaveData = require("src.core.SaveData") + + local guestInput = { + pressed = { a = true }, + state = {}, + pressQueue = {}, + wasPressed = function(_, button) return button == "a" end, + isDown = function() return false end, + step = function() end, + } + local guestStack = { list = {} } + function guestStack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function guestStack:pop() return table.remove(self.list) end + function guestStack:top() return self.list[#self.list] end + function guestStack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + + local guestGame = { + data = game.data, + input = guestInput, + stack = guestStack, + save = SaveData.newGame(), + } + local guest, why = LinkBattle.newGuest(guestGame, ctx.guestNet, { + myParty = ctx.guestParty, + theirParty = ctx.hostParty, + theirName = "RED", + seed = ctx.seed, + verdict = "full", + strict = true, + keepNetOpen = true, + }) + if not guest then + U.log("FAIL headless guest could not start:", tostring(why)) + love.event.quit(1) + return + end + guest.onFinish = function(result) ctx.guestResult = result end + guestStack:push(guest) + U.log("headless guest running") + + for step = 1, 8000 do + table.insert(game.input.pressQueue, "a") + guestStack:update(1 / 60) + if shots[step] then game.capturePath = shots[step] end + coroutine.yield() + if ctx.result then break end + end + + if not ctx.result then + U.log("FAIL the arena battle never finished") + love.event.quit(1) + return + end + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/arena_boot_spec.lua b/tests/drivers/arena_boot_spec.lua new file mode 100644 index 00000000..3d0f6b81 --- /dev/null +++ b/tests/drivers/arena_boot_spec.lua @@ -0,0 +1,56 @@ +-- POKEPORT_ARENA_SPEC payload for tests/drivers/arena_boot_loopback.lua. + +local Net = require("src.link.Net") +local ArenaBoot = require("src.online.ArenaBoot") + +local hostNet, guestNet = Net.loopbackPair() + +local function packedMon(species) + return { + species = species, + level = 5, + hp = 4, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + dvs = { hp = 15, attack = 15, defense = 15, speed = 15, special = 15 }, + statExp = {}, + } +end + +local hostParty = { packedMon("CHARMANDER") } +local guestParty = { packedMon("SQUIRTLE") } +local seed = 424242 + +local ctx = { + hostNet = hostNet, + guestNet = guestNet, + hostParty = hostParty, + guestParty = guestParty, + seed = seed, + result = nil, +} +_G.POKEPORT_ARENA_TEST = ctx + +local spec, err = ArenaBoot.spec({ + profile = { + engine = 1, + version = "red", + engineVersion = "test", + apiVersion = 2, + fingerprint = "loopback", + rulesetId = nil, + kind = "vanilla", + rule = { partySize = 1 }, + }, + role = "host", + slotId = "slot1", + team = { 1 }, + seed = seed, + peerName = "BLUE", + myParty = hostParty, + theirParty = guestParty, + session = hostNet, + onDone = function(result) ctx.result = result or "ended" end, +}) + +if not spec then error("arena spec invalid: " .. tostring(err)) end +return spec diff --git a/tests/drivers/battle_scy_deform_bug1895_test.lua b/tests/drivers/battle_scy_deform_bug1895_test.lua new file mode 100644 index 00000000..4fb1f2b8 --- /dev/null +++ b/tests/drivers/battle_scy_deform_bug1895_test.lua @@ -0,0 +1,131 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/battle_scy_deform_bug1895_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/scy-deform love . +-- No POKEPORT_SPEED: the shots land on counted animation frames. +local U = require("tests.drivers.util") + +local BattleAnimView = require("src.ui.gen2.BattleAnimView") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/scy-deform" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[scy] ok " .. label) + else + failures = failures + 1 + print("[scy] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function shot(path) + if not U.shot(game, path) then failures = failures + 1 end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + game.save.party = { Mon.new(game.data, "TOTODILE", 20) } + game.save.inventory = { POTION = 3 } + assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed") + U.wait(8) + if not Permissions.isWalkable(world:playerCollision()) then + for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down") + and Permissions.isWalkable(world:playerCollision()) then + break + end + end + U.wait(8) + end + + assert(world:startBattle({ wild = Mon.new(game.data, "GYARADOS", 20) }), + "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + ok("the battle screen came up", battle ~= nil, battle) + if not battle then + print(("[scy] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + for _ = 1, 150 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok("and it reached the FIGHT menu", battle.phase == "menu", battle.phase) + U.wait(10) + shot(out .. "/00-battle.png") + + local function sampleWhile(label, frames) + local scanned, worst = 0, nil + for _ = 1, frames do + local anim = battle.anim + local bg = anim and anim.bg + if bg and (bg.lcdc or bg.scy ~= 0) then + local lines = BattleAnimView.scanlines(bg) + local seen, dup = {}, false + for _, line in ipairs(lines) do + if seen[line.dest] then dup = true end + seen[line.dest] = true + if line.src < 0 or line.src >= BattleAnimView.SCREEN_H then + worst = "source row " .. line.src .. " is off the panel" + end + end + if dup then worst = "two source rows landed on one scanline" end + scanned = scanned + 1 + end + U.wait(1) + end + ok(label .. ": " .. scanned .. " deformed frames, each scanline drawn once", + scanned > 0 and worst == nil, worst or "no deformed frame seen") + end + + local shots = { + { "WATER_GUN", "01-water-gun", 10, + "TOTODILE's WATER GUN: GYARADOS ripples in a gentle vertical wave, a", + "couple of pixels deep, and the enemy HUD above it breathes with it.", + "Wrong is the pic sliced into strips with blank rows between them." }, + { "CONFUSION", "02-confusion", 10, + "CONFUSION: the same window, amplitude 2, so an even softer ripple.", + "Nothing should tear, and no white gaps should open in the pic." }, + { "PSYCHIC_M", "03-psychic", 10, + "PSYCHIC is the rSCX effect, the control: the whole screen shears", + "SIDEWAYS. If this one stopped moving, the SCX path regressed." }, + { "WITHDRAW", "04-withdraw", 20, + "WITHDRAW parks rows on $90: TOTODILE sinks out of sight row by row.", + "Wrong is the pic staying put, or the whole panel jumping." }, + } + + for _, entry in ipairs(shots) do + local move, name, delay = entry[1], entry[2], entry[3] + battle.anim = nil + local started = battle:animForMove(move, move == "WITHDRAW" + and "player" or "enemy", 0, 10) + ok(move .. " has an extracted animation script", started, started) + if started then + U.wait(delay) + for i = 4, #entry do U.log(entry[i]) end + shot(out .. "/" .. name .. ".png") + sampleWhile(move, 30) + end + end + + battle.anim = nil + U.wait(6) + + print(failures == 0 and "[scy] PASS battle_scy_deform_bug1895" + or ("[scy] FAIL battle_scy_deform_bug1895 (%d)"):format(failures)) + U.log("the battle menu is yours; pick a move to watch a full animation.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/caught_ball_palette_bug1896_test.lua b/tests/drivers/caught_ball_palette_bug1896_test.lua new file mode 100644 index 00000000..8c0dad31 --- /dev/null +++ b/tests/drivers/caught_ball_palette_bug1896_test.lua @@ -0,0 +1,155 @@ +-- pokegold engine/battle_anims/anim_commands.asm:213 (#1896) +-- +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/caught_ball_palette_bug1896_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/caught-ball love . +-- +-- No POKEPORT_SPEED: the shot has to land on the click frame. +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/caught-ball" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[ball] PASS " .. label) + else + failures = failures + 1 + print("[ball] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function finish() + print(failures == 0 and "[ball] PASS caught_ball_palette_bug1896" + or ("[ball] FAIL caught_ball_palette_bug1896 (%d)"):format(failures)) + while true do coroutine.yield() end + end + + local function battleScreen() + local top = game.stack:top() + return (top and top.battle) and top or nil + end + + local function waitFor(predicate, frames) + for _ = 1, frames or 600 do + if predicate() then return true end + U.wait(1) + end + return predicate() and true or false + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local save = game.save + save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + save.inventory = { POTION = 2, POKE_BALL = 10 } + + local wild = Mon.new(game.data, "WOOPER", 5) + assert(wild, "the cache carries no WOOPER") + assert(world:startBattle({ wild = wild }), "the wild battle refused to start") + + local screen + assert(waitFor(function() + screen = battleScreen() + return screen ~= nil + end, 900), "the battle screen never came up") + + for _ = 1, 400 do + if screen.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + ok("the battle reached the BattleMenu", screen.phase == "menu", screen.phase) + if screen.phase ~= "menu" then finish() end + + screen.battle.random = function() return 0 end + + if screen.menuIndex % 2 == 0 then + U.tap(game, "left") + U.wait(3) + end + if screen.menuIndex <= 2 then + U.tap(game, "down") + U.wait(3) + end + ok("the cursor sat on the PACK row", screen.menuIndex == 3, screen.menuIndex) + U.tap(game, "a") + U.wait(6) + + local pack = game.stack:top() + ok("the battle PACK opened", pack ~= nil and pack.rows ~= nil, pack) + if not (pack and pack.rows) then finish() end + + for _ = 1, 4 do + if pack:pocket().id == "BALL" then break end + U.tap(game, "right") + U.wait(4) + end + ok("and it crossed to the POKe BALLS pocket", pack:pocket().id == "BALL", + pack:pocket().id) + + local ballRow + for index, row in ipairs(pack.rows) do + if row.id == "POKE_BALL" then ballRow = index end + end + ok("the POKE BALL is on the list", ballRow ~= nil, #pack.rows) + if not ballRow then finish() end + + for _ = 1, 20 do + if pack.index == ballRow then break end + U.tap(game, pack.index < ballRow and "down" or "up") + U.wait(3) + end + ok("the cursor reached it", pack.index == ballRow, pack.index) + U.tap(game, "a") + U.wait(6) + + ok("the throw was rolled as a catch", + screen.ballThrow ~= nil and screen.ballThrow.caught == true, + screen.ballThrow and screen.ballThrow.caught) + ok("ANIM_THROW_POKE_BALL is running", screen.anim ~= nil, screen.anim) + if not screen.anim then finish() end + + local clicked = waitFor(function() + local anim = screen.anim + return anim ~= nil and anim:done() and anim.keepSprites + end, 1200) + ok("the wobble loop ended on .Click and kept its sprites", clicked, + screen.anim and screen.anim.keepSprites) + if not clicked then finish() end + + local kept, wrong = 0, nil + for _, obj in ipairs(screen.anim:oam()) do + kept = kept + 1 + if obj.palette ~= "PAL_BATTLE_OB_ENEMY" then wrong = obj.palette end + end + ok("the ball's OBJs outlived the script", kept > 0, kept) + ok(("all %d kept OBJs are on the enemy palette slot"):format(kept), + kept > 0 and wrong == nil, wrong) + + local gotcha = waitFor(function() + local text = screen.message + return type(text) == "string" and text:find("Gotcha") ~= nil + end, 600) + ok("the Gotcha line is on screen", gotcha, screen.message) + + local stillThere = screen.anim ~= nil and #screen.anim:oam() > 0 + ok("and the ball is still parked under it", stillThere, screen.anim) + + U.log("shot 01: the ball sits on the ground where WOOPER was, with the") + U.log("Gotcha line under it. Right looks like a ball wearing WOOPER's own") + U.log("blues, darker and muted; wrong is the bright red and white ball that") + U.log("was thrown, unchanged from the frame before the click.") + if not U.shot(game, out .. "/01-gotcha-ball.png") then failures = failures + 1 end + + print(failures == 0 and "[ball] PASS caught_ball_palette_bug1896" + or ("[ball] FAIL caught_ball_palette_bug1896 (%d)"):format(failures)) + U.log("the battle is left standing on the Gotcha line; press A in the") + U.log("window to carry on through the catch.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/discord_join_test.lua b/tests/drivers/discord_join_test.lua deleted file mode 100644 index 1f34fe07..00000000 --- a/tests/drivers/discord_join_test.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Driver: hosts a real online match (against the deployed relay) and --- reports Discord IPC connection/subscribe/join-code state, so the whole --- "Ask to Join" wiring can be checked against the actual local Discord --- client instead of guessing from code review alone. -return function(game) - local U = dofile("tests/drivers/util.lua") - local DiscordPresence = require("src.core.DiscordPresence") - local Pokemon = require("src.pokemon.Pokemon") - - local function report(line) - U.log(line) - local f = io.open("/tmp/discord_test_status.txt", "a") - if f then f:write(line .. "\n"); f:close() end - end - - game.save.party = { Pokemon.new(game.data, "PIKACHU", 25) } - U.teleport(game, "PALLET_TOWN", 10, 8, "down") - - U.wait(30) -- give the Discord IPC connection a moment to establish - local s = DiscordPresence._state - report(("discord: enabled=%s connected=%s subscribedJoin=%s"):format( - tostring(s.enabled), tostring(s.connected), tostring(s.subscribedJoin))) - - local LinkState = require("src.link.LinkState") - local link = LinkState.new(game) - game.stack:push(link) - U.wait(3) - U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row - U.tap(game, "a"); U.wait(3) -- into onlineMenu - U.tap(game, "a"); U.wait(30) -- HOST ONLINE -> connect to the real relay - - local waited = 0 - while not link.net.code and waited < 300 do - U.wait(1) - waited = waited + 1 - end - report(("host code: %s net error: %s"):format( - tostring(link.net.code), tostring(link.net.error))) - - U.wait(60) -- let DiscordPresence pick up the code and push an activity - report(("discord joinCode now: %s connected: %s"):format( - tostring(s.joinCode), tostring(s.connected))) - report("DISCORD_JOIN_TEST: check your Discord profile/status now -- it") - report("should show an 'Ask to Join' button, code " .. tostring(link.net.code)) - - while true do - coroutine.yield() - end -end diff --git a/tests/drivers/fly_music_bug1892_test.lua b/tests/drivers/fly_music_bug1892_test.lua new file mode 100644 index 00000000..1d6b3429 --- /dev/null +++ b/tests/drivers/fly_music_bug1892_test.lua @@ -0,0 +1,68 @@ +-- home/overworld.asm (#1892) +-- POKEPORT_DRIVER=tests/drivers/fly_music_bug1892_test.lua \ +-- POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Music = require("src.core.Music") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function waitFor(pred, limit) + local guard = 0 + while not pred() and guard < (limit or 900) do + guard = guard + 1 + coroutine.yield() + end + return pred() + end + + U.teleport(game, "ROUTE_17", 4, 10, "down") + local ow = game.stack:top() + local destSong = game.data.audio.mapSongs.PALLET_TOWN + U.log("Pallet Town theme is " .. tostring(destSong)) + + local Screens = require("src.ui.Screens") + Screens.push(game, "TownMap", { fly = true, onFly = function() end }) + for _ = 1, 20 do coroutine.yield() end + U.shot(game, DIR .. "/fly_music_1_picker.png") + U.log("Picker: two small black triangles sit in the top-right of the" + .. " name strip, one pointing up, one pointing down") + game.stack:pop() + for _ = 1, 4 do coroutine.yield() end + + ow:flyTo("PALLET_TOWN") + + if not waitFor(function() return ow.map.id == "PALLET_TOWN" end) then + U.log("FAIL never reached PALLET_TOWN") + while true do coroutine.yield() end + end + + local earlyStart = nil + local frames = 0 + while ow.flyArrive and frames < 300 do + if Music.current() == destSong then + earlyStart = earlyStart or frames + end + frames = frames + 1 + coroutine.yield() + end + U.shot(game, DIR .. "/fly_music_2_swoop.png") + + if earlyStart then + U.log(("FAIL the Pallet theme started %d frames into the landing swoop") + :format(earlyStart)) + else + U.log("PASS the destination theme stayed silent through the swoop") + end + + for _ = 1, 8 do coroutine.yield() end + if Music.current() == destSong then + U.log("PASS the Pallet theme started once the bird landed") + else + U.log("FAIL after landing the current song is " + .. tostring(Music.current())) + end + U.shot(game, DIR .. "/fly_music_3_landed.png") + + U.log("Screenshots are under " .. DIR) + while true do coroutine.yield() end +end diff --git a/tests/drivers/game_corner_bug624_test.lua b/tests/drivers/game_corner_bug624_test.lua index 7254cb94..95daf5ad 100644 --- a/tests/drivers/game_corner_bug624_test.lua +++ b/tests/drivers/game_corner_bug624_test.lua @@ -9,7 +9,7 @@ return function(game) local U = dofile("tests/drivers/util.lua") local ChoiceBox = require("src.ui.ChoiceBox") - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") local TextBox = require("src.render.TextBox") local mapScripts = require("data.scripts.init") @@ -197,7 +197,7 @@ return function(game) U.tap(game, "a") U.wait(20) local function listUp() - return getmetatable(game.stack:top()) == ListMenu + return getmetatable(game.stack:top()) == PrizeCounter end mashUntil(listUp, 600) local list = listUp() and game.stack:top() or nil @@ -206,20 +206,17 @@ return function(game) if list then -- wMaxMenuItem is 3: this counter's three prizes and NO THANKS, never -- the whole catalogue (#623) - local rows = list.items or {} + local rows = list.prizes or {} local names = {} - for _, item in ipairs(rows) do - names[#names + 1] = item.label .. - (item.right and (" " .. item.right) or "") + for _, row in ipairs(rows) do + names[#names + 1] = row.name .. " " .. tostring(row.cost) end U.log("counter 1 offers:", table.concat(names, ", ")) - check("four rows: three prizes and NO THANKS", #rows == 4) - check("the last row is the NO THANKS exit", - rows[4] ~= nil and rows[4].label == "NO THANKS" - and rows[4].value == nil) + check("three prizes, with NO THANKS as the fourth cursor row", #rows == 3) local priced = true - for i = 1, math.min(3, #rows) do - if not (rows[i].value and tonumber(rows[i].right)) then priced = false end + for i = 1, #rows do + if not (rows[i].prize and tonumber(rows[i].cost)) then priced = false end + if tostring(rows[i].name):find("L%d") then priced = false end end check("each prize names a real species or TM and a coin price", priced) check("captured the prize list", @@ -240,8 +237,7 @@ return function(game) U.log("it asks:", (said:gsub("^%s+", ""))) check("the question names the prize, not {RAM:wNameBuffer}", said:find("wNameBuffer", 1, true) == nil - and rows[1] ~= nil and said:find(rows[1].label:match("^%S+"), 1, true) - ~= nil) + and rows[1] ~= nil and said:find(rows[1].name, 1, true) ~= nil) check("captured the confirmation", U.shot(game, SHOT_DIR .. "/bug623_prize_confirm.png")) end @@ -258,8 +254,9 @@ return function(game) U.log("out under the YES/NO, MONEY ¥3000 and COIN 500 in the top-right") U.log("window. the near-miss to look for is that window going stale, still") U.log("reading ¥3000 after the 50 coins are bought.") - U.log("the prize list is still full-screen and the YES/NO lands on top of") - U.log("the third row. the small windowed menu half of #623 was left alone.") + U.log("the prize window is the cart's two boxes over the prize room: COIN") + U.log("top-right, names with their coin prices on the row below, and the") + U.log("floor still visible to the right of it (#1867).") while true do coroutine.yield() diff --git a/tests/drivers/gold_battle_size_bug1709_test.lua b/tests/drivers/gold_battle_size_bug1709_test.lua new file mode 100644 index 00000000..56606b49 --- /dev/null +++ b/tests/drivers/gold_battle_size_bug1709_test.lua @@ -0,0 +1,204 @@ +-- maps/Route29.asm:432 (#1709) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_battle_size_bug1709_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-battle-size love . +-- No POKEPORT_SPEED: the shots land on counted frames. +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local OptionsMenu = require("src.ui.gen2.OptionsMenu") +local Permissions = require("src.world.gen2.Permissions") +local Save = require("src.core.gen2.Save") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-battle-size" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[battlesize] ok " .. label) + else + failures = failures + 1 + print("[battlesize] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 6) + end + + local function shot(path) + if not U.shot(game, path) then failures = failures + 1 end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + local BattleState = require("src.ui.gen2.BattleState") + + ok("battleFit has a default in the Gold options table", + Save.DEFAULT_OPTIONS.battleFit == "fixed", Save.DEFAULT_OPTIONS.battleFit) + + local sizeRow + for i, row in ipairs(OptionsMenu.ROWS) do + if row.label == "BATTLE SIZE" then sizeRow = i end + end + ok("OPTION carries a BATTLE SIZE row", sizeRow ~= nil, sizeRow) + if sizeRow then + ok("and BATTLE BG follows it", + OptionsMenu.ROWS[sizeRow + 1] + and OptionsMenu.ROWS[sizeRow + 1].key == "battleBg", sizeRow) + end + ok("the battle screen reads the option rather than answering true", + BattleState.wantsFillScale({ game = { options = { battleFit = "fixed" } } }) + == false, "stub") + + if love.window and love.window.setMode then + love.window.setMode(1280, 840, { resizable = true }) + U.wait(6) + end + local winW, winH = love.graphics.getDimensions() + local fixed = Chrome.fitScale(winW, winH) + local fill = BattleState.fillScale(winW, winH) + ok(("the window makes FILL fractional (%dx%d, fixed x%d, fill x%.3f)") + :format(winW, winH, fixed, fill), fill > fixed and fill % 1 > 0.01, + fixed .. " / " .. fill) + + local player = Mon.new(game.data, "CYNDAQUIL", 12) + local wild = Mon.new(game.data, "PIDGEY", 4) + ok("CYNDAQUIL builds from the extracted tables", + player ~= nil and #player.moves > 0, player and #player.moves) + game.save.party = { player } + game.save.inventory = { POKE_BALL = 5, POTION = 3 } + + assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed") + U.wait(8) + if not Permissions.isWalkable(world:playerCollision()) then + for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 }, + { 2, 0 }, { -2, 0 } }) do + if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down") + and Permissions.isWalkable(world:playerCollision()) then + break + end + end + U.wait(8) + end + ok("the player is standing on floor, not in a wall", + Permissions.isWalkable(world:playerCollision()), + tostring(world:playerCollision())) + + print(failures == 0 + and "[battlesize] preflight PASS -- the shots below are worth looking at" + or ("[battlesize] preflight FAIL (%d) -- fix these before judging a pixel") + :format(failures)) + + game.options.battleFit = "fixed" + game.options.battleBg = "white" + + assert(world:startBattle({ wild = wild }), "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + ok("the battle screen came up after the transition", battle ~= nil, battle) + if not battle then + print(("[battlesize] FAIL no battle to shoot (%d)"):format(failures)) + while true do coroutine.yield() end + end + for _ = 1, 150 do + if battle.phase == "menu" then break end + tap("a", 2) + end + ok("the battle reached the FIGHT menu", battle.phase == "menu", battle.phase) + ok("and it reports FIXED", battle:wantsFillScale() == false, + battle:wantsFillScale()) + U.wait(10) + + U.log("01: the battle on FIXED. this is the reference shot: every box border") + U.log("is one even line, the HUD rules are even, and the glyphs are whole.") + shot(out .. "/01-battle-fixed.png") + + game.options.battleFit = "fill" + U.wait(8) + ok("the battle now reports FILL", battle:wantsFillScale() == true, + battle:wantsFillScale()) + U.log("02: the same battle on FILL. the panel is bigger and reaches the top") + U.log("and bottom edges. what to judge: the message box border, the HUD") + U.log("rules under both names, and the level and HP digits. some stepping is") + U.log("inherent at a fractional scale; a border that breaks into visible") + U.log("stairs, or a glyph missing a pixel row, is too much to ship.") + shot(out .. "/02-battle-fill.png") + + local function pointAt(index) + tap("left", 3) + tap("up", 3) + if index == 2 or index == 4 then tap("right", 3) end + if index == 3 or index == 4 then tap("down", 3) end + return battle.menuIndex == index + end + + ok("the cursor is on FIGHT", pointAt(1), battle.menuIndex) + tap("a", 12) + U.log("03: the move list on FILL. the move names, TYPE/ and the PP figures") + U.log("are the smallest text in the game -- this is where a lost pixel row") + U.log("shows first.") + shot(out .. "/03-moves-fill.png") + tap("b", 12) + + ok("the cursor is on PKMN", pointAt(2), battle.menuIndex) + tap("a", 12) + U.log("04: the party list opened over the FILL battle. PARTY paints its own") + U.log("surround at the whole-pixel fit, so the panel steps back down to the") + U.log("FIXED size here and the void around it must be clean -- no paper ring") + U.log("left where the bigger battle panel was. carrying FILL into PARTY and") + U.log("PACK is still open work.") + shot(out .. "/04-party-over-fill.png") + tap("b", 12) + for _ = 1, 20 do + if battle.phase == "menu" then break end + tap("b", 4) + end + + game.options.battleBg = "black" + U.wait(8) + U.log("05: FILL with BATTLE BG on BLACK. the black hugs the bigger panel:") + U.log("no white strip left on an edge, and no black creeping over the HUD.") + shot(out .. "/05-fill-black-bg.png") + game.options.battleBg = "white" + + for _, mode in ipairs({ "upper", "top" }) do + game.options.screenPos = mode + game:applyOptions() + U.wait(8) + U.log(("06-%s: SCREEN POS %s at FILL. the panel moves as a whole; it must") + :format(mode, mode)) + U.log("not shear or leave a sliver at the edge it was lifted from.") + shot(out .. ("/06-screenpos-%s-fill.png"):format(mode)) + end + game.options.screenPos = "center" + game:applyOptions() + U.wait(8) + + game.options.battleFit = "fixed" + U.wait(8) + ok("and back to FIXED", battle:wantsFillScale() == false, + battle:wantsFillScale()) + U.log("07: back on FIXED, for the same comparison as 01. nothing about the") + U.log("panel should have changed while FILL was on and off again.") + shot(out .. "/07-battle-fixed-again.png") + + print(failures == 0 and "[battlesize] PASS gold_battle_size_bug1709" + or ("[battlesize] FAIL gold_battle_size_bug1709 (%d)"):format(failures)) + U.log("the battle is still up on FIXED and the controls are yours. OPTION ->") + U.log("BATTLE SIZE is the row to flip by hand if you want another look.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_bike_mount_bug1481_test.lua b/tests/drivers/gold_bike_mount_bug1481_test.lua new file mode 100644 index 00000000..11efc254 --- /dev/null +++ b/tests/drivers/gold_bike_mount_bug1481_test.lua @@ -0,0 +1,69 @@ +-- pokegold engine/events/overworld.asm:1616-1630, :1686 (#1481) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_bike_mount_bug1481_test.lua love . +-- No POKEPORT_SPEED: the song starts on the audio clock. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") + +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } +local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + +return function(game) + local failed = 0 + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local world, save = game.world, game.save + + world:setMap(HOME.map, HOME.x, HOME.y, "down") + U.wait(12) + + local sprites = game.data and game.data.gen2Sprites or {} + pass(sprites.SPRITE_CHRIS_BIKE ~= nil, + "this cache carries SPRITE_CHRIS_BIKE, the sheet the mount swaps to") + local songs = game.data and game.data.audio and game.data.audio.songs or {} + pass(songs.Music_Bicycle ~= nil, + "and Music_Bicycle, the song .GetOnBike starts") + + save.inventory = { BICYCLE = 1 } + save.bagOrder = { "BICYCLE" } + Bag.order(save, { items = game.data.items }) + + game.packCursor = nil + -- data/items/attributes.asm + local pack = PackMenu.new(game, { save = save, world = world, + pocket = "KEY_ITEM", onClose = function() game.stack:pop() end }) + game.stack:push(pack) + U.wait(20) + + -- pokegold engine/items/pack.asm:243 + U.tap(game, "a") + U.wait(20) + U.tap(game, "a") + + for _ = 1, 150 do + if world.playerState == "bike" then break end + coroutine.yield() + end + pass(world.playerState == "bike", + "the queued Script_GetOnBike reached loadvar VAR_MOVEMENT, PLAYER_BIKE") + local def = world.player and world.player.spriteDef + pass(def ~= nil and def.id == "SPRITE_CHRIS_BIKE", + "and the player is on the rider sheet") + U.shot(game, SHOT_DIR .. "/bike_mount.png") + + U.tap(game, "a") + U.wait(40) + U.shot(game, SHOT_DIR .. "/bike_after_box.png") + U.log("the box above should read \"got on the BICYCLE!\" and the bike theme" + .. " should be playing; both are for a human to judge") + U.log(failed == 0 and "bike mount ran clean" + or (failed .. " machine-checkable parts failed")) + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_dex_area_bug1891_test.lua b/tests/drivers/gold_dex_area_bug1891_test.lua new file mode 100644 index 00000000..76d56252 --- /dev/null +++ b/tests/drivers/gold_dex_area_bug1891_test.lua @@ -0,0 +1,94 @@ +-- engine/pokegear/pokegear.asm:2285 (#1891) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_dex_area_bug1891_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/gold-dex-area love . +-- No POKEPORT_SPEED: the markers blink on a frame counter. +local U = require("tests.drivers.util") + +local HallOfFame = require("src.core.gen2.HallOfFame") +local Nests = require("src.core.gen2.Nests") +local PokedexMenu = require("src.ui.gen2.PokedexMenu") + +local SPECIES = "MILTANK" + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-dex-area" + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + local function shot(name) + U.wait(4) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local save = game.save + + save.pokedex = save.pokedex or {} + save.pokedex.seen = save.pokedex.seen or {} + save.pokedex.caught = save.pokedex.caught or {} + save.pokedex.seen[SPECIES] = true + save.pokedex.caught[SPECIES] = true + + local johto = Nests.find(game.data, SPECIES, "johto", save) + local kanto = Nests.find(game.data, SPECIES, "kanto", save) + pass(#johto == 2, ("%s has %d Johto nests; the cart's FindNest answers 2 " .. + "(Routes 38 and 39)"):format(SPECIES, #johto)) + pass(#kanto == 0, ("and %d in Kanto, where it answers none"):format(#kanto)) + + local gear = game.data and game.data.gen2MenuGfx and game.data.gen2MenuGfx.pokegear + pass(gear ~= nil and gear.maps ~= nil, "this cache carries the town maps") + pass(gear ~= nil and gear.nestIcon ~= nil, + "and PokedexNestIconGFX; without it the markers fall back to a plain " .. + "square and the cache wants a re-import") + + local dex = PokedexMenu.new(game, {}) + for i, row in ipairs(dex.rows) do + if row.species == SPECIES then dex.index = i break end + end + dex:ensureVisible() + dex.view = "area" + dex.areaRegion = nil + game.stack:push(dex) + U.wait(10) + + pass(dex:areaRegionName() == "johto", + "the page opens on Johto, whatever region the player is standing in") + + dex.areaBlink = 0 + U.log("00-johto-on: the Johto map, edge to edge, with no red or orange") + U.log("anywhere. row 0 is a cream strip reading \"MILTANK'S NEST\" in dark") + U.log("letters from the third column, row 1 the map's own rule. two markers") + U.log("blink over Routes 38 and 39, west of the middle. the word JOHTO is") + U.log("only the one baked into the map at the bottom right; no landmark") + U.log("name, no \"+1\", no second JOHTO up top.") + shot("00-johto-on") + + dex.areaBlink = 16 + U.log("01-johto-off: the same screen with both markers gone. sixteen frames") + U.log("on, sixteen off.") + shot("01-johto-off") + + dex.areaBlink = 0 + dex:updateArea({ wasPressed = function(_, b) return b == "right" end }) + pass(dex:areaRegionName() == "johto" or HallOfFame.hasEntered(save), + "right is refused until the Hall of Fame bit is set") + + HallOfFame.record(save).count = math.max(1, HallOfFame.count(save)) + dex:updateArea({ wasPressed = function(_, b) return b == "right" end }) + dex.areaBlink = 0 + pass(dex:areaRegionName() == "kanto", "and taken once it is") + U.log("02-kanto: the Kanto map with nothing blinking on it and the same") + U.log("cream caption on top. no \"AREA UNKNOWN\" printed over the baked-in") + U.log("KANTO at the bottom left.") + shot("02-kanto") + + U.log(("%d check(s) failed"):format(failed)) + + while true do coroutine.yield() end +end diff --git a/tests/drivers/gold_pack_sfx_bug1893_test.lua b/tests/drivers/gold_pack_sfx_bug1893_test.lua new file mode 100644 index 00000000..aa0599ff --- /dev/null +++ b/tests/drivers/gold_pack_sfx_bug1893_test.lua @@ -0,0 +1,120 @@ +-- engine/menus/scrolling_menu.asm:23 (#1893) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/gold_pack_sfx_bug1893_test.lua love . +-- No POKEPORT_SPEED: the cues are the thing under test. +local U = require("tests.drivers.util") + +local Bag = require("src.inventory.Bag") +local PackMenu = require("src.ui.gen2.PackMenu") +local Sound = require("src.core.Sound") + +local HOME = { map = "NEW_BARK_TOWN", x = 13, y = 6 } + +local SEED = { + { "POTION", 5 }, + { "SUPER_POTION", 3 }, + { "ESCAPE_ROPE", 2 }, + { "POKE_BALL", 7 }, +} + +local CUES = { "Sfx_ReadText2", "Sfx_SwitchPockets", "Sfx_SwitchPokemon" } + +return function(game) + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + local world, save = game.world, game.save + + world:setMap(HOME.map, HOME.x, HOME.y, "down") + U.wait(12) + + local sfx = game.data and game.data.audio and game.data.audio.sfx or {} + for _, name in ipairs(CUES) do + pass(sfx[Sound.resolve(game.data, name)] ~= nil, + ("this cache carries %s, so the PACK has something to play"):format(name)) + end + + save.inventory = {} + save.bagOrder = {} + for _, entry in ipairs(SEED) do + if game.data.items and game.data.items[entry[1]] then + save.inventory[entry[1]] = entry[2] + table.insert(save.bagOrder, entry[1]) + else + pass(false, entry[1] .. " is not in this cache, so its row is missing") + end + end + Bag.order(save, { items = game.data.items }) + + local heard = {} + local realPlay = Sound.play + Sound.play = function(data, name) + heard[#heard + 1] = name + return realPlay(data, name) + end + + local function since(mark) + return heard[#heard] ~= nil and #heard > mark and heard[#heard] or "nothing" + end + + game.packCursor = nil + local pack = PackMenu.new(game, { save = save, world = world, + onClose = function() game.stack:pop() end }) + game.stack:push(pack) + U.wait(20) + + local function tap(button, frames) + U.tap(game, button) + U.wait(frames or 25) + end + + U.log("listen from here: five sounds, in this order.") + + local mark = #heard + tap("down") + tap("up") + pass(#heard == mark, "moving up and down the list plays nothing") + U.log("1. no sound at all while the arrow walks the list.") + + mark = #heard + tap("right") + pass(since(mark) == "Sfx_SwitchPockets", + ("right into POKe BALLS played %s"):format(since(mark))) + U.log("2. the pocket flip: a short two-note sweep as POKe BALLS comes up.") + + mark = #heard + tap("left") + pass(since(mark) == "Sfx_SwitchPockets", + ("left back to ITEMS played %s"):format(since(mark))) + U.log("3. the same sweep coming back to ITEMS.") + + mark = #heard + tap("a") + pass(since(mark) == "Sfx_ReadText2", + ("A on POTION played %s"):format(since(mark))) + U.log("4. the menu click as the USE/GIVE/TOSS box opens, and the same") + U.log(" click again on the B below.") + tap("b") + + mark = #heard + tap("select") + pass(#heard == mark, "SELECT arming the move is silent on the cart too") + tap("down") + tap("a") + pass(since(mark) == "Sfx_SwitchPokemon", + ("placing the item played %s"):format(since(mark))) + U.log("5. the party-swap chirp as POTION lands on the second row. the cart") + U.log(" plays it twice, one after the other; this port plays it once.") + + Sound.play = realPlay + U.log(("%d cue(s) missing"):format(failed)) + U.log("cues dispatched: " .. table.concat(heard, ", ")) + + while true do coroutine.yield() end +end diff --git a/tests/drivers/link_addr_entry_bug1295_test.lua b/tests/drivers/link_addr_entry_bug1295_test.lua new file mode 100644 index 00000000..050536be --- /dev/null +++ b/tests/drivers/link_addr_entry_bug1295_test.lua @@ -0,0 +1,39 @@ +-- POKEPORT_DRIVER=tests/drivers/link_addr_entry_bug1295_test.lua POKEPORT_IDENTITY=bug1295 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local LinkState = require("src.link.LinkState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("10.0.0.1 round-trips unpadded", + LinkState.addrText(LinkState.addrEntry("10.0.0.1")) == "10.0.0.1") + check("192.168.1.40 does not gain trailing zeroes", + LinkState.addrText(LinkState.addrEntry("192.168.1.40")) == "192.168.1.40") + + U.newGame(game) + U.wait(10) + + local link = LinkState.new(game) + link.stage = "addrEntry" + link.addr = LinkState.addrEntry("10.42.0.1") + game.stack:push(link) + U.wait(5) + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.shot(game, SHOT_DIR .. "/bug1295_addr_entry.png") + U.log("captured", SHOT_DIR .. "/bug1295_addr_entry.png") + + U.log("ENTER HOST ADDRESS is on screen, seeded with 10.42.0.1 and reading") + U.log("exactly that, the way the HOSTING screen prints it. Before #1295 it") + U.log("was twelve fixed digits and the same address read 010.042.000.001.") + U.log("Left/right walks the slots, up/down cycles 0-9 then '.' then blank,") + U.log("so a shorter address just leaves the tail slots empty. A on") + U.log("something that is not a dotted quad says so instead of dialling.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/menu_hold_scroll_bug1898_test.lua b/tests/drivers/menu_hold_scroll_bug1898_test.lua new file mode 100644 index 00000000..8a3f9fb1 --- /dev/null +++ b/tests/drivers/menu_hold_scroll_bug1898_test.lua @@ -0,0 +1,72 @@ +-- home/list_menu.asm:8 (#1898) +-- POKEPORT_DRIVER=tests/drivers/menu_hold_scroll_bug1898_test.lua POKEPORT_IDENTITY=bug1898 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local STOCK = { + "POTION", "SUPER_POTION", "ANTIDOTE", "PARLYZ_HEAL", "AWAKENING", + "BURN_HEAL", "ICE_HEAL", "REPEL", "ESCAPE_ROPE", "POKE_BALL", + "GREAT_BALL", "ULTRA_BALL", + } + game.save.inventory = {} + game.save.bagOrder = {} + for i, id in ipairs(STOCK) do + game.save.inventory[id] = 1 + game.save.bagOrder[i] = id + end + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + Screens.push(game, "BagMenu", {}) + U.wait(10) + + local list = game.stack:top() + check("the bag list is on top of the stack", list ~= nil and list.items ~= nil) + if not (list and list.items) then + while true do coroutine.yield() end + end + check("hold-to-scroll is on with no mod loaded", list.keyRepeat == true) + check("at the cart's 30-frame delay", list.repeatDelay == 30) + check("and its 5-frame repeat", list.repeatRate == 5) + + table.insert(game.input.pressQueue, "down") + game.input.state.down = true + local marks = {} + for frame = 1, 90 do + coroutine.yield() + marks[frame] = list.index + end + game.input.state.down = false + + U.log("cursor row after the press:", marks[1]) + U.log("after 29 held frames:", marks[30]) + U.log("after 30:", marks[31], "after 35:", marks[36], "after 59:", marks[60]) + check("the press alone moves one row", marks[1] == 2) + check("a short hold moves nothing more", marks[30] == 2) + check("the 30th held frame repeats", marks[31] == 3) + check("and every fifth frame after it", marks[36] == 4) + check("a long hold reaches the CANCEL row", marks[90] == #list.items) + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + if U.shot(game, SHOT_DIR .. "/bug1898_bag_bottom.png") then + U.log("captured", SHOT_DIR .. "/bug1898_bag_bottom.png") + end + + list.index, list.scroll = 1, 0 + U.wait(5) + U.log("The bag is open at the top of a twelve-item list, input handed back.") + U.log("Hold Down: the cursor steps once, pauses about half a second, then") + U.log("runs to CANCEL at roughly twelve rows a second. Hold Up to come") + U.log("back. Before this fix the cursor moved one row and stopped there.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/online_demo_common.lua b/tests/drivers/online_demo_common.lua new file mode 100644 index 00000000..b211e816 --- /dev/null +++ b/tests/drivers/online_demo_common.lua @@ -0,0 +1,294 @@ +-- Shared body for the two-window live-relay demo drivers. +-- +-- Armed by OnlinePanel's POKEPORT_ONLINE_SHOT hook (see online_shot.lua's +-- demo-host / demo-guest states), so the real launcher ONLINE tab is on +-- screen and every panel call below is the one the buttons make. A +-- coroutine wrapped around love.update keeps driving after the launcher +-- hands off to the arena, then stops and leaves the window open. +-- +-- POKEPORT_IDENTITY=pokemon-love2d-demo-a POKEPORT_LAUNCHER_TAB=online \ +-- POKEPORT_ONLINE_SHOT=demo-host love . + +local Demo = {} + +local SCRATCH = os.getenv("POKEPORT_DEMO_DIR") or "/tmp/pokeport_demo" +local SHOT_DIR = os.getenv("POKEPORT_DEMO_SHOTS") or (SCRATCH .. "/shots") +local CODE_FILE = os.getenv("POKEPORT_DEMO_CODE") or (SCRATCH .. "/demo_code.txt") +local TAP_EVERY = tonumber(os.getenv("POKEPORT_DEMO_TAP") or "") or 45 + +local function log(...) + local parts = { "[demo]" } + for i = 1, select("#", ...) do + parts[#parts + 1] = tostring((select(i, ...))) + end + print(table.concat(parts, " ")) + pcall(function() io.stdout:flush() end) +end + +Demo.log = log + +local function Client() + return require("src.online.Client") +end + +local function mkdir(path) + os.execute('mkdir -p "' .. path .. '" 2>/dev/null') +end + +local function readCode() + local f = io.open(CODE_FILE, "rb") + if not f then return nil end + local body = f:read("*a") + f:close() + body = tostring(body or ""):gsub("%s+", "") + if #body ~= 6 then return nil end + return body +end + +local function writeCode(code) + mkdir(SCRATCH) + local f = io.open(CODE_FILE, "wb") + if not f then return false end + f:write(code) + f:close() + return true +end + +local function partyLine(mon) + if type(mon) ~= "table" then return "?" end + return ("%s Lv%s hp%s"):format(tostring(mon.species), tostring(mon.level), + tostring(mon.hp)) +end + +-- ------------------------------------------------------------------ driver + +function Demo.run(OnlinePanel, imp, opts) + if Demo._armed then return end + Demo._armed = true + + mkdir(SHOT_DIR) + local prefix = opts.prefix + local shotN = 0 + + local co + local function wait(n) + for _ = 1, (n or 1) do coroutine.yield() end + end + + local function shot(tag) + shotN = shotN + 1 + local path = ("%s/%s_%d_%s.png"):format(SHOT_DIR, prefix, shotN, tag) + love.graphics.captureScreenshot(function(imagedata) + local ok, encoded = pcall(function() return imagedata:encode("png") end) + if not ok then return end + local f = io.open(path, "wb") + if f then + f:write(encoded:getString()) + f:close() + end + end) + wait(20) + local f = io.open(path, "rb") + if f then + f:close() + log("shot", path) + else + log("FAIL shot did not reach disk", path) + end + return path + end + + local function waitFor(fn, frames, what) + for _ = 1, (frames or 3600) do + local ok, hit = pcall(fn) + if ok and hit then return true end + coroutine.yield() + end + log("TIMEOUT waiting for", what) + return false + end + + local body = function() + wait(30) + if love.window and love.window.setMode then + local w, h = (os.getenv("POKEPORT_DEMO_WIN") or "900x700") + :match("^(%d+)x(%d+)$") + if w then + pcall(love.window.setMode, tonumber(w), tonumber(h), + { resizable = true }) + end + end + + local st = OnlinePanel.state(imp) + st.name = opts.name + st.version = "red" + st.cartId = nil + st.kind = "vanilla" + st.slotId = opts.slotId + st.public = true + st.note = opts.note or "" + st.setupDone = true + st.team = {} + for _, index in ipairs(opts.team) do + OnlinePanel.toggleTeam(st.team, index, 6) + end + OnlinePanel.invalidate(imp) + + local pick, why = OnlinePanel.readTeamSlot(imp) + if not pick then + log("FAIL cannot read", opts.slotId, tostring(why)) + return + end + log("identity", tostring(os.getenv("POKEPORT_IDENTITY"))) + log("save", opts.slotId, "trainer", tostring(pick.trainerName)) + for _, index in ipairs(opts.team) do + log(" team", index, partyLine(pick.party[index])) + end + + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + waitFor(function() return OnlinePanel.myProfile(imp) ~= nil end, 900, + "the arena profile") + + local Net = require("src.link.Net") + log("relay", tostring(Net.defaultRelayAddress()), "as", opts.name) + OnlinePanel.doConnect(imp) + if not waitFor(function() return Client().state() == "online" end, 3600, + "the relay to accept the connection") then + log("FAIL relay refused:", tostring(Client().error()), + "state:", tostring(Client().state())) + shot("connect_failed") + return + end + log("online as", tostring((Client().you() or {}).name)) + wait(40) + shot("connect") + + if opts.role == "host" then + os.remove(CODE_FILE) + OnlinePanel.startWizard(imp, "hostBattle") + for _, step in ipairs({ "game", "save", "team", "rules", "visibility", + "summary" }) do + OnlinePanel.wizardTo(imp, step) + wait(30) + end + log("rule partySize", tostring(OnlinePanel.ruleFor(imp).partySize)) + if not OnlinePanel.wizardNext(imp) then + log("FAIL hosting refused:", tostring(st.status)) + return + end + if not waitFor(function() return Client().room() ~= nil end, 3600, + "the room to open") then + log("FAIL no room:", tostring(st.status)) + return + end + local code = Client().room().code + log("ROOM CODE", code) + writeCode(code) + wait(60) + shot("room") + if not waitFor(function() + local room = Client().room() + return room and #(room.players or {}) >= 2 + end, 60 * 60 * 4, "the guest to join") then + return + end + log("guest joined") + else + log("waiting for the host's room code at", CODE_FILE) + local code + if not waitFor(function() + code = readCode() + return code ~= nil + end, 60 * 60 * 4, "the host's room code") then + return + end + log("ROOM CODE", code) + if not OnlinePanel.startJoin(imp, code, { partySize = #opts.team }, + "player") then + log("FAIL join wizard refused:", tostring(st.status)) + return + end + for _, step in ipairs({ "game", "save", "team", "summary" }) do + OnlinePanel.wizardTo(imp, step) + wait(30) + end + if not OnlinePanel.wizardNext(imp) then + log("FAIL join refused:", tostring(st.status)) + return + end + if not waitFor(function() return Client().room() ~= nil end, 3600, + "the room to answer") then + log("FAIL not in a room:", tostring(st.status)) + return + end + log("joined room", tostring(Client().room().code)) + wait(60) + shot("room") + end + + wait(60) + if not OnlinePanel.sendReady(imp) then + log("FAIL READY refused:", tostring(st.status)) + return + end + log("READY sent") + + local GameMod + if not waitFor(function() + local ok, mod = pcall(require, "src.core.Game") + if not ok or type(mod) ~= "table" then return false end + if not (mod.linkSession and mod.stack and mod.stack.top) then + return false + end + GameMod = mod + return mod.stack:top() ~= nil + end, 60 * 60 * 3, "the arena to boot") then + log("FAIL the arena never booted") + return + end + log("arena booted, battling") + wait(180) + shot("first_turn") + + local frames, midShot = 0, false + while OnlinePanel.lastResult == nil do + frames = frames + 1 + if frames % TAP_EVERY == 0 then + local input = GameMod.input + if input and input.pressQueue then + table.insert(input.pressQueue, "a") + end + end + if not midShot and frames > 900 then + midShot = true + shot("mid_battle") + end + if frames > 60 * 60 * 8 then + log("FAIL the battle never finished") + break + end + coroutine.yield() + end + + log("RESULT", tostring(OnlinePanel.lastResult)) + shot("result") + wait(300) + shot("room_screen") + log("DEMO COMPLETE - window left open, no further input") + while true do coroutine.yield() end + end + + co = coroutine.create(body) + local base = love.update + love.update = function(dt) + base(dt) + if coroutine.status(co) == "suspended" then + local ok, err = coroutine.resume(co) + if not ok then log("DRIVER ERROR", tostring(err)) end + end + end + log("armed", opts.role, "shots ->", SHOT_DIR) +end + +return Demo diff --git a/tests/drivers/online_demo_guest.lua b/tests/drivers/online_demo_guest.lua new file mode 100644 index 00000000..90599552 --- /dev/null +++ b/tests/drivers/online_demo_guest.lua @@ -0,0 +1,16 @@ +-- Live-relay demo, guest seat. Armed by POKEPORT_ONLINE_SHOT=demo-guest. +-- +-- POKEPORT_IDENTITY=pokemon-love2d-demo-b POKEPORT_TOUCH=0 \ +-- POKEPORT_LAUNCHER_TAB=online POKEPORT_ONLINE_SHOT=demo-guest love . + +local Demo = require("tests.drivers.online_demo_common") + +return function(OnlinePanel, imp) + return Demo.run(OnlinePanel, imp, { + role = "guest", + prefix = "guest", + name = os.getenv("POKEPORT_DEMO_NAME") or "DEMO-B", + slotId = os.getenv("POKEPORT_DEMO_SLOT") or "slot3", + team = { 1, 3, 4 }, + }) +end diff --git a/tests/drivers/online_demo_host.lua b/tests/drivers/online_demo_host.lua new file mode 100644 index 00000000..f88117e1 --- /dev/null +++ b/tests/drivers/online_demo_host.lua @@ -0,0 +1,17 @@ +-- Live-relay demo, host seat. Armed by POKEPORT_ONLINE_SHOT=demo-host. +-- +-- POKEPORT_IDENTITY=pokemon-love2d-demo-a POKEPORT_TOUCH=0 \ +-- POKEPORT_LAUNCHER_TAB=online POKEPORT_ONLINE_SHOT=demo-host love . + +local Demo = require("tests.drivers.online_demo_common") + +return function(OnlinePanel, imp) + return Demo.run(OnlinePanel, imp, { + role = "host", + prefix = "host", + name = os.getenv("POKEPORT_DEMO_NAME") or "DEMO-A", + slotId = os.getenv("POKEPORT_DEMO_SLOT") or "slot1", + team = { 1, 2, 3 }, + note = "live relay demo", + }) +end diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua deleted file mode 100644 index 0a6c9123..00000000 --- a/tests/drivers/online_match_host.lua +++ /dev/null @@ -1,148 +0,0 @@ --- Driver: play a real online match, hosting side. --- --- Pair with tests/drivers/online_match_join.lua in a second instance: --- --- POKEPORT_IDENTITY=pokehost ONLINE_CODE_FILE=/tmp/poke_code.txt \ --- POKEPORT_DRIVER=tests/drivers/online_match_host.lua love . --- POKEPORT_IDENTITY=pokeguest ONLINE_CODE_FILE=/tmp/poke_code.txt \ --- POKEPORT_DRIVER=tests/drivers/online_match_join.lua love . --- --- Two real windows, the real relay (POKEPORT_RELAY_ADDR to point elsewhere), --- the real LinkState menus and the real lockstep battle -- the loopback --- suites can't see anything the transport, the two separate processes or --- the two save identities contribute. The host writes its room code to --- ONLINE_CODE_FILE for the joiner to pick up, since on a real screen that --- code is read aloud to a friend. --- --- Prints ONLINE_MATCH_HOST: lines; the wrapper script greps them. - -return function(game) - local U = dofile("tests/drivers/util.lua") - local Pokemon = require("src.pokemon.Pokemon") - local LinkState = require("src.link.LinkState") - local Runtime = require("src.mods.Runtime") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" - local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt" - local TAG = "ONLINE_MATCH_HOST:" - - local function log(...) U.log(TAG, ...) end - - -- a desync is the whole point of the exercise, so make it loud - local desyncs = {} - -- wrap emit rather than subscribing: with no mods loaded Runtime.events is - -- the null sink, and a vanilla online run is exactly the case under test - local realEmit = Runtime.emit - Runtime.emit = function(name, p) - if name == "link.desync" and p then - desyncs[#desyncs + 1] = p - log(("DESYNC turn=%s component=%s fatal=%s"):format( - tostring(p.turn), tostring(p.component), tostring(p.fatal))) - end - return realEmit(name, p) - end - - game.save.player.name = "HOST" - game.save.party = { - Pokemon.new(game.data, "CHARIZARD", 50), - Pokemon.new(game.data, "SNORLAX", 50), - Pokemon.new(game.data, "ALAKAZAM", 50), - } - U.teleport(game, "PALLET_TOWN", 10, 8, "down") - U.wait(30) - - local link = LinkState.new(game) - game.stack:push(link) - U.wait(10) - - -- the GAME SPEED forcing under test: a link session pins the logic clock - -- to 1X no matter what the option or POKEPORT_SPEED says. RFC 0007: set - -- all three per-category speeds high, since the link lock has to win over - -- every one of them, not just whichever category happens to be active. - game.save.options.speedOverworld = 10 - game.save.options.speedBattle = 10 - game.save.options.speedMenu = 10 - U.wait(2) - log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed()) - - -- top menu: LAN / ONLINE MATCH / TOURNAMENT - U.tap(game, "down"); U.wait(3) - U.tap(game, "a"); U.wait(5) -- ONLINE MATCH - if link.stage ~= "onlineMenu" then - log("FAIL expected onlineMenu, at", tostring(link.stage)) - -- a mods-enabled build lands on the vanilla-restart prompt instead - U.wait(60) - U.shot(game, DIR .. "/online_host_blocked.png") - return - end - U.tap(game, "a"); U.wait(10) -- HOST ONLINE - - -- wait for the relay to hand back a room code - local code - for _ = 1, 900 do - if link.net and link.net.code then code = link.net.code break end - U.wait(1) - end - if not code then - log("FAIL no room code from the relay:", tostring(link.net and link.net.error)) - U.shot(game, DIR .. "/online_host_nocode.png") - return - end - log("hosting code", code) - U.shot(game, DIR .. "/online_host_1_code.png") - local f = io.open(CODE_FILE, "w") - if f then f:write(code) f:close() end - - -- wait for the joiner - for _ = 1, 1800 do - if link.stage == "modeSelect" then break end - U.wait(1) - end - if link.stage ~= "modeSelect" then - log("FAIL nobody joined (stage " .. tostring(link.stage) .. ")") - return - end - log("paired with", tostring(link.peerName)) - U.shot(game, DIR .. "/online_host_2_paired.png") - - U.tap(game, "down"); U.wait(3) -- TRADE / BATTLE -> BATTLE - U.tap(game, "a"); U.wait(10) - -- host owns the level rule; ANY is the default row - for _ = 1, 600 do - if link.stage == "battleOptions" then break end - U.wait(1) - end - if link.stage == "battleOptions" then - U.shot(game, DIR .. "/online_host_3_options.png") - U.tap(game, "a"); U.wait(5) - end - - -- the battle: mash A, exactly like a player who just wants it over with - local battle - for _ = 1, 1800 do - local top = game.stack:top() - if top and top.kind == "link" then battle = top break end - U.wait(1) - end - if not battle then - log("FAIL battle never started (stage " .. tostring(link.stage) .. ")") - U.shot(game, DIR .. "/online_host_nobattle.png") - return - end - log("battle started vs", tostring(battle.opponentName)) - U.shot(game, DIR .. "/online_host_4_battle.png") - - local shots = 0 - for i = 1, 200000 do - if battle.result then break end - U.tap(game, "a") - if i % 900 == 0 and shots < 3 then - shots = shots + 1 - U.shot(game, DIR .. ("/online_host_5_turn%d.png"):format(shots)) - end - end - U.wait(60) - U.shot(game, DIR .. "/online_host_6_result.png") - log("result", tostring(battle.result), "turns", tostring(battle.turnCount)) - log("desyncs", #desyncs) - log("DONE") -end diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua deleted file mode 100644 index 31d3a593..00000000 --- a/tests/drivers/online_match_join.lua +++ /dev/null @@ -1,133 +0,0 @@ --- Driver: play a real online match, joining side. --- See tests/drivers/online_match_host.lua for how to run the pair. - -return function(game) - local U = dofile("tests/drivers/util.lua") - local Pokemon = require("src.pokemon.Pokemon") - local LinkState = require("src.link.LinkState") - local CodeEntry = require("src.link.CodeEntry") - local Runtime = require("src.mods.Runtime") - local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" - local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt" - local TAG = "ONLINE_MATCH_JOIN:" - - local function log(...) U.log(TAG, ...) end - - local desyncs = {} - -- wrap emit rather than subscribing: with no mods loaded Runtime.events is - -- the null sink, and a vanilla online run is exactly the case under test - local realEmit = Runtime.emit - Runtime.emit = function(name, p) - if name == "link.desync" and p then - desyncs[#desyncs + 1] = p - log(("DESYNC turn=%s component=%s fatal=%s"):format( - tostring(p.turn), tostring(p.component), tostring(p.fatal))) - end - return realEmit(name, p) - end - - game.save.player.name = "GUEST" - game.save.party = { - Pokemon.new(game.data, "BLASTOISE", 50), - Pokemon.new(game.data, "GENGAR", 50), - Pokemon.new(game.data, "DRAGONITE", 50), - } - U.teleport(game, "PALLET_TOWN", 10, 8, "down") - U.wait(30) - - -- the host writes its room code out once the relay assigns one - local code - for _ = 1, 3600 do - local f = io.open(CODE_FILE, "r") - if f then - local text = (f:read("*a") or ""):gsub("%s+", "") - f:close() - if #text == CodeEntry.LENGTH then code = text break end - end - U.wait(1) - end - if not code then - log("FAIL host never published a code") - return - end - log("joining code", code) - - local link = LinkState.new(game) - game.stack:push(link) - U.wait(10) - -- RFC 0007: set all three per-category speeds high, since the link lock - -- has to win over every one of them, not just whichever is active. - game.save.options.speedOverworld = 20 - game.save.options.speedBattle = 20 - game.save.options.speedMenu = 20 - U.wait(2) - log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed()) - - log("before nav: stage", tostring(link.stage), "index", tostring(link.index), - "top", tostring(game.stack:top() == link), "mods", #require("src.link.Handshake").mods(game)) - U.tap(game, "down"); U.wait(3) - log("after down: stage", tostring(link.stage), "index", tostring(link.index)) - U.tap(game, "a"); U.wait(5) -- ONLINE MATCH - log("after a: stage", tostring(link.stage), "index", tostring(link.index)) - if link.stage ~= "onlineMenu" then - log("FAIL expected onlineMenu, at", tostring(link.stage)) - U.wait(60) - U.shot(game, DIR .. "/online_join_blocked.png") - return - end - U.tap(game, "down"); U.wait(3) - U.tap(game, "a"); U.wait(5) -- JOIN ONLINE -> code entry - if link.stage ~= "codeEntry" then - log("FAIL expected codeEntry, at", tostring(link.stage)) - return - end - -- set the six slots straight rather than scrubbing each one with UP - -- presses; the scrub interaction has its own coverage (online_play_test) - for i = 1, CodeEntry.LENGTH do - local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true) - link.codeEntry.chars[i] = idx or 1 - end - U.wait(2) - U.shot(game, DIR .. "/online_join_1_code.png") - U.tap(game, "a"); U.wait(10) - - for _ = 1, 1800 do - if link.stage == "waitMode" or link.stage == "battleWait" then break end - if link.net and link.net.error then - log("FAIL join error:", tostring(link.net.error)) - return - end - U.wait(1) - end - log("connected, stage", tostring(link.stage)) - U.shot(game, DIR .. "/online_join_2_connected.png") - - local battle - for _ = 1, 1800 do - local top = game.stack:top() - if top and top.kind == "link" then battle = top break end - U.wait(1) - end - if not battle then - log("FAIL battle never started (stage " .. tostring(link.stage) .. ")") - U.shot(game, DIR .. "/online_join_nobattle.png") - return - end - log("battle started vs", tostring(battle.opponentName)) - U.shot(game, DIR .. "/online_join_3_battle.png") - - local shots = 0 - for i = 1, 200000 do - if battle.result then break end - U.tap(game, "a") - if i % 900 == 0 and shots < 3 then - shots = shots + 1 - U.shot(game, DIR .. ("/online_join_4_turn%d.png"):format(shots)) - end - end - U.wait(60) - U.shot(game, DIR .. "/online_join_5_result.png") - log("result", tostring(battle.result), "turns", tostring(battle.turnCount)) - log("desyncs", #desyncs) - log("DONE") -end diff --git a/tests/drivers/online_play_test.lua b/tests/drivers/online_play_test.lua deleted file mode 100644 index b2c163b4..00000000 --- a/tests/drivers/online_play_test.lua +++ /dev/null @@ -1,53 +0,0 @@ --- 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/online_relay_smoke.lua b/tests/drivers/online_relay_smoke.lua new file mode 100644 index 00000000..7ab13149 --- /dev/null +++ b/tests/drivers/online_relay_smoke.lua @@ -0,0 +1,309 @@ +-- Drives two src/online/Client instances against a real ../pokeserver over +-- TCP: room create, join, ready, a full Gen 1 LinkBattle over roomSession(), +-- room_report from both sides, and the relay's room_result. Runs under love +-- because plain luajit on macOS has no luasocket. +-- +-- POKEPORT_IDENTITY=online_smoke POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/online_relay_smoke.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local PORT = tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or 17778 + local SERVER = os.getenv("POKESERVER_DIR") or "../pokeserver" + local failures = 0 + + local function check(cond, msg) + if cond then + U.log("ok ", msg) + else + failures = failures + 1 + U.log("FAIL", msg) + end + end + + local function finish() + U.log(failures == 0 and "online relay smoke passed" + or (failures .. " online relay smoke check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local probe = io.open(SERVER .. "/server.js", "r") + if not probe then + U.log("FAIL", SERVER .. "/server.js is not checked out") + love.event.quit(1) + return + end + probe:close() + + local pidFile = "/tmp/pokeserver_smoke_" .. PORT .. ".pid" + os.execute(("(cd %q && PORT=%d HTTP_PORT=%d node server.js " .. + ">/tmp/pokeserver_smoke.log 2>&1 & echo $! > %q)") + :format(SERVER, PORT, PORT + 1, pidFile)) + + local function stopServer() + local handle = io.open(pidFile, "r") + if handle then + local pid = handle:read("*l") + handle:close() + if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end + end + os.remove(pidFile) + end + + local Net = require("src.link.Net") + local function reachable() + local probe = Net.new() + if not probe:connectTCP("127.0.0.1:" .. PORT) then return false end + for _ = 1, 30 do + probe:update() + if probe.closed then + pcall(function() probe:close() end) + return false + end + if not probe.connecting then + pcall(function() probe:close() end) + return true + end + end + pcall(function() probe:close() end) + return false + end + + local up = false + for _ = 1, 300 do + up = reachable() + if up then break end + U.wait(2) + end + if not up then + check(false, "the spawned pokeserver answered on 127.0.0.1:" .. PORT) + stopServer() + return finish() + end + U.log("pokeserver up on", PORT) + + local function freshClient() + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + C.configure({ relayAddress = "127.0.0.1:" .. PORT }) + return C + end + + local Host = freshClient() + local Guest = freshClient() + + local function pump(n) + for _ = 1, n or 1 do + Host.update(1 / 60) + Guest.update(1 / 60) + coroutine.yield() + end + end + + local function waitFor(fn, frames, what) + for _ = 1, frames or 600 do + if fn() then return true end + pump(1) + end + check(false, "timed out waiting for " .. tostring(what)) + return false + end + + local Data = game.data + local Version = require("src.core.Version") + local PROFILE = { + engine = 1, version = "red", engineVersion = Version.engine, + apiVersion = Version.modApi, fingerprint = "relay-smoke", + rulesetId = "gen1_faithful", kind = "vanilla", rule = { partySize = 1 }, + } + + Host.connect({ name = "RED", profiles = { PROFILE } }) + Guest.connect({ name = "BLUE", profiles = { PROFILE } }) + if not waitFor(function() + return Host.state() == "online" and Guest.state() == "online" + end, 600, "both clients to come online") then + U.log("host", Host.state(), tostring(Host.error()), + "guest", Guest.state(), tostring(Guest.error())) + stopServer() + return finish() + end + check(Host.you() ~= nil and Guest.you() ~= nil, + "the real relay welcomes both v2 clients") + + local room = Host.createRoom({ intent = "battle", profile = PROFILE, + playing = true, maxSpectators = 4 }) + waitFor(function() return room.done end, 600, "room_create to answer") + check(room.code ~= nil, "the real relay creates a room: " .. tostring(room.error)) + if not room.code then + stopServer() + return finish() + end + + local joined = Guest.joinRoom(room.code, "player", PROFILE) + waitFor(function() return joined.done end, 600, "room_join to answer") + check(joined.error == nil, "the guest joins the room: " .. tostring(joined.error)) + waitFor(function() + return Host.room() and #Host.room().players == 2 + end, 600, "the host to see two players") + + package.loaded["src.online.Client"] = nil + local Watcher = require("src.online.Client") + Watcher.reset() + Watcher.configure({ relayAddress = "127.0.0.1:" .. PORT }) + Watcher.connect({ name = "GREEN", profiles = { PROFILE } }) + local function pump3(n) + for _ = 1, n or 1 do + Host.update(1 / 60) + Guest.update(1 / 60) + Watcher.update(1 / 60) + coroutine.yield() + end + end + for _ = 1, 600 do + if Watcher.state() == "online" then break end + pump3(1) + end + local badProfile = {} + for k, v in pairs(PROFILE) do badProfile[k] = v end + badProfile.fingerprint = "deadbeef" + local refused = Watcher.joinRoom(room.code, "spectator", badProfile) + for _ = 1, 600 do + if refused.done then break end + pump3(1) + end + check(refused.reason == "profile_mismatch", + "a spectator whose profile differs is refused: " .. + tostring(refused.reason)) + local watching = Watcher.joinRoom(room.code, "spectator", PROFILE) + for _ = 1, 600 do + if watching.done then break end + pump3(1) + end + check(watching.error == nil, "a matching spectator is admitted: " .. + tostring(watching.error)) + Watcher.disconnect() + pump(5) + + local hostStart, guestStart, hostEnd, guestEnd + Host.on("match_start", function(p) hostStart = p end) + Guest.on("match_start", function(p) guestStart = p end) + Host.on("match_end", function(p) hostEnd = p end) + Guest.on("match_end", function(p) guestEnd = p end) + + local Input = require("src.core.Input") + local LinkBattle = require("src.link.LinkBattle") + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local SaveData = require("src.core.SaveData") + + local function headless(name, species) + local save = SaveData.newGame() + save.player.name = name + save.party = { Pokemon.new(Data, species, 50) } + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } + end + + local gameH = headless("RED", "CHARIZARD") + local gameG = headless("BLUE", "BLASTOISE") + local packedH = Protocol.packParty(gameH.save.party) + local packedG = Protocol.packParty(gameG.save.party) + + Host.ready(packedH, "dh") + Guest.ready(packedG, "dg") + waitFor(function() return hostStart ~= nil and guestStart ~= nil end, 600, + "match_start on both seats") + check(hostStart ~= nil and guestStart ~= nil, "the relay starts the match") + if not (hostStart and guestStart) then + stopServer() + return finish() + end + check(hostStart.role == "host" and guestStart.role == "guest", + "the relay assigns host and guest by join order") + check(type(hostStart.match) == "string" and hostStart.match == guestStart.match, + "both seats share one match token: " .. tostring(hostStart.match)) + check(hostStart.seed == guestStart.seed and hostStart.seed ~= nil, + "both seats share one seed: " .. tostring(hostStart.seed)) + check(hostStart.theirParty and hostStart.theirParty[1] and + hostStart.theirParty[1].species == "BLASTOISE", + "match_start carries the peer's party") + + local battleH = LinkBattle.newHost(gameH, Host.roomSession(), { + myParty = packedH, theirParty = hostStart.theirParty, + theirName = hostStart.peerName, seed = hostStart.seed, + ruleset = hostStart.ruleset, keepNetOpen = true, + }) + local battleG = LinkBattle.newGuest(gameG, Guest.roomSession(), { + myParty = packedG, theirParty = guestStart.theirParty, + theirName = guestStart.peerName, seed = guestStart.seed, + ruleset = guestStart.ruleset, keepNetOpen = true, + }) + check(battleH ~= nil and battleG ~= nil, + "LinkBattle builds over a real relay room session") + if not (battleH and battleG) then + stopServer() + return finish() + end + + local resH, resG + battleH.onFinish = function(r) resH = r end + battleG.onFinish = function(r) resG = r end + gameH.stack:push(battleH) + gameG.stack:push(battleG) + + for _ = 1, 20000 do + if resH and resG then break end + Input.pressed = { a = true } + gameH.stack:update(1 / 60) + gameG.stack:update(1 / 60) + pump(1) + end + check(resH ~= nil and resG ~= nil, + ("the battle finishes over the real relay (%s / %s)") + :format(tostring(resH), tostring(resG))) + check(battleH.player.mon.hp == battleG.enemy.mon.hp and + battleH.enemy.mon.hp == battleG.player.mon.hp, + "both simulations agree on HP across the relay") + + check(Host.report(resH) ~= false, "the host reports its result") + check(Guest.report(resG) ~= false, "the guest reports its result") + waitFor(function() return hostEnd ~= nil and guestEnd ~= nil end, 900, + "room_result on both seats") + check(hostEnd ~= nil, "the relay answers room_report with room_result") + if hostEnd then + check(hostEnd.match == hostStart.match, "room_result names the match") + check(hostEnd.how == "agreed" or hostEnd.how == "reported", + "the relay resolved the reports: " .. tostring(hostEnd.how)) + check(hostEnd.winnerId ~= nil, "room_result names a winner id") + check(hostEnd.youWon == (resH == "win"), + "youWon matches what the host's own simulation said") + check(guestEnd and guestEnd.winnerId == hostEnd.winnerId, + "both seats are told the same winner") + end + waitFor(function() + return Host.room() and Host.room().stage == "waiting" + end, 600, "the room to return to waiting") + check(Host.room() and Host.room().stage == "waiting", + "the room is ready for a rematch") + + Host.leaveRoom() + Guest.leaveRoom() + pump(10) + Host.disconnect() + Guest.disconnect() + pump(5) + stopServer() + return finish() +end diff --git a/tests/drivers/online_shot.lua b/tests/drivers/online_shot.lua new file mode 100644 index 00000000..185c9bef --- /dev/null +++ b/tests/drivers/online_shot.lua @@ -0,0 +1,551 @@ +-- Scripted ONLINE tab states for the launcher screenshot harness. Armed once +-- by OnlinePanel from POKEPORT_ONLINE_SHOT. +-- +-- POKEPORT_LAUNCHER_TAB=online POKEPORT_ONLINE_SHOT=play \ +-- POKEPORT_WIN=1280x800 POKEPORT_LAUNCHER_SHOT=/tmp/play.png love . +-- +-- States: home, play, play-full, play-hosting, +-- host-game, host-save, host-team, host-rules, host-visibility, +-- host-summary, pc-picker, +-- tour-game, tour-save, tour-playing, tour-team, tour-rules, +-- tour-shotclock, tour-spectators, tour-summary, +-- join-summary, join-team, trade-role, trade-summary, +-- room, room-result, room-trade, watch, tournament, +-- tournament-lobby, trade, trade-local, trade-pc, trade-remote, +-- trade-cross, trade-pc-picked, trade-pc-commit (writes both saves), +-- trade-modal, trade-modal-done, trade-commit (writes both saves). + +local function Client() + return require("src.online.Client") +end + +local PROFILE = { + engine = 1, version = "red", engineVersion = "1.2.3", apiVersion = 4, + fingerprint = "shot", rulesetId = "gen1_faithful", kind = "vanilla", + rule = { partySize = 3 }, +} + +local NAMES = { "RED", "BLUE", "GREEN", "LEAF", "ETHAN", "LYRA", "SILVER", + "KRIS", "GOLD", "MAY", "BRENDAN", "WALLY" } + +local function fakeLobby(count, myProfile) + local rows = {} + for i = 1, count do + local profile = {} + for key, value in pairs(myProfile or PROFILE) do profile[key] = value end + if i % 5 == 0 then profile.fingerprint = "other" end + if i % 7 == 0 then + profile.kind = "cart" + profile.cart = { id = "kanto", version = "1.0.0", hash = "abc" } + end + rows[i] = { + id = "e" .. i, + open = true, + spectators = i % 4, + code = ("L%05d"):format(i), + name = NAMES[((i - 1) % #NAMES) + 1] .. "#" .. (100 + i), + verified = i % 3 == 0, + intent = (i % 11 == 0) and "tournament" or "battle", + note = (i % 4 == 0) and "first to three" or nil, + profile = profile, + stage = (i % 6 == 0) and "battling" or "waiting", + } + end + return rows +end + +local function goOnline(OnlinePanel, imp, count) + local client = Client() + client.state = function() return "online" end + client.you = function() + return { id = "me", name = "RED#417", verified = true } + end + client.serverTime = function() return 1000 end + local cached, cachedKey + local function lobby() + local mine = OnlinePanel.myProfile(imp) + local key = tostring(mine and mine.fingerprint) + if key ~= cachedKey then + cachedKey = key + cached = fakeLobby(count, mine or PROFILE) + end + return cached + end + client.lobby = lobby + client.openRooms = function() + local out = {} + for _, entry in ipairs(lobby()) do + if entry.open and entry.intent ~= "tournament" then + out[#out + 1] = entry + end + end + return out + end + client.watchable = function() + local out = {} + for _, entry in ipairs(lobby()) do + if entry.stage == "battling" or entry.intent == "tournament" then + out[#out + 1] = entry + end + end + return out + end + client.counts = function() + return { players = count + 3, openRooms = #client.openRooms() } + end + return client +end + +local ROOM = { + code = "AB2CD3", host = "me", stage = "waiting", intent = "battle", + profile = PROFILE, + players = { + { id = "me", name = "RED#417", verified = true, ready = false }, + { id = "p2", name = "BLUE#221", ready = true }, + }, + spectators = { { id = "s1", name = "GREEN#009" } }, + deadlines = { ready = 61000 }, +} + +local TOUR = { + code = "TRN234", creator = "me", stage = "running", round = 2, shotClock = 6, + players = { + { id = "me", name = "RED#417", verified = true, online = true }, + { id = "p2", name = "BLUE#221", online = true }, + { id = "p3", name = "GREEN#009", online = false, eliminated = true }, + { id = "p4", name = "LEAF#712", online = true }, + }, + spectators = { { id = "s1", name = "WATCH" } }, + profile = PROFILE, rule = { partySize = 3 }, + bracket = { + { round = 1, matches = { + { match = "m1", a = "me", b = "p3", winner = "me", how = "agreed", + state = "done" }, + { match = "m2", a = "p2", b = "p4", winner = "p2", how = "reported", + state = "done" } } }, + { round = 2, matches = { + { match = "m3", a = "me", b = "p2", state = "live" } } }, + }, + live = "m3", + deadlines = { shot = 7000 }, +} + +-- ------------------------------------------------------------------ trade + +local function peerParty(entry) + if not entry then return {} end + local TeamPick = require("src.online.TeamPick") + local ok, slot = pcall(TeamPick.readSlot, entry.version, entry.slotId, + entry.cartId) + if not ok or type(slot) ~= "table" then return {} end + return slot.party or {} +end + +local function scriptedLink(party) + local Protocol = require("src.link.Protocol") + local inbox = {} + return { + send = function(_, msg) + if type(msg) ~= "table" then return end + if msg.type == "party" then + inbox[#inbox + 1] = { type = "party", + mons = Protocol.packParty(party) } + elseif msg.type == "pick" then + inbox[#inbox + 1] = { type = "pick", index = 1 } + elseif msg.type == "confirm" then + inbox[#inbox + 1] = { type = "confirm", ok = msg.ok } + end + end, + poll = function() + local out = inbox + inbox = {} + return out + end, + close = function() end, + } +end + +local function remoteShot(OnlinePanel, imp, rows) + local st = OnlinePanel.state(imp) + local tr = OnlinePanel.tradeState(imp) + local entry = rows[1] + if not entry then return end + tr.mode, tr.chosen = "remote", true + st.version, st.slotId, st.cartId = entry.version, entry.slotId, entry.cartId + local Trade = require("src.online.Trade") + local handle = Trade.openSlot(entry.version, entry.slotId, entry.cartId) + if not handle then return end + local remote = Trade.remote(handle, scriptedLink(peerParty(rows[2])), + { peerName = "BLUE" }) + if not remote then return end + tr.remote, tr.peerName = remote, "BLUE" + remote:start() + remote:update() + remote:pick(1) + remote:update() +end + +local function tradeablePair(OnlinePanel, imp, pickA, pickB) + local tr = OnlinePanel.tradeState(imp) + local a = OnlinePanel.tradeSideView(imp, "a") + local b = OnlinePanel.tradeSideView(imp, "b") + local partyA = (a and a.handle and a.handle.party) or {} + local partyB = (b and b.handle and b.handle.party) or {} + for i = 1, #partyA do + for j = 1, #partyB do + tr.picks.a = pickA or i + tr.picks.b = pickB or j + tr.plan, tr.lines, tr.convertLines = nil, nil, nil + if OnlinePanel.tradeModalPreview(imp) then return true end + if pickA and pickB then return false end + end + end + return false +end + +local function envPick(name) + local raw = os.getenv(name) + if not raw or raw == "" then return nil end + local box, index = raw:match("^box:(%d+):(%d+)$") + if box then + return { where = "box", box = tonumber(box), index = tonumber(index) } + end + return tonumber(raw) +end + +local function localShot(OnlinePanel, imp, want, rows) + local tr = OnlinePanel.tradeState(imp) + tr.mode, tr.chosen = "local", true + local second = rows[2] + local wantCross = want == "trade-cross" or want:sub(1, 11) == "trade-modal" + or want == "trade-commit" + if wantCross and rows[1] then + for _, row in ipairs(rows) do + if row.generation ~= rows[1].generation then + second = row + break + end + end + end + OnlinePanel.tradeSetSide(imp, "a", rows[1]) + OnlinePanel.tradeSetSide(imp, "b", second) + local pickA = envPick("POKEPORT_ONLINE_PICK_A") + local pickB = envPick("POKEPORT_ONLINE_PICK_B") + OnlinePanel.tradePick(imp, "a", pickA or 1) + OnlinePanel.tradePick(imp, "b", pickB or 1) + if want ~= "trade-modal" and want ~= "trade-modal-done" + and want ~= "trade-commit" then + OnlinePanel.tradePreview(imp) + return + end + if not tradeablePair(OnlinePanel, imp, pickA, pickB) then + print("[shot] no pair this cache can trade: " .. + tostring(OnlinePanel.tradeState(imp).status)) + return + end + if want == "trade-commit" then + OnlinePanel.tradeModalConfirm(imp) + return + end + if want ~= "trade-modal-done" then return end + local mo = OnlinePanel.tradeModal(imp) + if not mo then return end + mo.view, mo.ok, mo.message = "result", true, "Traded." + mo.resultLines = OnlinePanel.tradeResultLines( + OnlinePanel.tradeState(imp).plan, mo.labels) +end + +-- ----------------------------------------------------------------- states + +local function firstSlot(OnlinePanel, imp) + local st = OnlinePanel.state(imp) + local version = OnlinePanel.selectedVersion(imp) + for _, row in ipairs(OnlinePanel.slotsIn(imp, version, nil)) do + if row.exists then + st.slotId = row.id + return row.id + end + end + return nil +end + +local STATES = {} + +local function ready(OnlinePanel, imp, picks) + local st = OnlinePanel.state(imp) + firstSlot(OnlinePanel, imp) + st.setupDone = true + st.team = {} + local pick = OnlinePanel.readTeamSlot(imp) + if pick then + require("src.online.OnlineSprites").prime( + OnlinePanel.selectedVersion(imp), pick.party) + for index = 1, math.min(picks or 2, #pick.party) do + OnlinePanel.toggleTeam(st.team, index) + end + st.focusMon = OnlinePanel.refKey(st.team[1]) + end + OnlinePanel.invalidate(imp) + return st +end + +function STATES.home(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 6) + OnlinePanel.home(imp) +end + +function STATES.play(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 6) + ready(OnlinePanel, imp, 3) + OnlinePanel.state(imp).note = "first to three" + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") +end + +STATES["play-full"] = function(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 50) + ready(OnlinePanel, imp, 3) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") +end + +STATES["play-hosting"] = function(OnlinePanel, imp) + local client = goOnline(OnlinePanel, imp, 6) + client.room = function() return ROOM end + ready(OnlinePanel, imp, 3) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + OnlinePanel.refresh(imp) +end + +local function wizardAt(OnlinePanel, imp, kind, step, picks) + goOnline(OnlinePanel, imp, 4) + ready(OnlinePanel, imp, picks or 2) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + OnlinePanel.startWizard(imp, kind) + OnlinePanel.wizardTo(imp, step) + OnlinePanel.refresh(imp) +end + +local HOST_STEPS = { "game", "save", "team", "rules", "visibility", "summary" } +for _, step in ipairs(HOST_STEPS) do + STATES["host-" .. step] = function(O, imp) + wizardAt(O, imp, "hostBattle", step) + end +end + +local TOUR_STEPS = { "game", "save", "playing", "team", "rules", "shotclock", + "spectators", "summary" } +for _, step in ipairs(TOUR_STEPS) do + STATES["tour-" .. step] = function(O, imp) + wizardAt(O, imp, "hostTournament", step) + end +end + +STATES["join-summary"] = function(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 4) + ready(OnlinePanel, imp, 3) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + OnlinePanel.startJoin(imp, "AB2CD3", { partySize = 3 }) + OnlinePanel.refresh(imp) +end + +STATES["join-team"] = function(OnlinePanel, imp) + STATES["join-summary"](OnlinePanel, imp) + OnlinePanel.wizardTo(imp, "team") + OnlinePanel.refresh(imp) +end + +STATES["trade-role"] = function(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 4) + ready(OnlinePanel, imp, 1) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + OnlinePanel.tradeState(imp).chosen = true + OnlinePanel.tradeMode(imp, "remote") + OnlinePanel.startWizard(imp, "tradeRemote") + OnlinePanel.wizardTo(imp, "role") + OnlinePanel.refresh(imp) +end + +STATES["trade-summary"] = function(OnlinePanel, imp) + STATES["trade-role"](OnlinePanel, imp) + OnlinePanel.wizardTo(imp, "summary") + OnlinePanel.refresh(imp) +end + +STATES["pc-picker"] = function(OnlinePanel, imp) + wizardAt(OnlinePanel, imp, "hostBattle", "team") + OnlinePanel.pcOpen(imp) + OnlinePanel.update(imp, 1 / 60) + OnlinePanel.refresh(imp) +end + +function STATES.room(OnlinePanel, imp) + local client = goOnline(OnlinePanel, imp, 4) + client.room = function() return ROOM end + ready(OnlinePanel, imp, 3) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + OnlinePanel.go(imp, "room") +end + +STATES["room-result"] = function(OnlinePanel, imp) + STATES.room(OnlinePanel, imp) + OnlinePanel.lastResult = "win" +end + +STATES["room-trade"] = function(OnlinePanel, imp) + local client = goOnline(OnlinePanel, imp, 4) + client.room = function() + return { code = "TR9ZQ2", host = "me", stage = "waiting", + intent = "trade", profile = PROFILE, + players = { { id = "me", name = "RED#417", verified = true } }, + spectators = {} } + end + ready(OnlinePanel, imp, 1) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + OnlinePanel.go(imp, "room") +end + +function STATES.watch(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 14) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "watch") +end + +function STATES.tournament(OnlinePanel, imp) + local client = goOnline(OnlinePanel, imp, 4) + client.tournament = function() return TOUR end + OnlinePanel.home(imp) + OnlinePanel.go(imp, "tournament") +end + +STATES["tournament-lobby"] = function(OnlinePanel, imp) + goOnline(OnlinePanel, imp, 4) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "tournament") +end + +STATES["trade-pc"] = function(OnlinePanel, imp) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + local tr = OnlinePanel.tradeState(imp) + tr.mode, tr.chosen = "local", true + local rows = OnlinePanel.tradeSlots(imp) + OnlinePanel.tradeSetSide(imp, "a", rows[1]) + OnlinePanel.tradeSetSide(imp, "b", rows[2]) + local view = OnlinePanel.tradeSideView(imp, "a") + local handle = view and view.handle or nil + if not handle then return end + if #OnlinePanel.tradeBoxRows(imp, "a") == 0 and handle.party[1] then + handle.save.boxes = type(handle.save.boxes) == "table" + and handle.save.boxes or {} + handle.save.boxes[2] = { handle.party[1] } + handle.boxes = handle.save.boxes + end + for _, side in ipairs({ "a", "b" }) do + if OnlinePanel.pcOpen(imp, { side = side }) then + OnlinePanel.refresh(imp) + local row = OnlinePanel.pcRows(imp)[1] + if row then OnlinePanel.pcPick(imp, row) else OnlinePanel.pcClose(imp) end + end + end + OnlinePanel.pcOpen(imp, { side = "a" }) + OnlinePanel.update(imp, 1 / 60) + OnlinePanel.refresh(imp) +end + +STATES["trade-pc-picked"] = function(OnlinePanel, imp) + STATES["trade-pc"](OnlinePanel, imp) + OnlinePanel.pcClose(imp) + OnlinePanel.refresh(imp) +end + +STATES["trade-pc-commit"] = function(OnlinePanel, imp) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + local tr = OnlinePanel.tradeState(imp) + tr.mode, tr.chosen = "local", true + local rows = OnlinePanel.tradeSlots(imp) + local a, b = rows[1], nil + for _, row in ipairs(rows) do + if a and row.generation == a.generation + and not OnlinePanel.sameSlot(row, a) then + b = row + break + end + end + if not (a and b) then + print("[shot] no same-generation pair on this machine") + return + end + OnlinePanel.tradeSetSide(imp, "a", a) + OnlinePanel.tradeSetSide(imp, "b", b) + if not OnlinePanel.pcOpen(imp, { side = "a" }) then + print("[shot] the PC popup would not open") + return + end + OnlinePanel.refresh(imp) + local want = envPick("POKEPORT_ONLINE_PICK_A") + local picked + for _, row in ipairs(OnlinePanel.pcRows(imp)) do + if not want or (row.ref.box == want.box and row.ref.index == want.index) then + picked = row + break + end + end + if not picked then + print("[shot] nothing in that save's PC") + OnlinePanel.pcClose(imp) + return + end + print("[shot] from the PC: " .. tostring(picked.label) .. " (" .. + tostring(picked.source) .. ")") + OnlinePanel.pcPick(imp, picked) + OnlinePanel.tradePick(imp, "b", envPick("POKEPORT_ONLINE_PICK_B") or 1) + OnlinePanel.refresh(imp) + if not OnlinePanel.tradeModalPreview(imp) then + print("[shot] preview refused: " .. tostring(tr.status)) + return + end + OnlinePanel.tradeModalConfirm(imp) + print("[shot] trade-pc-commit: " .. tostring(tr.status)) +end + +function STATES.trade(OnlinePanel, imp) + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + OnlinePanel.tradeState(imp).chosen = false +end + +local function armDemo(module, OnlinePanel, imp) + local ok, err = xpcall(function() + return require(module)(OnlinePanel, imp) + end, debug.traceback) + if not ok then print("[demo] ARM ERROR " .. tostring(err)) end +end + +STATES["demo-host"] = function(OnlinePanel, imp) + return armDemo("tests.drivers.online_demo_host", OnlinePanel, imp) +end + +STATES["demo-guest"] = function(OnlinePanel, imp) + return armDemo("tests.drivers.online_demo_guest", OnlinePanel, imp) +end + +return function(OnlinePanel, imp, want) + if os.getenv("POKEPORT_REDUCE_MOTION") ~= "0" then + require("src.ui.kit.Transition").reduceMotion = true + end + local run = STATES[want] + if run then return run(OnlinePanel, imp) end + OnlinePanel.home(imp) + OnlinePanel.go(imp, "trade") + local rows = OnlinePanel.tradeSlots(imp) + if want == "trade-remote" then return remoteShot(OnlinePanel, imp, rows) end + return localShot(OnlinePanel, imp, want, rows) +end diff --git a/tests/drivers/online_tour_smoke.lua b/tests/drivers/online_tour_smoke.lua new file mode 100644 index 00000000..bd483f96 --- /dev/null +++ b/tests/drivers/online_tour_smoke.lua @@ -0,0 +1,746 @@ +-- Drives five src/online/Client instances through a v2 tournament against a +-- real ../pokeserver over TCP: tour_create with a party, joins, tour_start, +-- tour_bye, tour_match -> a child room -> a full Gen 1 LinkBattle over +-- roomSession(), reports, room_result, bracket advance, tour_over; an outside +-- spectator watching through match_start_spectate and side-tagged room_msg; a +-- mid-battle socket kill with resume and replay; a report("error") forfeit; +-- the server-side shot clock; and tour_closed on a kick. Runs under love +-- because plain luajit on macOS has no luasocket. +-- +-- POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/online_tour_smoke.lua love . +-- +-- headless, with a wall-clock cap (script -q is useless on macOS): +-- perl -e 'alarm 900; exec @ARGV' \ +-- python3 -c 'import pty,sys; pty.spawn(sys.argv[1:])' \ +-- /Applications/love.app/Contents/MacOS/love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local PORT = tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or 17778 + local SERVER = os.getenv("POKESERVER_DIR") or "../pokeserver" + local SHOT_MS = 3000 + local failures = 0 + + local function check(cond, msg) + if cond then + U.log("ok ", msg) + else + failures = failures + 1 + U.log("FAIL", msg) + end + end + + local function finish() + U.log(failures == 0 and "online tournament smoke passed" + or (failures .. " online tournament smoke check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local probe = io.open(SERVER .. "/server.js", "r") + if not probe then + U.log("FAIL", SERVER .. "/server.js is not checked out") + love.event.quit(1) + return + end + probe:close() + + local pidFile = "/tmp/pokeserver_tour_" .. PORT .. ".pid" + os.execute(("(cd %q && PORT=%d HTTP_PORT=%d POKESERVER_TEST_SHOT_MS=%d " .. + "node server.js >/tmp/pokeserver_tour.log 2>&1 & echo $! > %q)") + :format(SERVER, PORT, PORT + 1, SHOT_MS, pidFile)) + + local function stopServer() + local handle = io.open(pidFile, "r") + if handle then + local pid = handle:read("*l") + handle:close() + if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end + end + os.remove(pidFile) + end + + local Net = require("src.link.Net") + local function reachable() + local sock = Net.new() + if not sock:connectTCP("127.0.0.1:" .. PORT) then return false end + for _ = 1, 30 do + sock:update() + if sock.closed then + pcall(function() sock:close() end) + return false + end + if not sock.connecting then + pcall(function() sock:close() end) + return true + end + end + pcall(function() sock:close() end) + return false + end + + local up = false + for _ = 1, 300 do + up = reachable() + if up then break end + U.wait(2) + end + if not up then + check(false, "the spawned pokeserver answered on 127.0.0.1:" .. PORT) + stopServer() + return finish() + end + U.log("pokeserver up on", PORT, "shot clock", SHOT_MS .. "ms") + + local Data = game.data + local Input = require("src.core.Input") + local LinkBattle = require("src.link.LinkBattle") + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + local SaveData = require("src.core.SaveData") + local Version = require("src.core.Version") + + local PROFILE = { + engine = 1, version = "red", engineVersion = Version.engine, + apiVersion = Version.modApi, fingerprint = "tour-smoke", + rulesetId = "gen1_faithful", kind = "vanilla", rule = { partySize = 1 }, + } + local RULE = { partySize = 1 } + + local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, item in pairs(v) do out[k] = copy(item) end + return out + end + + local EVENTS = { "match_start", "match_end", "tour_match", "tour_spectate", + "tour_bye", "tour_over", "error" } + + local bots = {} + + local function newBot(name, species) + package.loaded["src.online.Client"] = nil + local C = require("src.online.Client") + C.reset() + local bot = { name = name, C = C, ev = {}, seen = {} } + C.configure({ + relayAddress = "127.0.0.1:" .. PORT, + connect = function(address) + local net = Net.new() + if not net:connectTCP(address or ("127.0.0.1:" .. PORT)) then + return nil, net.error or "no relay" + end + net.mode = "onlineLobby" + net.v2 = true + bot.net = net + return net + end, + }) + for _, event in ipairs(EVENTS) do + local key = event + C.on(key, function(payload) + bot.ev[key] = payload + bot.seen[key] = (bot.seen[key] or 0) + 1 + end) + end + C.on("tournament", function(t) + if t and t.stage == "finished" and not bot.champFromState then + bot.champFromState = t.champion + end + end) + if species then + local save = SaveData.newGame() + save.player.name = name + save.party = { Pokemon.new(Data, species, 50) } + bot.save = save + bot.party = Protocol.packParty(save.party) + end + bots[#bots + 1] = bot + return bot + end + + local function pump(n) + for _ = 1, n or 1 do + for _, bot in ipairs(bots) do bot.C.update(1 / 60) end + coroutine.yield() + end + end + + local function waitFor(fn, frames, what) + for _ = 1, frames or 600 do + if fn() then return true end + pump(1) + end + if what then check(false, "timed out waiting for " .. tostring(what)) end + return false + end + + local function connectAll(list) + for _, bot in ipairs(list) do + bot.C.connect({ name = bot.name, profiles = { PROFILE } }) + end + local ok = waitFor(function() + for _, bot in ipairs(list) do + if bot.C.state() ~= "online" then return false end + end + return true + end, 900, "every client to come online") + return ok + end + + local function headless(bot) + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = bot.save } + end + + -- ------------------------------------------------------- the main bracket + + local org = newBot("ORG") + local watch = newBot("WATCH") + local p1 = newBot("PONE", "CHARIZARD") + local p2 = newBot("PTWO", "BLASTOISE") + local p3 = newBot("PTHREE", "VENUSAUR") + local players = { p1, p2, p3 } + + if not connectAll(bots) then + stopServer() + return finish() + end + check(org.C.you() ~= nil, "the real relay welcomes every v2 client") + + local made = org.C.createTournament({ profile = PROFILE, rule = RULE, + playing = false, shotClock = 3, + maxSpectators = 8 }) + waitFor(function() return made.done end, 600, "tour_create to answer") + check(made.code ~= nil, + "the real relay creates a tournament: " .. tostring(made.error)) + if not made.code then + stopServer() + return finish() + end + local code = made.code + local created = org.C.tournament() + check(created and created.stage == "registering" and + created.creator == org.C.you().id and #created.players == 0 and + #created.spectators == 1, + "a playing:false creator is a spectator holding creator powers") + check(created and created.shotClock == 3 and created.rule and + created.rule.partySize == 1, + "tour_state carries the shot clock and the rule") + + for _, bot in ipairs(players) do + bot.join = bot.C.joinTournament(code, "player", bot.party, "d" .. bot.name) + end + watch.join = watch.C.joinTournament(code, "spectator") + waitFor(function() + local t = org.C.tournament() + return t and #t.players == 3 and #t.spectators == 2 + end, 900, "three players and two spectators to register") + local roster = org.C.tournament() + check(roster and #roster.players == 3, + "three players register with their teams up front") + check(roster and #roster.spectators == 2, + "the creator and the outside spectator both watch") + for _, bot in ipairs(players) do + check(bot.join.error == nil, + bot.name .. " joined: " .. tostring(bot.join.error)) + end + + local short = newBot("SHORT", "PIKACHU") + connectAll({ short }) + local twoMons = copy(short.party) + twoMons[2] = copy(short.party[1]) + local refused = short.C.joinTournament(code, "player", twoMons, "dx") + waitFor(function() return refused.done end, 600, "the bad party to be refused") + check(refused.reason == "party_ineligible", + "a party that breaks the rule is refused: " .. tostring(refused.reason)) + short.C.disconnect() + + org.C.startTournament() + waitFor(function() + local t = org.C.tournament() + return t and t.stage == "running" and #t.bracket >= 1 + end, 900, "the tournament to start") + local running = org.C.tournament() + check(running and running.stage == "running", + "the creator starts the tournament") + check(running and running.bracket[1] and #running.bracket[1].matches == 2, + "three players make a two-slot first round: " .. + tostring(running and running.bracket[1] and #running.bracket[1].matches)) + + waitFor(function() + for _, bot in ipairs(players) do + if bot.ev.tour_bye then return true end + end + return false + end, 900, "a walkover player to be told tour_bye") + local byeBots = 0 + for _, bot in ipairs(players) do + if bot.ev.tour_bye then byeBots = byeBots + 1 end + end + check(byeBots == 1, "exactly one player gets tour_bye: " .. byeBots) + + local played = {} + + local function liveSeats() + local seated = {} + for _, bot in ipairs(players) do + local tm = bot.ev.tour_match + local start = bot.ev.match_start + if tm and start and start.match == tm.match and not played[tm.match] then + seated[#seated + 1] = bot + end + end + return seated + end + + local function playLiveMatch(label) + waitFor(function() return #liveSeats() == 2 end, 1800, + "two players to be seated in " .. label) + local seats = liveSeats() + if #seats ~= 2 then return nil end + local a, b = seats[1], seats[2] + local tourA, tourB = a.ev.tour_match, b.ev.tour_match + local startA, startB = a.ev.match_start, b.ev.match_start + local token = tourA.match + played[token] = true + check(tourA.match == tourB.match, + label .. ": both players share the bracket token (" .. token .. ")") + check(tourA.code == tourB.code and startA.code == tourA.code, + label .. ": match_start is bound to the child room from tour_match") + check(startA.match == token, + label .. ": the child room reuses the bracket token as its match") + check(startA.seed ~= nil and startA.seed == startB.seed, + label .. ": both seats share one seed") + check(startA.theirParty ~= nil and startB.theirParty ~= nil, + label .. ": each seat is handed the other party without room_ready") + + local watchers = {} + for _, bot in ipairs(bots) do + if bot ~= a and bot ~= b and bot.C.state() == "online" then + watchers[#watchers + 1] = bot + end + end + waitFor(function() + for _, bot in ipairs(watchers) do + local spec, start = bot.ev.tour_spectate, bot.ev.match_start + if not (spec and spec.match == token and start and start.match == token) then + return false + end + end + return true + end, 900, label .. ": every other member to be told to spectate") + for _, bot in ipairs(watchers) do + local spec, start = bot.ev.tour_spectate, bot.ev.match_start + check(spec ~= nil and spec.match == token and start ~= nil and + start.match == token and start.role == "spectator", + label .. ": " .. bot.name .. " watches through match_start_spectate") + check(start ~= nil and start.code == tourA.code, + label .. ": " .. bot.name .. " is put in the child room") + bot.sides = {} + end + + local host, hostStart = a, startA + local guest, guestStart = b, startB + if startA.role ~= "host" then + host, hostStart, guest, guestStart = b, startB, a, startA + end + check(hostStart.role == "host" and guestStart.role == "guest", + label .. ": the relay seats one host and one guest") + + local gameH, gameG = headless(host), headless(guest) + local battleH = LinkBattle.newHost(gameH, host.C.roomSession(), { + myParty = copy(host.party), theirParty = copy(hostStart.theirParty), + theirName = hostStart.peerName, seed = hostStart.seed, + ruleset = hostStart.ruleset, keepNetOpen = true, + }) + local battleG = LinkBattle.newGuest(gameG, guest.C.roomSession(), { + myParty = copy(guest.party), theirParty = copy(guestStart.theirParty), + theirName = guestStart.peerName, seed = guestStart.seed, + ruleset = guestStart.ruleset, keepNetOpen = true, + }) + check(battleH ~= nil and battleG ~= nil, + label .. ": LinkBattle builds over the tournament child room") + if not (battleH and battleG) then return nil end + + local resH, resG + battleH.onFinish = function(r) resH = r end + battleG.onFinish = function(r) resG = r end + gameH.stack:push(battleH) + gameG.stack:push(battleG) + + for frame = 1, 24000 do + if resH and resG then break end + if frame % 600 == 0 then U.log(label, "battle frame", frame) end + Input.pressed = { a = true } + gameH.stack:update(1 / 60) + gameG.stack:update(1 / 60) + Input.pressed = {} + for _, bot in ipairs(watchers) do + local session = bot.C.roomSession() + if session then + for _, msg in ipairs(session:poll()) do + if msg.type == "spectate" and msg.side then + bot.sides[msg.side] = (bot.sides[msg.side] or 0) + 1 + end + end + end + end + pump(1) + end + check(resH ~= nil and resG ~= nil, + label .. ": the battle finishes over the relay (" .. + tostring(resH) .. " / " .. tostring(resG) .. ")") + check(battleH.player.mon.hp == battleG.enemy.mon.hp and + battleH.enemy.mon.hp == battleG.player.mon.hp, + label .. ": both simulations agree on HP across the relay") + for _, bot in ipairs(watchers) do + check((bot.sides.host or 0) > 0 and (bot.sides.guest or 0) > 0, + label .. ": " .. bot.name .. " saw both sides' room_msg tagged (" .. + tostring(bot.sides.host) .. "/" .. tostring(bot.sides.guest) .. ")") + end + + host.C.report(resH) + guest.C.report(resG) + local function ended(bot) + return bot.ev.match_end and bot.ev.match_end.match == token + and bot.ev.match_end or nil + end + waitFor(function() return ended(host) and ended(guest) end, 1200, + label .. ": room_result on both seats") + local endHost, endGuest = ended(host), ended(guest) + check(endHost ~= nil, + label .. ": room_result names the bracket token and reaches both seats") + local winnerId = nil + if endHost then + winnerId = endHost.winnerId + check(endHost.how == "agreed", + label .. ": the relay agreed the two reports (" .. + tostring(endHost.how) .. ")") + check(endGuest ~= nil and endGuest.winnerId == winnerId, + label .. ": both seats are told the same winner") + end + waitFor(function() + local t = org.C.tournament() + if not t then return false end + for _, round in ipairs(t.bracket) do + for _, m in ipairs(round.matches) do + if m.match == token and m.state == "done" then return true end + end + end + return false + end, 900, label .. ": the bracket to record the result") + local recorded = nil + local t = org.C.tournament() + for _, round in ipairs(t and t.bracket or {}) do + for _, m in ipairs(round.matches) do + if m.match == token then recorded = m end + end + end + check(recorded ~= nil and recorded.state == "done" and + recorded.winner == winnerId and recorded.how == "agreed", + label .. ": the bracket records the winner and how") + local loserId = nil + if winnerId then + loserId = winnerId == host.C.you().id and guest.C.you().id + or host.C.you().id + end + local eliminated = false + for _, entry in ipairs(t and t.players or {}) do + if entry.id == loserId and entry.eliminated then eliminated = true end + end + check(eliminated, label .. ": the loser is marked eliminated in tour_state") + return token + end + + local firstToken = playLiveMatch("round 1") + if not firstToken then + stopServer() + return finish() + end + local secondToken = playLiveMatch("round 2") + check(secondToken ~= nil and secondToken ~= firstToken, + "the bracket advances into a second match: " .. tostring(secondToken)) + + waitFor(function() return org.ev.tour_over ~= nil end, 1200, + "tour_over after the final") + local over = org.ev.tour_over + check(over ~= nil, "a three-player bracket ends in tour_over") + if over then + check(over.code == code, "tour_over names the tournament code") + check(org.champFromState ~= nil and org.champFromState == over.championId, + "tour_state.champion is the id tour_over calls championId (" .. + tostring(org.champFromState) .. ")") + local championName = nil + for _, entry in ipairs(org.C.tournament().players) do + if entry.id == over.championId then championName = entry.name end + end + check(championName ~= nil and championName == over.champion, + "tour_over.champion is that id's name (" .. tostring(over.champion) .. + ")") + check(watch.ev.tour_over ~= nil and + watch.ev.tour_over.championId == over.championId, + "the outside spectator is told the same champion") + check(org.C.tournament().stage == "finished", + "the model stays finished until we leave") + end + + for _, bot in ipairs(bots) do + if bot.C.state() == "online" then bot.C.leaveTournament() end + end + pump(10) + for _, bot in ipairs(bots) do bot.C.disconnect() end + pump(5) + bots = {} + + -- --------------------------------------------------- resume mid-battle + + local rOrg = newBot("RORG") + local rA = newBot("RONE", "CHARIZARD") + local rB = newBot("RTWO", "BLASTOISE") + if not connectAll(bots) then + stopServer() + return finish() + end + local rMade = rOrg.C.createTournament({ profile = PROFILE, rule = RULE, + playing = false, shotClock = 9, + maxSpectators = 4 }) + waitFor(function() return rMade.done end, 600, "the resume tournament") + rA.C.joinTournament(rMade.code, "player", rA.party, "dra") + rB.C.joinTournament(rMade.code, "player", rB.party, "drb") + waitFor(function() + local t = rOrg.C.tournament() + return t and #t.players == 2 + end, 900, "both resume-test players to register") + rOrg.C.startTournament() + waitFor(function() return rA.ev.match_start and rB.ev.match_start end, 1200, + "the resume-test match to start") + check(rA.ev.match_start ~= nil and rB.ev.match_start ~= nil, + "resume: the bracket seats both players") + if not (rA.ev.match_start and rB.ev.match_start) then + stopServer() + return finish() + end + + local rHost = rA.ev.match_start.role == "host" and rA or rB + local rGuest = rHost == rA and rB or rA + local sessH, sessG = rHost.C.roomSession(), rGuest.C.roomSession() + local function instrument(bot, session) + bot.sent, bot.recv = 0, 0 + local rawSend, rawPoll = session.send, session.poll + session.send = function(self, msg) + bot.sent = bot.sent + 1 + return rawSend(self, msg) + end + session.poll = function(self) + local out = rawPoll(self) + bot.recv = bot.recv + #out + return out + end + end + instrument(rHost, sessH) + instrument(rGuest, sessG) + + local rGameH, rGameG = headless(rHost), headless(rGuest) + local rBattleH = LinkBattle.newHost(rGameH, sessH, { + myParty = copy(rHost.party), theirParty = copy(rHost.ev.match_start.theirParty), + theirName = rHost.ev.match_start.peerName, seed = rHost.ev.match_start.seed, + ruleset = rHost.ev.match_start.ruleset, keepNetOpen = true, + }) + local rBattleG = LinkBattle.newGuest(rGameG, sessG, { + myParty = copy(rGuest.party), theirParty = copy(rGuest.ev.match_start.theirParty), + theirName = rGuest.ev.match_start.peerName, seed = rGuest.ev.match_start.seed, + ruleset = rGuest.ev.match_start.ruleset, keepNetOpen = true, + }) + local rResH, rResG + rBattleH.onFinish = function(r) rResH = r end + rBattleG.onFinish = function(r) rResG = r end + rGameH.stack:push(rBattleH) + rGameG.stack:push(rBattleG) + + local sessionBefore = rGuest.C.sessionId() + local killedAt, reconnected = nil, false + for frame = 1, 24000 do + if rResH and rResG then break end + if frame == 240 and not killedAt then + killedAt = frame + pcall(function() rGuest.net.tcpSocket:close() end) + end + if killedAt and not reconnected and rGuest.C.state() == "online" + and frame > killedAt + 30 then + reconnected = true + end + Input.pressed = { a = true } + rGameH.stack:update(1 / 60) + rGameG.stack:update(1 / 60) + Input.pressed = {} + pump(1) + end + check(killedAt ~= nil, "resume: the guest's socket was killed mid-battle") + check(reconnected, "resume: the guest reconnected on its own backoff") + check(rGuest.C.sessionId() == sessionBefore, + "resume: the guest walked back into the same relay session") + check(rResH ~= nil and rResG ~= nil, + "resume: the battle still finishes (" .. tostring(rResH) .. " / " .. + tostring(rResG) .. ")") + check(rBattleH.player.mon.hp == rBattleG.enemy.mon.hp and + rBattleH.enemy.mon.hp == rBattleG.player.mon.hp, + "resume: both simulations agree on HP, so the replay caused no desync") + for _ = 1, 30 do + if sessH then sessH:poll() end + if sessG then sessG:poll() end + pump(1) + end + check(rHost.recv == rGuest.sent, + ("resume: the host got each guest message once (%d sent, %d delivered)") + :format(rGuest.sent, rHost.recv)) + check(rGuest.recv == rHost.sent, + ("resume: room_replay redelivered nothing twice (%d sent, %d delivered)") + :format(rHost.sent, rGuest.recv)) + U.log("resume: duplicate room messages dropped by the client:", + rGuest.C.duplicates()) + + rHost.C.report(rResH) + rGuest.C.report(rResG) + waitFor(function() return rOrg.ev.tour_over ~= nil end, 1200, + "resume: tour_over") + check(rOrg.ev.tour_over ~= nil, + "resume: the tournament finishes after the resumed match") + for _, bot in ipairs(bots) do bot.C.disconnect() end + pump(5) + bots = {} + + -- ------------------------------------------------------------- forfeit + + local fOrg = newBot("FORG") + local fA = newBot("FONE", "CHARIZARD") + local fB = newBot("FTWO", "BLASTOISE") + if not connectAll(bots) then + stopServer() + return finish() + end + local fMade = fOrg.C.createTournament({ profile = PROFILE, rule = RULE, + playing = false, shotClock = 9, + maxSpectators = 4 }) + waitFor(function() return fMade.done end, 600, "the forfeit tournament") + fA.C.joinTournament(fMade.code, "player", fA.party, "dfa") + fB.C.joinTournament(fMade.code, "player", fB.party, "dfb") + waitFor(function() + local t = fOrg.C.tournament() + return t and #t.players == 2 + end, 900, "both forfeit-test players to register") + fOrg.C.startTournament() + waitFor(function() return fA.ev.match_start and fB.ev.match_start end, 1200, + "the forfeit-test match to start") + fA.C.report("error") + waitFor(function() return fA.ev.match_end and fB.ev.match_end end, 1200, + "room_result after the forfeit") + check(fA.ev.match_end ~= nil and fA.ev.match_end.how == "forfeit", + "report(\"error\") forfeits the match: " .. + tostring(fA.ev.match_end and fA.ev.match_end.how)) + check(fA.ev.match_end and fA.ev.match_end.winnerId == fB.C.you().id, + "the other seat wins the forfeited match") + waitFor(function() return fOrg.ev.tour_over ~= nil end, 1200, + "tour_over after the forfeit") + check(fOrg.ev.tour_over ~= nil and + fOrg.ev.tour_over.championId == fB.C.you().id, + "the forfeit hands the bracket to the other player") + for _, bot in ipairs(bots) do bot.C.disconnect() end + pump(5) + bots = {} + + -- ----------------------------------------------------------- shot clock + + local sOrg = newBot("SORG") + local sA = newBot("SONE", "CHARIZARD") + local sB = newBot("STWO", "BLASTOISE") + if not connectAll(bots) then + stopServer() + return finish() + end + local sMade = sOrg.C.createTournament({ profile = PROFILE, rule = RULE, + playing = false, shotClock = 3, + maxSpectators = 4 }) + waitFor(function() return sMade.done end, 600, "the shot-clock tournament") + sA.C.joinTournament(sMade.code, "player", sA.party, "dsa") + sB.C.joinTournament(sMade.code, "player", sB.party, "dsb") + waitFor(function() + local t = sOrg.C.tournament() + return t and #t.players == 2 + end, 900, "both shot-clock players to register") + sOrg.C.startTournament() + waitFor(function() return sA.ev.match_start and sB.ev.match_start end, 1200, + "the shot-clock match to start") + local sHost = sA.ev.match_start.role == "host" and sA or sB + local sStalled = sHost == sA and sB or sA + sHost.C.roomSession():send({ type = "action", kind = "move", slot = 1 }) + waitFor(function() return sHost.ev.match_end ~= nil end, + math.floor(SHOT_MS / 1000 * 60) + 900, + "the shot clock to fire on the stalled player") + check(sHost.ev.match_end ~= nil and sHost.ev.match_end.how == "timeout", + "an unanswered turn forfeits on the shot clock: " .. + tostring(sHost.ev.match_end and sHost.ev.match_end.how)) + check(sHost.ev.match_end and sHost.ev.match_end.winnerId == sHost.C.you().id, + "the player who acted wins the timed-out match") + check(sStalled.ev.match_end ~= nil and + sStalled.ev.match_end.winnerId == sHost.C.you().id, + "the stalled player is told the same result") + for _, bot in ipairs(bots) do bot.C.disconnect() end + pump(5) + bots = {} + + -- ----------------------------------------------------------- kick + + local kOrg = newBot("KORG") + local kA = newBot("KONE", "CHARIZARD") + if not connectAll(bots) then + stopServer() + return finish() + end + local kMade = kOrg.C.createTournament({ profile = PROFILE, rule = RULE, + playing = false, shotClock = 6, + maxSpectators = 4 }) + waitFor(function() return kMade.done end, 600, "the kick tournament") + kA.C.joinTournament(kMade.code, "player", kA.party, "dka") + waitFor(function() + local t = kOrg.C.tournament() + return t and #t.players == 1 + end, 900, "the kick-test player to register") + kOrg.C.kickFromTournament(kA.C.you().id) + waitFor(function() return kA.ev.error ~= nil end, 900, + "tour_closed on the kicked player") + check(kA.ev.error ~= nil and kA.ev.error.scope == "tournament" and + kA.ev.error.reason == "kicked", + "a kicked player is told tour_closed: " .. + tostring(kA.ev.error and kA.ev.error.reason)) + check(kA.C.tournament() == nil, + "the kicked player's tournament model is cleared") + waitFor(function() + local t = kOrg.C.tournament() + return t and #t.players == 0 + end, 900, "the kicked player to leave tour_state") + check(kOrg.C.tournament() and #kOrg.C.tournament().players == 0, + "the kicked player is out of tour_state") + kOrg.C.closeTournament() + waitFor(function() return kOrg.ev.error ~= nil end, 900, + "tour_closed on the creator") + check(kOrg.ev.error ~= nil and kOrg.ev.error.reason == "closed", + "the creator can close the tournament: " .. + tostring(kOrg.ev.error and kOrg.ev.error.reason)) + + for _, bot in ipairs(bots) do bot.C.disconnect() end + pump(5) + stopServer() + return finish() +end diff --git a/tests/drivers/online_walkthrough.lua b/tests/drivers/online_walkthrough.lua new file mode 100644 index 00000000..b749f9d9 --- /dev/null +++ b/tests/drivers/online_walkthrough.lua @@ -0,0 +1,240 @@ +-- Drives the ONLINE tab's own screen stack against a real ../pokeserver: +-- connect, host from the Play screen, a second seat joins, both ready, the +-- arena spec is built and handed to playArena, and the panel is back on the +-- Room screen with the match result. +-- +-- POKEPORT_RELAY_ADDR=127.0.0.1:17778 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/online_walkthrough.lua love . + +return function() + local U = dofile("tests/drivers/util.lua") + local PORT = tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or 17778 + local SERVER = os.getenv("POKESERVER_DIR") or "../pokeserver" + local failures = 0 + + local function check(cond, msg) + if cond then + U.log("ok ", msg) + else + failures = failures + 1 + U.log("FAIL", msg) + end + end + + + local probe = io.open(SERVER .. "/server.js", "r") + if not probe then + U.log("FAIL", SERVER .. "/server.js is not checked out") + love.event.quit(1) + return + end + probe:close() + + local pidFile = "/tmp/pokeserver_walk_" .. PORT .. ".pid" + os.execute(("(cd %q && PORT=%d HTTP_PORT=%d node server.js " .. + ">/tmp/pokeserver_walk.log 2>&1 & echo $! > %q)") + :format(SERVER, PORT, PORT + 1, pidFile)) + + local function stopServer() + local handle = io.open(pidFile, "r") + if handle then + local pid = handle:read("*l") + handle:close() + if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end + end + os.remove(pidFile) + end + + local function finish() + stopServer() + U.log(failures == 0 and "online walkthrough passed" + or (failures .. " online walkthrough check(s) failed")) + love.event.quit(failures == 0 and 0 or 1) + while true do coroutine.yield() end + end + + local Host = require("src.online.Client") + Host.reset() + Host.configure({ relayAddress = "127.0.0.1:" .. PORT }) + package.loaded["src.online.Client"] = nil + local Guest = require("src.online.Client") + package.loaded["src.online.Client"] = Host + Guest.reset() + Guest.configure({ relayAddress = "127.0.0.1:" .. PORT }) + + local Net = require("src.link.Net") + local function reachable() + local socketProbe = Net.new() + if not socketProbe:connectTCP("127.0.0.1:" .. PORT) then return false end + for _ = 1, 30 do + socketProbe:update() + if socketProbe.closed then + pcall(function() socketProbe:close() end) + return false + end + if not socketProbe.connecting then + pcall(function() socketProbe:close() end) + return true + end + end + pcall(function() socketProbe:close() end) + return false + end + + local up = false + for _ = 1, 300 do + up = reachable() + if up then break end + U.wait(2) + end + if not up then + check(false, "the spawned pokeserver answered on 127.0.0.1:" .. PORT) + return finish() + end + U.log("pokeserver up on", PORT) + + local OnlinePanel = require("src.import.OnlinePanel") + local SaveData = require("src.core.SaveData") + local TeamPick = require("src.online.TeamPick") + local GameVersion = require("src.core.GameVersion") + + local version + for _, id in ipairs(GameVersion.ORDER) do + if GameVersion.generation(id) == 1 then + local ok, rows = pcall(SaveData.listSlots, id) + if ok and type(rows) == "table" then + for _, row in ipairs(rows) do + if row.exists then version = version or id end + end + end + end + if version then break end + end + if not version then + check(false, "a Gen 1 save is imported for the walkthrough") + return finish() + end + + local slots = SaveData.listSlots(version) + local slotId + for _, row in ipairs(slots) do + if row.exists and not slotId then slotId = row.id end + end + + local booted = nil + local imp = { + ready = { [version] = true }, + activeSlot = {}, slots = { [version] = slots }, carts = {}, pulse = 0, + _pages = {}, _uiActions = {}, _actAt = {}, + playArena = function(_, bootVersion, cartId, spec) + booted = { version = bootVersion, cartId = cartId, spec = spec } + return true + end, + } + local st = OnlinePanel.state(imp) + st.version, st.slotId = version, slotId + st.team = { { where = "party", index = 1 } } + + local function pump(n) + for _ = 1, n or 1 do + Guest.update(1 / 60) + OnlinePanel.update(imp, 1 / 60) + coroutine.yield() + end + end + + local function waitFor(fn, frames, what) + for _ = 1, frames or 900 do + if fn() then return true end + pump(1) + end + check(false, "timed out waiting for " .. tostring(what)) + return false + end + + waitFor(function() return OnlinePanel.myProfile(imp) ~= nil end, 300, + "the arena profile to compute") + local profile = OnlinePanel.myProfile(imp) + check(profile ~= nil, "the panel computes its own arena profile") + if not profile then return finish() end + + OnlinePanel.connect(imp) + Guest.connect({ name = "BLUE#221", profiles = { profile } }) + waitFor(function() + return Host.state() == "online" and Guest.state() == "online" + end, 900, "both seats to come online") + check(Host.state() == "online", "the panel's own client is online") + + OnlinePanel.home(imp) + OnlinePanel.go(imp, "play") + check(OnlinePanel.screen(imp) == "play", "Home walks to the Play screen") + + check(OnlinePanel.startWizard(imp, "hostBattle"), + "Host a battle opens the wizard") + check(OnlinePanel.screen(imp) == "wizard", "on its own screen") + local steps = OnlinePanel.wizardSteps(imp) + check(table.concat(steps, ",") == "game,save,team,rules,visibility,summary", + "one step per choice: " .. table.concat(steps, ",")) + OnlinePanel.wizardTo(imp, "rules") + check(OnlinePanel.ruleFor(imp).partySize == 1, + "the rule defaults to the number of POKeMON picked") + OnlinePanel.wizardTo(imp, "summary") + check(OnlinePanel.wizardNext(imp), + "the summary confirm hosts the room: " .. tostring(st.status)) + waitFor(function() return Host.room() ~= nil end, 900, "the room to open") + pump(2) + local room = Host.room() + check(room ~= nil and room.code ~= nil, "hosting opens a room") + if not room then return finish() end + check(OnlinePanel.screen(imp) == "room", + "the panel routes itself onto the Room screen: " + .. tostring(OnlinePanel.screen(imp))) + + check(OnlinePanel.back(imp) and OnlinePanel.screen(imp) == "play", + "Back walks out of the Room without leaving it") + OnlinePanel.refresh(imp) + local mine = OnlinePanel.cache(imp).mine + check(mine ~= nil and mine.code == room.code, + "Play shows the player's own lobby as a card, not a joinable row") + for _, entry in ipairs(OnlinePanel.cache(imp).rooms) do + check(entry.code ~= room.code, "and never as a row you can join") + end + OnlinePanel.go(imp, "room") + + local joined = Guest.joinRoom(room.code, "player", profile) + waitFor(function() return joined.done end, 900, "the guest's join to answer") + check(joined.error == nil, "the second seat joins: " .. tostring(joined.error)) + waitFor(function() + local live = Host.room() + return live and #live.players == 2 + end, 900, "the room to hold two players") + + local pick = TeamPick.readSlot(version, slotId) + local packed = TeamPick.pack(pick, { 1 }, pick.generation) + local sent = OnlinePanel.sendReady(imp) + check(sent, "READY packs the chosen team: " .. tostring(st.status)) + Guest.ready(packed, "guest") + + waitFor(function() return booted ~= nil end, 900, "the arena to boot") + check(booted ~= nil, "match_start hands an arena spec to playArena") + if booted then + check(booted.spec.role == "host", "the panel boots as the host seat") + check(booted.spec.session ~= nil, "over the room's own session") + check(booted.spec.team ~= nil and #booted.spec.team == 1, + "carrying the team the wizard picked") + check(booted.spec.team[1] == 1, "as a party index ArenaBoot understands") + end + + OnlinePanel.recordResult("win") + st.routeKey = nil + OnlinePanel.update(imp, 1 / 60) + check(OnlinePanel.lastResult == "win", "the arena's result survives") + check(OnlinePanel.screen(imp) == "room", + "and the tab is back on the Room screen: " + .. tostring(OnlinePanel.screen(imp))) + + Guest.disconnect() + Host.disconnect() + pump(5) + return finish() +end diff --git a/tests/drivers/party_bug147_message_test.lua b/tests/drivers/party_bug147_message_test.lua index cff18bbe..22f94a4f 100644 --- a/tests/drivers/party_bug147_message_test.lua +++ b/tests/drivers/party_bug147_message_test.lua @@ -1,7 +1,6 @@ -- Driver: party menu bottom context message (#147). -- Gen1 (pokered engine/menus/party_menu.asm PartyMenuMessage) always prints --- a message in the bottom text box: "Choose a POKéMON." (PartyMenuNormalText) --- in the field, "Bring out which POKéMON?" (PartyMenuBattleText) in battle. +-- the bottom text box message (core.asm:2315, #1901). -- The recomp handled only the swap / item / TM-HM ids and printed NOTHING for -- the default field and battle voluntary-switch cases -- reporter's "NO TEXT -- BOX". This driver opens the party menu in both contexts, screenshots each, @@ -48,12 +47,15 @@ return function(game) ow:pushBattle(battle) local function mashUntil(cond, max) - for _ = 1, max or 80 do + for _ = 1, max or 200 do if cond() then return true end U.tap(game, "a") - U.wait(4) + for _ = 1, 4 do + if cond() then return true end + U.wait(1) + end end - return false + return cond() end check("reached battle menu", mashUntil(function() return battle.phase == "menu" @@ -67,8 +69,17 @@ return function(game) local battleMsg = pm and pm.bottomMessage and pm:bottomMessage() U.log("battle party open (onSwitch set):", pm and pm.onSwitch ~= nil) U.log("battle bottomMessage:", tostring(battleMsg)) - check("battle message == 'Bring out which\\nPOKéMON?'", - battleMsg == "Bring out which\nPOKéMON?") + check("voluntary battle message == 'Choose a POKéMON.'", + battleMsg == "Choose a POKéMON.") + + local forced = require("src.ui.PartyMenu").new(game, { + battle = battle, party = game.save.party, forceSwitch = true, + onSwitch = function() end, + }) + local forcedMsg = forced:bottomMessage() + U.log("forced-switch bottomMessage:", tostring(forcedMsg)) + check("forced switch message == 'Bring out which\\nPOKéMON?'", + forcedMsg == "Bring out which\nPOKéMON?") U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) end diff --git a/tests/drivers/poison_flicker_bug1872_test.lua b/tests/drivers/poison_flicker_bug1872_test.lua new file mode 100644 index 00000000..ffd3a112 --- /dev/null +++ b/tests/drivers/poison_flicker_bug1872_test.lua @@ -0,0 +1,76 @@ +-- pokered engine/gfx/screen_effects.asm:1-12 (#1872) +-- POKEPORT_DRIVER=tests/drivers/poison_flicker_bug1872_test.lua POKEPORT_IDENTITY=bug1872 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PaletteFX = require("src.render.PaletteFX") + local Zoom = require("src.render.Zoom") + local Renderer = require("src.render.Renderer") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.party[1].status = "PSN" + game.save.player.name = "bryan" + + local lo = Zoom.offsetRange(Renderer:fitScale()) + game.save.options.zoom = lo + Zoom.applyOptions(game.save.options) + + U.teleport(game, "ROUTE_1", 5, 20, "down") + U.wait(20) + + local rects = {} + local realRect = love.graphics.rectangle + love.graphics.rectangle = function(mode, x, y, w, h, ...) + if mode == "fill" and x == 0 and y == 0 and w == 160 and h == 144 then + rects[#rects + 1] = true + end + return realRect(mode, x, y, w, h, ...) + end + + -- engine/events/poison.asm + local ow, armed, peak = game.overworld, false, 0 + for _ = 1, 160 do + local dir = (_ % 2 == 0) and "up" or "down" + for _ = 1, 2 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + ow = game.overworld + local n = ow and ow.poisonFlash or 0 + if n > peak then peak = n end + if n > 0 then armed = true end + end + game.input.state[dir] = false + if armed then break end + end + check("a poison tick armed the flicker while walking", armed) + U.log("tinted frames", peak + 1) + if armed then + check("it arms four frames, like `ld c, 4 / call DelayFrames`", peak + 1 == 4) + end + + local sawMap = false + for _ = 1, 6 do + U.wait(1) + if PaletteFX.shadeMap() == PaletteFX.POISON_BGP then sawMap = true end + end + check("the flicker goes through the rBGP shade map, not a canvas overlay", + sawMap) + check("nothing filled the 160x144 UI canvas during the flash", #rects == 0) + love.graphics.rectangle = realRect + + U.shot(game, "poison_flicker_bug1872.png") + U.log("walk around with the poisoned CHARIZARD: every fourth step the WHOLE") + U.log("window should darken for four frames, survey map and letterbox bars") + U.log("included, not just the 160x144 box in the middle.") + U.log("white background pixels drop one shade; sprites and dark tiles stay put.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/prize_room_coincase_bug194_test.lua b/tests/drivers/prize_room_coincase_bug194_test.lua index 32a8b000..730594ad 100644 --- a/tests/drivers/prize_room_coincase_bug194_test.lua +++ b/tests/drivers/prize_room_coincase_bug194_test.lua @@ -3,12 +3,10 @@ -- COIN CASE: IsItemInBag COIN_CASE first, and with no case it prints -- RequireCoinCaseText and returns without ever opening a window; only with the -- case does it print ExchangeCoinsForPrizesText and then show the prize list. --- The port used to open "PRIZES (COINS)" unconditionally with no intro line. -- -- The three prize counters are bg-event signs at cells (2,2),(4,2),(6,2) in -- GAME_CORNER_PRIZE_ROOM (data/generated/maps.lua). Stand south of vendor 1 --- and press A: no-case -> require box and NO list; has-case -> exchange box, --- then the prize list; cancel returns to the overworld (onCancel == done). +-- and press A: no-case -> require box and NO list; has-case -> exchange box. -- -- SHOT_DIR=/tmp/prize194 POKEPORT_IDENTITY=bug194 POKEPORT_TOUCH=0 \ -- POKEPORT_DRIVER=tests/drivers/prize_room_coincase_bug194_test.lua love . @@ -20,7 +18,7 @@ return function(game) local Pokemon = require("src.pokemon.Pokemon") local TextBox = require("src.render.TextBox") - local ListMenu = require("src.ui.ListMenu") + local PrizeCounter = require("src.ui.PrizeCounter") -- a party so nothing else blocks overworld interaction game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } @@ -102,19 +100,27 @@ return function(game) assert(sawExchange, "has-case: 'We exchange your coins for prizes.' never shown (#194)") - -- advance past the exchange line; the prize list must then open local sawList = false for _ = 1, 60 do - if topMeta() == ListMenu then sawList = true break end + if topMeta() == PrizeCounter then sawList = true break end U.tap(game, "a") U.wait(1) end - U.log("has-case: sawList", sawList, "title", - (sawList and game.stack:top().title) or "-") + U.log("has-case: sawList", sawList, "rows", + (sawList and #game.stack:top().prizes) or "-") U.shot(game, DIR .. "/prize_room_3_menu.png") - assert(sawList, "has-case: prize ListMenu never opened after exchange text") - assert(game.stack:top().title == "PRIZES (COINS)", - "has-case: opened list is not the prize window") + assert(sawList, "has-case: prize window never opened after exchange text") + local under = game.stack.states[#game.stack.states - 1] + assert(getmetatable(under) == TextBox, + "has-case: WhichPrizeText should stay in the box under the window") + U.log("has-case: names", + table.concat({ game.stack:top().prizes[1].name, + game.stack:top().prizes[2].name, + game.stack:top().prizes[3].name }, ", ")) + for _, row in ipairs(game.stack:top().prizes) do + assert(not tostring(row.name):find("L%d"), + "has-case: prize names carry no level (GetMonName only)") + end -- cancel returns to the overworld (onCancel == done) U.tap(game, "b") diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 4cad673d..5432bb85 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -6504,10 +6504,7 @@ end -- to MR_FUJIS_HOUSE" however hard it searches. One FRESH_WATER opens every -- gate permanently. -- --- The machines are SIGNS (10,1) (11,1) (12,2), not clerks, so ops.shop --- cannot drive them -- they open a plain ListMenu (data/scripts/story4.lua --- vendingMachine) which stays up between purchases, showing a "popped out!" --- box over itself each time. +-- The machines are SIGNS (10,1) (11,1) (12,2), not clerks. -- -- Buys several: one goes to the guards, and the roof's thirsty girl trades -- the others for TMs. Nothing later depends on those TMs, so a short bag or @@ -6526,32 +6523,27 @@ function MANUAL.giveWater(where) note("giveWater: no vending machine here", where) return false end - -- a sign is read from the cell below it, facing up - if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then - note("giveWater: cannot reach the vending machine", where) - return false - end - faceDir("up") - press("a") - U.wait(10) - if not waitFor(isList, 40) then - note("giveWater: the vending machine did not open", where) - say("giveWater: no vending menu appeared") - backOut() - return false - end local bought = 0 for _ = 1, VENDING_WANT do - if not isList() then break end - cursorTo("index", 1) -- FRESH_WATER, the cheapest at 200 - press("a") - U.wait(12) - -- the purchase (or "Not enough money") prints over the list; clear it - for _ = 1, 20 do - if isList() then break end - press("a") - U.wait(4) + if not ops.goto_({ x = sign.x, y = sign.y + 1 }) then + note("giveWater: cannot reach the vending machine", where) + break end + faceDir("up") + press("a") + U.wait(10) + if not pressUntil(isMenu, "a", 20) then + if bought == 0 then + note("giveWater: the vending machine did not open", where) + say("giveWater: no vending menu appeared") + end + backOut() + break + end + cursorTo("index", 1) -- FRESH WATER, the cheapest at 200 + press("a") + U.wait(140) + mashUntilIdle() bought = bought + 1 end backOut() diff --git a/tests/drivers/snorlax_flute_bug1880_test.lua b/tests/drivers/snorlax_flute_bug1880_test.lua new file mode 100644 index 00000000..22683eef --- /dev/null +++ b/tests/drivers/snorlax_flute_bug1880_test.lua @@ -0,0 +1,106 @@ +-- pokered engine/items/item_effects.asm:1794 (#1880) +-- POKEPORT_DRIVER=tests/drivers/snorlax_flute_bug1880_test.lua \ +-- POKEPORT_IDENTITY=bug1880 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- No POKEPORT_SPEED: the tune runs on the audio clock. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Screens = require("src.ui.Screens") + local Bag = require("src.inventory.Bag") + local Pokemon = require("src.pokemon.Pokemon") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1; U.log("PASS", label) + else fail = fail + 1; U.log("FAIL", label) end + return ok + end + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfxVol is 0; turn it up in OPTION or the ear half of this check is moot") + end + + game.save.party = { Pokemon.new(game.data, "PIDGEOTTO", 30) } + Bag.add(game.save, "POKE_FLUTE", 1) + + U.teleport(game, "ROUTE_12", 10, 30, "down") + U.wait(20) + local ow = game.overworld + local snorlax + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and tostring(npc.def.name or ""):find("SNORLAX") then snorlax = npc end + end + if not check("ROUTE_12 has its SNORLAX", snorlax ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + U.teleport(game, "ROUTE_12", snorlax.cellX, snorlax.cellY - 1, "down") + U.wait(20) + ow = game.overworld + + local bag = Screens.push(game, "BagMenu") + U.wait(20) + local row + for i, item in ipairs(bag.items) do + if item.value == "POKE_FLUTE" then row = i end + end + if not check("the POKé FLUTE is in the bag", row ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + bag.index = row + U.tap(game, "a") + U.wait(20) + if game.stack:top() ~= bag and not game.stack:top().isTextBox then + U.tap(game, "a") + U.wait(20) + end + + local box + for _ = 1, 240 do + local top = game.stack:top() + if top and top.isTextBox then box = top break end + U.wait(1) + end + if not check("the played-flute box opened", box ~= nil) then + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + while true do coroutine.yield() end + end + + for _ = 1, 240 do + if box.done then break end + U.tap(game, "a") + end + check("the line typed out", box.done == true) + check("nothing has played yet", box.autoStarted ~= true and box.autoSrc == nil) + U.log("the box should read ' played the POKé FLUTE.' with a blinking") + U.log("arrow and NO tune yet; the map music is still going") + U.shot(game, "/tmp/shots/bug1880_prompt.png") + + U.tap(game, "a") + U.wait(2) + check("the prompt was answered", box.autoPrompted == true) + check("the tune started", box.autoSrc ~= nil) + U.log("the tune plays now, with the same box still on screen") + + local heldFrames, wokeEarly = 0, false + for _ = 1, 600 do + local playing = box.autoSrc and box.autoSrc.isPlaying and box.autoSrc:isPlaying() + if not playing then break end + heldFrames = heldFrames + 1 + if game.stack:top() ~= box then wokeEarly = true break end + U.tap(game, "a") + end + check("A never cut the tune short", not wokeEarly) + check("the tune held the box for a while", heldFrames > 30) + U.log(("the box stayed up for %d frames of tune"):format(heldFrames)) + + U.wait(30) + U.log("SNORLAX should be waking up only now, after the tune finished") + U.shot(game, "/tmp/shots/bug1880_after.png") + U.log(("RESULT pass=%d fail=%d"):format(pass, fail)) + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/touch_skin_crop_bug1677_test.lua b/tests/drivers/touch_skin_crop_bug1677_test.lua new file mode 100644 index 00000000..846804ac --- /dev/null +++ b/tests/drivers/touch_skin_crop_bug1677_test.lua @@ -0,0 +1,72 @@ +-- SHOT_DIR=/tmp/skin1677 POKEPORT_TOUCH=1 \ +-- POKEPORT_DRIVER=tests/drivers/touch_skin_crop_bug1677_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TouchControls = require("src.core.TouchControls") + local TouchSkin = require("src.core.TouchSkin") + local Pokemon = require("src.pokemon.Pokemon") + + local dir = os.getenv("SHOT_DIR") or "/tmp/skin1677" + local skinId = os.getenv("SKIN") or "gb_anim" + local shotW = tonumber(os.getenv("SHOT_W")) or 1600 + local shotH = tonumber(os.getenv("SHOT_H")) or 720 + love.window.setMode(shotW, shotH, { resizable = true, highdpi = true }) + U.wait(2) + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.save.player.name = "bryan" + game.save.options.touchControls = { enabled = true, skin = skinId } + game.save.options.tilt = 0 + game.save.options.zoom = 0 + game.save.options.pipelines = {} + game:applyOptions() + + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + U.wait(10) + + local ww, wh = love.graphics.getDimensions() + local page = TouchSkin.page() + U.log("skin:", tostring(TouchControls.skinId), "page:", page and page.name, + "window:", ww .. "x" .. wh) + if not page then + U.log("FAIL: no skin page active, cannot judge the overlay art") + while true do coroutine.yield() end + end + + local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh) + U.log("page box:", string.format("%.1f,%.1f %.1fx%.1f", bx, by, bw, bh)) + + local function report(label, img, w, h) + if not img then return true end + local iw, ih = img:getWidth(), img:getHeight() + local sx, sy = TouchSkin.imageFit(iw, ih, w, h) + if not sx then + U.log("FAIL:", label, "zero-sized image") + return false + end + local dw, dh = iw * sx, ih * sy + local fits = dw <= w + 0.01 and dh <= h + 0.01 + U.log(string.format("%-14s art %dx%d dest %.1fx%.1f drawn %.1fx%.1f%s", + label, iw, ih, w, h, dw, dh, fits and "" or " CROPPED")) + return fits + end + + local ok = report("page bezel", page.image, bw, bh) + for i, ctl in ipairs(page.controls) do + if ctl.image then + local _, _, halfW, halfH = TouchSkin.controlGeometry(page, ctl, ww, wh) + local label = (ctl.buttons and ctl.buttons[1]) or ("desc" .. (i - 1)) + if not report(label, ctl.image, halfW * 2, halfH * 2) then ok = false end + end + end + + U.log(ok and "PASS: every overlay bitmap draws whole inside its dest box" + or "FAIL: some overlay art is drawn larger than its dest box") + U.shot(game, dir .. "/skin_wide.png") + U.log("shot:", dir .. "/skin_wide.png") + U.log("right looks like: each button keeps its full outline ring, and the") + U.log("art may look stretched on a window that is not the overlay aspect,") + U.log("but no edge of any button graphic is sliced off.") + + while true do coroutine.yield() end +end diff --git a/tests/drivers/tournament_guest_test.lua b/tests/drivers/tournament_guest_test.lua deleted file mode 100644 index 781afb4d..00000000 --- a/tests/drivers/tournament_guest_test.lua +++ /dev/null @@ -1,72 +0,0 @@ --- 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 deleted file mode 100644 index 45bf0347..00000000 --- a/tests/drivers/tournament_host_test.lua +++ /dev/null @@ -1,57 +0,0 @@ --- 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/drivers/vending_machine_bug1876_test.lua b/tests/drivers/vending_machine_bug1876_test.lua new file mode 100644 index 00000000..5d918712 --- /dev/null +++ b/tests/drivers/vending_machine_bug1876_test.lua @@ -0,0 +1,124 @@ +-- engine/events/vending_machine.asm (#1876) +-- POKEPORT_DRIVER=tests/drivers/vending_machine_bug1876_test.lua POKEPORT_IDENTITY=bug1876 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + os.execute("mkdir -p " .. SHOT_DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local Menu = require("src.ui.Menu") + + local ok = true + local function check(label, pass) + if not pass then ok = false end + U.log(pass and "PASS" or "FAIL", label) + return pass + end + + local function waitForArrow(box) + for _ = 1, 240 do + if box.waiting or box.done then return true end + U.wait(1) + end + return false + end + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) } + game.save.money = 3000 + + U.teleport(game, "CELADON_MART_ROOF", 10, 2, "up") + U.wait(20) + + local ow = game.overworld + local sign + for _, s in ipairs((ow.map.def and ow.map.def.signs) or {}) do + if tostring(s.text or s.id or ""):find("VENDING_MACHINE") then + sign = s + break + end + end + if not check("the roof has a vending machine sign", sign ~= nil) then + while true do coroutine.yield() end + end + U.teleport(game, "CELADON_MART_ROOF", sign.x, sign.y + 1, "up") + U.wait(20) + + U.tap(game, "a") + U.wait(40) + local intro = game.stack:top() + check("A on the machine opens a text box", getmetatable(intro) == TextBox) + local said = {} + for _, page in ipairs(intro.pages or {}) do + for _, line in ipairs(page) do said[#said + 1] = line end + end + U.log("it reads:", table.concat(said, " / ")) + check("it is VendingMachineText1", + table.concat(said, " "):find("vending machine", 1, true) ~= nil) + check("the money box rides along", intro.money ~= nil) + waitForArrow(intro) + U.shot(game, SHOT_DIR .. "/bug1876_1_intro.png") + + U.tap(game, "a") + U.wait(30) + local menu = game.stack:top() + check("the drink list opens over it", getmetatable(menu) == Menu) + if getmetatable(menu) ~= Menu then + while true do coroutine.yield() end + end + check("the greeting is still in the box underneath", + game.stack.states[#game.stack.states - 1] == intro) + check("the list is not a screen of its own", menu.isOpaque ~= true) + check("TextBoxBorder at hlcoord 0,3, 14x10", + menu.tx == 0 and menu.ty == 3 and menu.tw == 14 and menu.th == 10) + local labels = {} + for i, item in ipairs(menu.items) do labels[i] = item.label end + U.log("rows:", table.concat(labels, ", ")) + check("three drinks and CANCEL", #menu.items == 4 + and labels[4] == "CANCEL") + U.shot(game, SHOT_DIR .. "/bug1876_2_menu.png") + + U.tap(game, "up") + U.wait(10) + check("Up on FRESH WATER does not wrap onto CANCEL", menu.index == 1) + + local moneyBefore = game.save.money + local heldBefore = game.save.inventory.FRESH_WATER or 0 + U.tap(game, "a") + U.wait(150) -- 120 frames of rumble, then the popped-out line + local result = game.stack:top() + check("the delivery ends in a text box", getmetatable(result) == TextBox) + local out = {} + for _, page in ipairs((result.pages) or {}) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + U.log("it reads:", table.concat(out, " / ")) + check("it is VendingMachineText5", + table.concat(out, " "):find("popped out", 1, true) ~= nil) + check("the drink is in the bag", + (game.save.inventory.FRESH_WATER or 0) == heldBefore + 1) + check("and it cost 200", game.save.money == moneyBefore - 200) + check("the drink list is gone by then", + game.stack.states[#game.stack.states - 1] == ow) + waitForArrow(result) + U.shot(game, SHOT_DIR .. "/bug1876_3_popped.png") + + U.tap(game, "a") + U.wait(30) + check("one A closes the whole conversation", game.stack:top() == ow) + + U.log(ok and "checks are green, the screen is worth looking at." + or "something above says FAIL, do not trust what is on screen.") + U.log("shots are in " .. SHOT_DIR .. ".") + U.log("") + U.log("the half a machine cannot judge: bug1876_2_menu.png should show the") + U.log("roof tiles to the right of column 14 and the greeting still in the") + U.log("bottom box, with MONEY top-right and the prices one row under each") + U.log("drink. talk to the machine again and press A on a drink: the rumble") + U.log("is SFX_PUSH_BOULDER restarted 60 times over two seconds, and the") + U.log("popped-out line lands while the last one is still decaying.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/widescreen_clip_bug1869_test.lua b/tests/drivers/widescreen_clip_bug1869_test.lua new file mode 100644 index 00000000..b9d7d1c1 --- /dev/null +++ b/tests/drivers/widescreen_clip_bug1869_test.lua @@ -0,0 +1,166 @@ +-- engine/events/halloffame.asm:270 (#1869) +-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/widescreen_clip_bug1869_test.lua \ +-- POKEPORT_SHOT_DIR=/tmp/widescreen-clip love . +local U = require("tests.drivers.util") + +local Chrome = require("src.ui.gen2.Chrome") +local Mon = require("src.battle.gen2.Mon") +local Permissions = require("src.world.gen2.Permissions") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/widescreen-clip" + local failures = 0 + + local function ok(label, condition, detail) + if condition then + print("[wide] ok " .. label) + else + failures = failures + 1 + print("[wide] FAIL " .. label .. " " .. tostring(detail)) + end + end + + local function shot(path) + if not U.shot(game, path) then failures = failures + 1 end + end + + U.wait(45) + local world = game.world + assert(world and world.map, "gold world did not boot") + + if love.window and love.window.setMode then + love.window.setMode(1280, 840, { resizable = true }) + U.wait(6) + end + local winW, winH = love.graphics.getDimensions() + local scale = Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + ok(("the window leaves bars to look at (%dx%d, panel at %d,%d x%d)") + :format(winW, winH, ox, oy, scale), ox > 8 and oy > 8, ox .. "," .. oy) + + local G = love.graphics + local function panelClip(label, drawIt) + local rects = {} + local realSet, realIntersect = G.setScissor, G.intersectScissor + G.setScissor = function(...) rects[#rects + 1] = { ... } return realSet(...) end + if realIntersect then + G.intersectScissor = function(...) + rects[#rects + 1] = { ... } + return realIntersect(...) + end + end + local fine, err = pcall(drawIt) + G.setScissor, G.intersectScissor = realSet, realIntersect + if not fine then + ok(label .. " draws", false, err) + return + end + local matched = false + for _, r in ipairs(rects) do + if r[1] == ox and r[2] == oy and r[3] == Chrome.SCREEN_W * 8 * scale + and r[4] == Chrome.SCREEN_H * 8 * scale then + matched = true + end + end + ok(label .. " clips to the 160x144 panel before it draws", matched, + #rects .. " scissors, none matching " .. ox .. "," .. oy) + end + + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 12) } + assert(world:setMap("ROUTE_29", 15, 11, "down"), "setMap ROUTE_29 failed") + U.wait(8) + if not Permissions.isWalkable(world:playerCollision()) then + for _, step in ipairs({ { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }) do + if world:setMap("ROUTE_29", 15 + step[1], 11 + step[2], "down") + and Permissions.isWalkable(world:playerCollision()) then + break + end + end + U.wait(8) + end + assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 4) }), + "startBattle failed") + local battle + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then battle = top break end + U.wait(1) + end + ok("the battle screen came up", battle ~= nil, battle) + if battle then + U.log("01: the first frames of the battle intro. The player's backpic") + U.log("slides in from the right EDGE OF THE PANEL. The white surround") + U.log("around it must be empty -- a trainer sprite parked out in the white") + U.log("void to the right of the screen is the bug.") + shot(out .. "/01-battle-intro.png") + U.wait(20) + shot(out .. "/02-battle-intro-mid.png") + for _ = 1, 150 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(2) + end + U.log("03: the FIGHT menu. Nothing at all outside the panel.") + shot(out .. "/03-battle-menu.png") + panelClip("BattleState:drawWidescreen", + function() battle:drawWidescreen(winW, winH) end) + game.stack:pop() + U.wait(10) + end + + world:hallOfFame(function() end) + local hof + for _ = 1, 600 do + local top = game.stack:top() + if top and top.phase then hof = top break end + U.wait(1) + end + ok("the Hall of Fame is up", hof ~= nil, hof) + if hof then + hof.phase = "backpic" + hof.scx = 0x50 + U.wait(2) + U.log("04: the Hall of Fame mid-slide. The bars left and right of the") + U.log("panel must be solid black. A whole backpic sitting in the left bar,") + U.log("or a scrap of one in the right bar, is the wrap copy leaking.") + shot(out .. "/04-hof-backpic.png") + panelClip("HallOfFame:drawWidescreen", + function() hof:drawWidescreen(winW, winH) end) + for _ = 1, 60 do + U.tap(game, "a") + U.wait(4) + if game.stack:top() ~= hof then break end + end + end + + local credits = game.stack:top() + if not (credits and credits.drawBorderStrips) then + game.stack:pop() + world:credits(function() end) + for _ = 1, 600 do + local top = game.stack:top() + if top and top.drawBorderStrips then credits = top break end + U.wait(1) + end + end + ok("the credits are rolling", credits ~= nil, credits) + if credits then + U.wait(120) + U.log("05: the credits. The two border strips run edge to edge INSIDE the") + U.log("panel only. Wavy white bands continuing through the black bars to") + U.log("the window edge is the strip loop drawing past x=0 and x=160.") + shot(out .. "/05-credits.png") + panelClip("Credits:drawWidescreen", + function() credits:drawWidescreen(winW, winH) end) + U.wait(240) + shot(out .. "/06-credits-later.png") + end + + print(failures == 0 and "[wide] PASS widescreen_clip_bug1869" + or ("[wide] FAIL widescreen_clip_bug1869 (%d)"):format(failures)) + U.log("the window is left at 1280x840; resize it and the bars must stay") + U.log("empty at every size.") + + while true do coroutine.yield() end +end diff --git a/tests/engine/amnesia_rest_share_anim_bug1881.lua b/tests/engine/amnesia_rest_share_anim_bug1881.lua new file mode 100644 index 00000000..f3789998 --- /dev/null +++ b/tests/engine/amnesia_rest_share_anim_bug1881.lua @@ -0,0 +1,35 @@ +-- engine/battle/animations.asm:452-473 (#1881) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local AnimPlayer = require("src.battle.AnimPlayer") + +local function row(marker) + return { seq = { { effect = "SE_DARK_SCREEN_FLASH", sound = marker } } } +end + +local data = { moveAnims = { + AMNESIA = row("player_amnesia"), + REST = row("player_rest"), + CONF_ANIM = row("enemy_conf"), + SLP_ANIM = row("enemy_slp"), +} } + +local function firstSound(moveId, attackerIsPlayer) + local p = AnimPlayer.new(data) + p:start(moveId, attackerIsPlayer) + for _, ev in ipairs(p.events) do + if ev.sound then return ev.sound end + end +end + +T.eq(firstSound("AMNESIA", false), "enemy_conf", + "the foe's Amnesia plays CONF_ANIM, not a coord-flipped AmnesiaAnim") +T.eq(firstSound("REST", false), "enemy_slp", + "the foe's Rest plays SLP_ANIM") +T.eq(firstSound("AMNESIA", true), "player_amnesia", + "the player's Amnesia keeps its own row") +T.eq(firstSound("REST", true), "player_rest", + "the player's Rest keeps its own row") +T.eq(firstSound("CONF_ANIM", false), "enemy_conf", + "the status-check ids are untouched") diff --git a/tests/engine/arena_boot.lua b/tests/engine/arena_boot.lua new file mode 100644 index 00000000..82ce132e --- /dev/null +++ b/tests/engine/arena_boot.lua @@ -0,0 +1,210 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local ArenaBoot = require("src.online.ArenaBoot") + +local function fakeSession() + return { + sent = {}, + send = function(self, msg) self.sent[#self.sent + 1] = msg end, + poll = function() return {} end, + take = function() return nil end, + close = function(self) self.closed = true end, + update = function() end, + paired = true, + closed = false, + } +end + +local function mon(species, level) + return { + species = species, + level = level or 50, + hp = 100, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }, + statExp = {}, + } +end + +local function packed(species) + return { { species = species, level = 50, moves = {}, dvs = {}, statExp = {} } } +end + +local NONE = setmetatable({}, { __tostring = function() return "NONE" end }) + +local function merge(base, over) + for k, v in pairs(over or {}) do + base[k] = (v ~= NONE) and v or nil + end + return base +end + +local function profile(over) + local p = { + engine = 1, + version = "red", + engineVersion = "1.9.0", + apiVersion = 2, + fingerprint = "abc123", + rulesetId = "gen1_faithful", + kind = "vanilla", + rule = { partySize = 3, forceLevel = 50 }, + } + return merge(p, over) +end + +local function fields(over) + local f = { + profile = profile(), + role = "host", + slotId = "slot1", + team = { 3, 1 }, + seed = 12345, + peerName = "BLUE", + theirParty = packed("BLASTOISE"), + session = fakeSession(), + onDone = function() end, + } + return merge(f, over) +end + +-- ---------------------------------------------------------------- spec +local spec, err = ArenaBoot.spec(fields()) +T.check(spec ~= nil, "a well formed host spec validates: " .. tostring(err)) +T.eq(spec.role, "host", "role survives") +T.eq(spec.slotId, "slot1", "slotId survives") +T.eq(spec.seed, 12345, "seed survives") +T.eq(spec.peerName, "BLUE", "peerName survives") +T.same(spec.team, { 3, 1 }, "team order survives") +T.eq(spec.profile.rule.partySize, 3, "rule.partySize survives") +T.eq(spec.profile.rule.forceLevel, 50, "rule.forceLevel survives") +T.eq(spec.hostName, "HOST", "hostName defaults") +T.eq(spec.guestName, "GUEST", "guestName defaults") +T.eq(type(spec.onDone), "function", "onDone is always callable") + +local defaulted = ArenaBoot.spec(fields({ profile = profile({ rule = NONE }), + team = NONE, peerName = NONE })) +T.check(defaulted ~= nil, "a spec with no rule and no team validates") +T.eq(defaulted and defaulted.profile.rule.partySize, 6, "partySize defaults to 6") +T.eq(defaulted and defaulted.peerName, "FOE", "peerName defaults to FOE") + +local function rejects(over, what) + local got, why = ArenaBoot.spec(fields(over)) + T.check(got == nil, what .. " is refused (got " .. tostring(why) .. ")") +end + +rejects({ profile = NONE }, "a spec with no profile") +rejects({ profile = profile({ engine = 3 }) }, "an unknown engine") +rejects({ profile = profile({ version = "ruby" }) }, "an unknown version") +rejects({ profile = profile({ kind = "cart" }) }, "a cart profile with no cart") +rejects({ profile = profile({ rule = { partySize = 9 } }) }, "partySize above 6") +rejects({ profile = profile({ rule = { forceLevel = 500 } }) }, "an out of range forceLevel") +rejects({ role = "referee" }, "an unknown role") +rejects({ slotId = NONE }, "a player spec with no slotId") +rejects({ team = { 1, 1 } }, "a team that repeats an index") +rejects({ team = { 1, 7 } }, "a team index outside 1..6") +rejects({ team = { 1, 2, 3, 4 } }, "a team longer than partySize") +rejects({ seed = "abc" }, "a non-numeric seed") +rejects({ session = {} }, "a session with no send/poll/close") +rejects({ theirParty = NONE }, "a player spec with no theirParty") +rejects({ onDone = 7 }, "a non-callable onDone") + +local cartSpec = ArenaBoot.spec(fields({ + profile = profile({ kind = "cart", + cart = { id = "kanto_plus", version = "1.2.0", hash = "deadbeef" } }), +})) +T.check(cartSpec ~= nil, "a cart profile with id and hash validates") +T.eq(cartSpec and cartSpec.profile.cart.id, "kanto_plus", "cart id survives") + +local specSession = fakeSession() +local watched = ArenaBoot.spec(fields({ session = specSession })) +T.check(watched.session == specSession, "the session object is passed through, not copied") + +local seen +local reported = ArenaBoot.spec(fields({ onDone = function(r) seen = r end })) +reported.onDone("win") +T.eq(seen, "win", "onDone forwards the result") + +local spectator, specErr = ArenaBoot.spec({ + profile = profile(), + role = "spectator", + seed = 7, + hostParty = packed("CHARIZARD"), + guestParty = packed("BLASTOISE"), + hostName = "RED", + guestName = "BLUE", + session = fakeSession(), +}) +T.check(spectator ~= nil, "a spectator spec needs no slotId: " .. tostring(specErr)) +T.check(ArenaBoot.spec({ profile = profile(), role = "spectator", seed = 7, + session = fakeSession() }) == nil, + "a spectator spec with no parties is refused") + +-- ---------------------------------------------------------------- battleOpts +local opts = ArenaBoot.battleOpts(spec) +T.eq(opts.role, "host", "host opts carry the role") +T.eq(opts.theirName, "BLUE", "host opts name the peer") +T.eq(opts.seed, 12345, "host opts carry the seed") +T.eq(opts.ruleset, "gen1_faithful", "ruleset comes from profile.rulesetId") +T.eq(opts.verdict, "full", "arena battles always run at verdict full") +T.eq(opts.strict, true, "arena battles are always strict") +T.eq(opts.forceLevel, 50, "forceLevel comes from the rule") +T.eq(opts.keepNetOpen, true, "the arena never closes the session") +T.check(opts.theirParty ~= nil, "host opts carry theirParty") + +local guestOpts = ArenaBoot.battleOpts(ArenaBoot.spec(fields({ role = "guest" }))) +T.eq(guestOpts.role, "guest", "guest opts carry the role") + +local specOpts = ArenaBoot.battleOpts(spectator) +T.eq(specOpts.hostName, "RED", "spectator opts carry hostName") +T.eq(specOpts.guestName, "BLUE", "spectator opts carry guestName") +T.eq(specOpts.keepNetOpen, true, "spectator opts keep the session open") +T.check(specOpts.hostParty ~= nil and specOpts.guestParty ~= nil, + "spectator opts carry both parties") +T.check(specOpts.myParty == nil, "spectator opts have no myParty") + +-- ---------------------------------------------------------------- packOwnParty +local game = { save = { party = { mon("CHARIZARD"), mon("PIKACHU"), mon("SNORLAX") } } } +local fresh = ArenaBoot.spec(fields()) +local mine, packErr = ArenaBoot.packOwnParty(game, fresh) +T.check(mine ~= nil, "packOwnParty packs a party: " .. tostring(packErr)) +T.eq(#mine, 2, "packOwnParty packs exactly the chosen team") +T.eq(mine[1].species, "SNORLAX", "the team's first index leads") +T.eq(mine[2].species, "CHARIZARD", "the team's second index follows") +T.check(fresh.myParty == mine, "packOwnParty fills spec.myParty") + +local again = ArenaBoot.packOwnParty(game, fresh) +T.check(again == mine, "packOwnParty is idempotent once myParty is filled") + +local noTeam = ArenaBoot.spec(fields({ team = NONE })) +local wholeParty = ArenaBoot.packOwnParty(game, noTeam) +T.eq(#wholeParty, 3, "with no team the whole party goes, capped by partySize") + +local capped = ArenaBoot.spec(fields({ team = NONE, + profile = profile({ rule = { partySize = 2 } }) })) +T.eq(#ArenaBoot.packOwnParty(game, capped), 2, "partySize caps the untargeted party") + +local empty = ArenaBoot.spec(fields()) +local none, noneErr = ArenaBoot.packOwnParty({ save = { party = {} } }, empty) +T.check(none == nil and noneErr ~= nil, "an empty party is refused: " .. tostring(noneErr)) + +T.check(ArenaBoot.packOwnParty(game, spectator) == nil, + "packOwnParty does nothing for a spectator") + +-- A Gen 2 profile packs through Protocol.packParty2, whose record carries the +-- held item and Gen 2's `experience` rather than Gen 1's `exp`. +local gen2Game = { save = { party = { mon("TYPHLOSION"), mon("PIKACHU"), + mon("SNORLAX") } } } +gen2Game.save.party[3].item = "LEFTOVERS" +gen2Game.save.party[3].experience = 125000 +local gen2 = ArenaBoot.spec(fields({ profile = profile({ engine = 2, version = "gold" }) })) +local g2, g2err = ArenaBoot.packOwnParty(gen2Game, gen2) +T.check(g2 ~= nil, "gen 2 party packing goes through packParty2: " .. tostring(g2err)) +T.eq(g2 and #g2, 2, "the Gen 2 pack honours the chosen team") +T.eq(g2 and g2[1].species, "SNORLAX", "the Gen 2 team's first index leads") +T.eq(g2 and g2[1].item, "LEFTOVERS", "the held item rides the Gen 2 record") +T.eq(g2 and g2[1].experience, 125000, "the Gen 2 record carries `experience`") +T.check(g2 and g2[1].exp == nil, "...and not Gen 1's `exp`") + +T.finish("arena_boot") diff --git a/tests/engine/cart_import_cancel_bug1813.lua b/tests/engine/cart_import_cancel_bug1813.lua new file mode 100644 index 00000000..43acb27a --- /dev/null +++ b/tests/engine/cart_import_cancel_bug1813.lua @@ -0,0 +1,93 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.graphics.polygon = love.graphics.polygon or function() end + +local Kit = require("src.ui.kit.Kit") +local Platform = require("src.core.Platform") +local HostShell = require("src.core.HostShell") +local FilePicker = require("src.core.FilePicker") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +love.graphics.getDimensions = function() return 1280, 720 end +love.graphics.getPixelDimensions = function() return 1280, 720 end + +local pumped = 0 +local realPump = HostShell.pumpHostEvents +HostShell.pumpHostEvents = function() pumped = pumped + 1 end + +local realPopen, realPclose = HostShell.popen, HostShell.pclose +HostShell.popen = function() + return { read = function() return "" end, close = function() end } +end +HostShell.pclose = function() end + +local realCanSpawn = Platform.canSpawnProcess +Platform.canSpawnProcess = function() return true end +local realGetOS = love.system.getOS +love.system.getOS = function() return "OS X" end + +local cancelled = FilePicker.open("Choose a cart", { label = "Cart", + exts = { "g1rcart" } }) +eq(cancelled, nil, "empty chooser output reads as a cancel") +eq(pumped, 1, "the chooser's button-up is pumped once the read unblocks") + +HostShell.popen, HostShell.pclose = realPopen, realPclose +HostShell.pumpHostEvents = realPump +love.system.getOS = realGetOS + +local realOpen = FilePicker.open +FilePicker.open = function() return nil end + +local imp = RomImporter.new(function() end, { launcher = true }) +imp.tab = "red" +imp.ready = { red = true, blue = true } +imp._cartPopup = "red" + +love.mouse.isDown = function() return false end +imp._prevMouseDown = true +imp._mouseAt = { x = 10, y = 10 } +imp._clickPt = { x = 10, y = 10 } + +eq(imp:importCartFile("red"), false, "a cancelled cart import commits nothing") +eq(imp._cartPopup, "red", "Custom Carts stays open after a cancel") +eq(imp._cartNotice, nil, "a cancel is not an error worth a notice") +eq(imp._mouseAt, nil, "the armed drag is dropped when the dialog returns") +eq(imp._clickPt, nil, "no stale click survives the dialog") +eq(imp._prevMouseDown, false, + "the polled click machine resyncs to the real button state") + +local ok, err = pcall(LauncherView.draw, imp) +check(ok, "Custom Carts draws after the cancel: " .. tostring(err)) + +local close = nil +for i = 1, (Kit._navPrevN or 0) do + local slot = Kit._nav[i] + if slot and slot.id == "cartpop-close" then close = slot end +end +check(close ~= nil, "the Close control is in the modal's nav graph") + +if close then + LauncherView.clickAt(imp, close.x + close.w / 2, close.y + close.h / 2) + ok, err = pcall(LauncherView.draw, imp) + check(ok, "the click frame draws: " .. tostring(err)) + LauncherView.update(imp, 1 / 60) + eq(imp._cartPopup, nil, "Close still closes Custom Carts after a cancel") +end + +FilePicker.open = realOpen +Platform.canSpawnProcess = realCanSpawn + +local src = io.open("src/core/FilePicker.lua", "r"):read("*a") +check(not src:match("io%.popen%("), + "FilePicker never reaches io.popen directly") +check(src:match("HostShell%.pumpHostEvents"), + "FilePicker's blocking read pumps host events afterwards") + +print("ok cart import cancel keeps the modal live") diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index 40c0adad..2616200b 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -1281,10 +1281,15 @@ check(filterText:find("Filter by category", 1, true) == nil, "not by a category no cart has") find.findBase = nil --- MODS-tab scope still applies: a cart plays as exactly one game -find.modScope = "silver" -eq(#find:_findRows(), 1, "a scoped launcher lists only that game's carts") -eq(find:_findRows()[1].id, "indexed_cart", "the one based on silver") +find.modScope = "blue" +eq(#find:_findRows(), 2, "an unfiltered cart list ignores the launcher's game") +find.findBase = "silver" +eq(#find:_findRows(), 1, "the Filter popup is what narrows it") +eq(find:_findRows()[1].id, "indexed_cart", "to that base game's carts") +find.findBase = "gold" +eq(#find:_findRows(), 1, "including a game the launcher is not scoped to") +eq(find:_findRows()[1].id, "gold_rush", "which is the point of Filter") +find.findBase = nil find.modScope = nil find:_setFindKind("mods") diff --git a/tests/engine/crystal_version_test.lua b/tests/engine/crystal_version_test.lua index 8b2c8937..b4ca44c3 100644 --- a/tests/engine/crystal_version_test.lua +++ b/tests/engine/crystal_version_test.lua @@ -238,10 +238,10 @@ local sawCrystal, sawMods, sawBug = false, false, false for _, id in ipairs(visited) do if id == "crystal" then sawCrystal = true end if id == "mods" then sawMods = true end - if id == "bug" then sawBug = true end + if id == "skins" then sawBug = true end end check(sawCrystal, "cycling the launcher tabs reaches crystal") -check(sawMods and sawBug, "and still reaches the mods and bug tabs") +check(sawMods and sawBug, "and still reaches the mods and skins tabs") local seen, cycle = {}, 0 fake.tab = "crystal" @@ -250,8 +250,8 @@ repeat fake:_cycleTab(1) cycle = cycle + 1 until fake.tab == "crystal" or cycle > 40 -eq(cycle, #GameVersion.ORDER + 4, - "the ring is the six games plus mods/find/skins/bug") +eq(cycle, #GameVersion.ORDER + 3, + "the ring is the six games plus mods/find/skins") fake.tab = "crystal" fake:_cycleTab(-1) diff --git a/tests/engine/discord_join_requested.lua b/tests/engine/discord_join_requested.lua index 650fdf33..23325f95 100644 --- a/tests/engine/discord_join_requested.lua +++ b/tests/engine/discord_join_requested.lua @@ -1,5 +1,6 @@ -- Unit coverage for event:discord.join_requested (DiscordPresence Ask-to-Join). --- Mods subscribe through Runtime; the engine emits before pushing JoinOnline. +-- Mods subscribe through Runtime; the engine emits before it hands the code +-- to the launcher's online client. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") @@ -7,28 +8,28 @@ local Runtime = require("src.mods.Runtime") local Events = require("src.mods.Events") local DiscordPresence = require("src.core.DiscordPresence") -local savedLink = package.loaded["src.link.LinkState"] -local savedTour = package.loaded["src.link.Tournament"] -package.loaded["src.link.LinkState"] = { - newJoinOnline = function(_game, code) - return { tag = "link", code = code, stage = true, net = {} } - end, -} -package.loaded["src.link.Tournament"] = { - newJoinOnline = function(_game, code) - return { tag = "tournament", code = code, stage = true, net = {} } +local joined = {} +local savedClient = package.loaded["src.online.Client"] +package.loaded["src.online.Client"] = { + joinRoom = function(code, as) + joined[#joined + 1] = { code = code, as = as } end, } -local function freshGame() +local function freshGame(withLauncher) local items = {} - return { + local game = { + returned = nil, stack = { items = items, top = function(self) return self.items[#self.items] end, push = function(self, screen) self.items[#self.items + 1] = screen end, }, } + if withLauncher ~= false then + game.returnToLauncher = function(opts) game.returned = opts end + end + return game end local bus = Events.new() @@ -50,27 +51,33 @@ do st.activity = "exploring" local seen = listen() + joined = {} DiscordPresence.handleJoinRequest("m:HOST01") T.eq(#seen, 1, "match join emits discord.join_requested") T.eq(seen[1].code, "HOST01", "payload carries the match code") T.eq(seen[1].kind, "m", "payload carries kind tag m") - T.eq(game.stack:top().tag, "link", "pushes LinkState.newJoinOnline") - T.eq(game.stack:top().code, "HOST01", "join screen gets the code") + T.eq(game.returned and game.returned.tab, "online", + "a running game returns to the launcher's online tab") + T.eq(game.returned and game.returned.joinCode, "HOST01", + "the launcher is handed the code") + T.eq(#joined, 1, "the online client is asked to join the room") + T.eq(joined[1].code, "HOST01", "...with the code") + T.eq(joined[1].as, "player", "...as a player") + T.eq(game.stack:top(), nil, "no in-game link screen is pushed any more") bus:removeOwner("discord_join_test") end do - local game = freshGame() local st = DiscordPresence._state - st.game = game + st.game = nil -- the launcher, with no game booted st.activity = "menu" local seen = listen() + joined = {} DiscordPresence.handleJoinRequest("t:TOUR99") - T.eq(#seen, 1, "tournament join emits discord.join_requested") + T.eq(#seen, 1, "a tournament secret still emits discord.join_requested") T.eq(seen[1].kind, "t", "payload carries kind tag t") - T.eq(seen[1].code, "TOUR99", "payload carries the tournament code") - T.eq(game.stack:top().tag, "tournament", "pushes Tournament.newJoinOnline") + T.eq(joined[1] and joined[1].code, "TOUR99", "it joins that room code") bus:removeOwner("discord_join_test") end @@ -81,10 +88,26 @@ do st.activity = "exploring" local seen = listen() + joined = {} DiscordPresence.handleJoinRequest("PLAIN42") T.eq(#seen, 1, "plain secret still emits discord.join_requested") T.eq(seen[1].kind, "m", "plain secret defaults to match kind") T.eq(seen[1].code, "PLAIN42", "plain secret is the whole code") + T.eq(joined[1] and joined[1].code, "PLAIN42", "and it joins that room") + bus:removeOwner("discord_join_test") +end + +do + local game = freshGame(false) -- a build with no returnToLauncher + local st = DiscordPresence._state + st.game = game + st.activity = "exploring" + local seen = listen() + + joined = {} + DiscordPresence.handleJoinRequest("m:NOWAY") + T.eq(#seen, 1, "the event still fires so a mod can act on it") + T.eq(#joined, 0, "a game that cannot reach the launcher joins nothing") bus:removeOwner("discord_join_test") end @@ -95,9 +118,10 @@ do st.activity = "battle" local seen = listen() + joined = {} DiscordPresence.handleJoinRequest("m:NOPE") T.eq(#seen, 0, "battle activity suppresses join dispatch") - T.eq(game.stack:top(), nil, "battle activity pushes no join screen") + T.eq(#joined, 0, "battle activity joins no room") bus:removeOwner("discord_join_test") end @@ -109,8 +133,10 @@ do st.activity = "exploring" local seen = listen() + joined = {} DiscordPresence.handleJoinRequest("m:NOPE") T.eq(#seen, 0, "active link session suppresses join dispatch") + T.eq(#joined, 0, "active link session joins no room") T.eq(#game.stack.items, 1, "active link session is not replaced") bus:removeOwner("discord_join_test") end @@ -118,7 +144,6 @@ end DiscordPresence._state.game = nil DiscordPresence._state.activity = "menu" Runtime.events, Runtime.hooks = savedEvents, savedHooks -package.loaded["src.link.LinkState"] = savedLink -package.loaded["src.link.Tournament"] = savedTour +package.loaded["src.online.Client"] = savedClient T.finish("discord_join_requested") diff --git a/tests/engine/fly_map_arrows_bug1892.lua b/tests/engine/fly_map_arrows_bug1892.lua new file mode 100644 index 00000000..4df37b1b --- /dev/null +++ b/tests/engine/fly_map_arrows_bug1892.lua @@ -0,0 +1,170 @@ +-- engine/items/town_map.asm:150, 167, 176, 185 (#1892) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") +local TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + PALLET_TOWN = { x = 4, y = 12, name = "PALLET TOWN" }, + VIRIDIAN_CITY = { x = 4, y = 11, name = "VIRIDIAN CITY" }, + CINNABAR_ISLAND = { x = 2, y = 15, name = "CINNABAR ISLAND" }, +} + +local function newGame() + local pressed = {} + local game = { + data = { + field = { + townMap = { + locations = LOCATIONS, + background = { + map = { 1 }, + tiles = { path = "assets/generated/townmap/tiles.png" }, + }, + }, + playerSprites = { walk = "SPRITE_RED", fly = "SPRITE_BIRD" }, + flyOrder = { "PALLET_TOWN", "VIRIDIAN_CITY", "CINNABAR_ISLAND" }, + flyWarps = { + PALLET_TOWN = { x = 1, y = 1 }, + VIRIDIAN_CITY = { x = 1, y = 1 }, + CINNABAR_ISLAND = { x = 1, y = 1 }, + }, + }, + sprites = { + SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" }, + SPRITE_BIRD = { image = "assets/generated/sprites/bird.png" }, + }, + maps = { + PALLET_TOWN = { id = "PALLET_TOWN", index = 0 }, + VIRIDIAN_CITY = { id = "VIRIDIAN_CITY", index = 1 }, + CINNABAR_ISLAND = { id = "CINNABAR_ISLAND", index = 2 }, + }, + }, + save = { + visited = { PALLET_TOWN = true, VIRIDIAN_CITY = true, + CINNABAR_ISLAND = true }, + }, + overworld = { map = { id = "PALLET_TOWN" } }, + input = { wasPressed = function(_, name) + local p = pressed[name] + pressed[name] = nil + return p + end }, + stack = { pop = function() end }, + } + return game, function(name) pressed[name] = true end +end + +local function capture(tm) + local texts, codes, draws, polys = {}, {}, {}, {} + local realDraw, realPoly = love.graphics.draw, love.graphics.polygon + local realText, realCode = Font.draw, Font.drawCode + love.graphics.draw = function(img, quadOrX, x, y) + draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y } + end + love.graphics.polygon = function(mode, ...) + polys[#polys + 1] = { mode = mode, ... } + end + Font.draw = function(text, x, y) + texts[#texts + 1] = { text = text, x = x, y = y } + return 0 + end + Font.drawCode = function(code, x, y) + codes[#codes + 1] = { code = code, x = x, y = y } + end + tm:draw() + love.graphics.draw, love.graphics.polygon = realDraw, realPoly + Font.draw, Font.drawCode = realText, realCode + return texts, codes, draws, polys +end + +local function findText(texts, want) + for _, t in ipairs(texts) do + if t.text == want then return t end + end + return nil +end + +local function upArrowAt(tm, draws, polys) + if tm.upArrow then + for _, d in ipairs(draws) do + if d.img == tm.upArrow then return d.quad end + end + return nil + end + for _, p in ipairs(polys) do + if p.mode == "fill" then return p[1] end + end + return nil +end + +local function codeAt(codes, code, x, y) + for _, c in ipairs(codes) do + if c.code == code and c.x == x and c.y == y then return true end + end + return false +end + +local game, tap = newGame() +local tm = TownMap.new(game, { fly = true, onFly = function() end }) +check(tm.fly == true, "the picker opens in fly mode") +eq(tm.mode, "grid", "the picker draws the Kanto map, not the name list") + +local texts, codes, draws, polys = capture(tm) +local to = findText(texts, "To") +check(to ~= nil, "ToText is printed on its own") +if to then eq(to.x, 0, "ToText sits at hlcoord 0, 0") end +local name = findText(texts, "PALLET TOWN") +check(name ~= nil, "the destination name is printed on its own") +if name then eq(name.x, 24, "the name sits at hlcoord 3, 0") end +check(findText(texts, "To PALLET TOWN") == nil, + "the banner is no longer one concatenated string at column 1") + +check(codeAt(codes, Theme.moreArrow, 152, 0), + "the down arrow is drawn at hlcoord 19, 0") +for _, c in ipairs(codes) do + check(c.code ~= Theme.cursor, + "the up arrow is never the $ED right-pointing cursor glyph") +end + +check(upArrowAt(tm, draws, polys) == nil, + "the up arrow is blank for the opening 15 frames") +for _ = 1, 15 do tm:update(0) end +local _, _, draws3, polys3 = capture(tm) +eq(upArrowAt(tm, draws3, polys3), 144, + "after 15 frames the up arrow is drawn at hlcoord 18, 0") + +tap("down") tm:update(0) +local _, codes4, draws4, polys4 = capture(tm) +eq(upArrowAt(tm, draws4, polys4), 144, "the up arrow stays put on DOWN") +check(not codeAt(codes4, Theme.moreArrow, 152, 0), + "the down arrow blanks while DOWN is held") +for _ = 1, 15 do tm:update(0) end +local _, codes5 = capture(tm) +check(codeAt(codes5, Theme.moreArrow, 152, 0), + "both arrows are back 15 frames later") + +tap("up") tm:update(0) +local _, codes6, draws6, polys6 = capture(tm) +check(upArrowAt(tm, draws6, polys6) == nil, "the up arrow blanks on UP") +check(codeAt(codes6, Theme.moreArrow, 152, 0), + "the down arrow stays put on UP") + +local game7 = newGame() +local tm7 = TownMap.new(game7, { fly = true, onFly = function() end }) +tm7.sel = 3 +local texts7 = capture(tm7) +local long = findText(texts7, "CINNABAR ISLAND") +check(long ~= nil, "the longest fly name is printed") +if long then + check(long.x + #"CINNABAR ISLAND" * 8 <= 144, + "a 15-column name ends before the arrow columns") +end + +T.finish("fly map arrows bug 1892") diff --git a/tests/engine/gate_fingerprint.lua b/tests/engine/gate_fingerprint.lua index 73199601..a8d62128 100644 --- a/tests/engine/gate_fingerprint.lua +++ b/tests/engine/gate_fingerprint.lua @@ -94,6 +94,19 @@ local mutations = { if id then d.move_effects[id] = { mutated = true } end end, }, + { + name = "a retuned ruleset field", + apply = function(d) + local rulesets = d.rulesets + local id = rulesets and next(rulesets) + if id then + local copy = {} + for k, v in pairs(rulesets[id]) do copy[k] = v end + copy.oneIn256Miss = not copy.oneIn256Miss + rulesets[id] = copy + end + end, + }, { name = "a link-surface constant", apply = function(d) d.constants.levelCap = d.constants.levelCap - 1 end, diff --git a/tests/engine/gate_gen2_mod_api.lua b/tests/engine/gate_gen2_mod_api.lua index ad632b2c..099ec992 100644 --- a/tests/engine/gate_gen2_mod_api.lua +++ b/tests/engine/gate_gen2_mod_api.lua @@ -381,6 +381,9 @@ local GEN2_EVENTS = { -- in the party or the box, Evolution.apply after the species swap, both -- matching the Gen 1 payload keys "pokemon.caught", "pokemon.evolved", + -- the arena battle's own result, from src/ui/ArenaState.lua and + -- src/ui/gen2/ArenaState.lua with the same five keys + "link.battle_ended", -- boot, save and the script VM "game.ready", "save.created", "save.loaded", "save.loading", "save.writing", "script.started", "script.ended", @@ -546,6 +549,10 @@ local GEN2_ONLY_EVENTS = { local GEN2_ONLY_HOOKS = { "held_item.trigger", "breeding.compatibility", "phone.contact_list", "shiny.roll", "gender.roll", + -- AI_SwitchOrTryItem's choke point. Red's AI has no switch or item branch + -- at all (src/battle/TrainerAI.lua picks a move and nothing else), so there + -- is no Gen 1 site to share the name with. + "battle.enemy_switch_or_item", } local sourceCache = {} diff --git a/tests/engine/gate_strings_coverage.lua b/tests/engine/gate_strings_coverage.lua index 7cac686c..63337c05 100644 --- a/tests/engine/gate_strings_coverage.lua +++ b/tests/engine/gate_strings_coverage.lua @@ -25,7 +25,7 @@ local WATCHED_DIRS = { } local WATCHED_FILES = { "src/link/LinkState.lua", "src/link/LinkBattle.lua", - "src/link/Tournament.lua", "src/link/Net.lua", + "src/link/Net.lua", "src/mods/ManagerState.lua", "src/import/RomImporter.lua", "src/core/DiscordPresence.lua", } diff --git a/tests/engine/gen2_anim_bake_scissor_bug1895.lua b/tests/engine/gen2_anim_bake_scissor_bug1895.lua new file mode 100644 index 00000000..82ea6860 --- /dev/null +++ b/tests/engine/gen2_anim_bake_scissor_bug1895.lua @@ -0,0 +1,53 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895, #1869) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local G = love.graphics + +local scissor = { 112, 24, 800, 720 } +local seenInCanvas = {} +G.intersectScissor = nil +G.setScissor = function(x, y, w, h) + if x then scissor = { x, y, w, h } else scissor = nil end +end +G.getScissor = function() + if scissor then return scissor[1], scissor[2], scissor[3], scissor[4] end +end + +local function drawBody(label) + seenInCanvas[label] = scissor and "clipped" or "clear" +end + +do + local BattleAnimView = require("src.ui.gen2.BattleAnimView") + local view = setmetatable({}, { __index = BattleAnimView }) + view:bake(function() drawBody("bake") end) + T.eq(seenInCanvas.bake, "clear", "the panel bakes with no window scissor") + T.eq(scissor and scissor[1], 112, "and the caller's scissor x comes back") + T.eq(scissor and scissor[4], 720, "with its height") + + scissor = nil + view:bake(function() drawBody("bake2") end) + T.eq(seenInCanvas.bake2, "clear", "a bake with no scissor stays clear") + T.eq(scissor, nil, "and leaves none behind") +end + +do + local BattleState = require("src.ui.gen2.BattleState") + scissor = { 112, 24, 800, 720 } + local state = setmetatable({ + battle = { enemy = {}, player = {} }, + animPicState = function(_, side) + if side == "enemy" then return { lifted = { 0, 7 } } end + end, + drawPic = function() drawBody("lift") end, + }, { __index = BattleState }) + state:drawLiftedRows(state.battle) + T.eq(seenInCanvas.lift, "clear", "lifted rows bake with no window scissor") + T.eq(scissor and scissor[1], 112, "and the caller's scissor comes back") +end + +T.finish("gen2 anim bake scissor bug 1895") diff --git a/tests/engine/gen2_caught_ball_palette_bug1896.lua b/tests/engine/gen2_caught_ball_palette_bug1896.lua new file mode 100644 index 00000000..53ce6907 --- /dev/null +++ b/tests/engine/gen2_caught_ball_palette_bug1896.lua @@ -0,0 +1,48 @@ +-- engine/battle_anims/anim_commands.asm:213 (#1896) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local AnimRunner = require("src.battle.gen2.AnimRunner") + +local function runnerWith(rows, palettes) + local runner = AnimRunner.new({ + animId = "ANIM_THROW_POKE_BALL", + battleTurn = 0, + data = { scripts = { click = rows } }, + }) + runner:start("click") + runner.objects.playFrame = function(objects) + objects.oam = {} + for _, name in ipairs(palettes) do + objects.oam[#objects.oam + 1] = { x = 80, y = 80, tile = 0, attr = 0, + palette = name } + end + return false + end + return runner +end + +do + local runner = runnerWith({ { "keepsprites" }, { "ret" } }, + { "PAL_BATTLE_OB_RED", "PAL_BATTLE_OB_BLUE" }) + T.eq(runner:step(), false, "keepsprites + ret ends the script the same frame") + T.eq(runner.keepSprites, true, "and BATTLEANIM_KEEPSPRITES_F is set") + T.eq(#runner:oam(), 2, "the ball's OBJs stay on screen") + for _, obj in ipairs(runner:oam()) do + T.eq(obj.palette, "PAL_BATTLE_OB_ENEMY", + "every kept OBJ is remapped onto the wild mon's palette") + end +end + +do + local runner = runnerWith({ { "ret" } }, + { "PAL_BATTLE_OB_RED", "PAL_BATTLE_OB_BLUE" }) + T.eq(runner:step(), false, "a break-free ret still ends the script") + T.eq(runner.keepSprites, false, "with no keepsprites flag") + T.eq(#runner:oam(), 0, "so BattleAnim_ClearOAM deletes the sprites instead") +end + +T.finish("gen2 caught ball palette bug 1896") diff --git a/tests/engine/gen2_caught_data_stamp_bug1886.lua b/tests/engine/gen2_caught_data_stamp_bug1886.lua new file mode 100644 index 00000000..770dd523 --- /dev/null +++ b/tests/engine/gen2_caught_data_stamp_bug1886.lua @@ -0,0 +1,220 @@ +-- engine/pokemon/caught_data.asm:72-81, :163-233 + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Breeding = require("src.core.gen2.Breeding") +local Catching = require("src.battle.gen2.Catching") +local Mon = require("src.battle.gen2.Mon") +local NpcTrade = require("src.core.gen2.NpcTrade") +local UI = require("src.ui.gen2.BattleState") +local World = require("src.world.gen2.World") + +local NEW_BARK = 1 +local ROUTE_29 = 2 + +local POKEMON = { + growthRates = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, + }, + RATTATA = { + id = "RATTATA", index = 19, name = "RATTATA", + baseStats = { hp = 30, attack = 56, defense = 35, speed = 72, + specialAttack = 25, specialDefense = 35 }, + types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, + DODRIO = { + id = "DODRIO", index = 85, name = "DODRIO", + baseStats = { hp = 60, attack = 110, defense = 70, speed = 100, + specialAttack = 60, specialDefense = 60 }, + types = { "NORMAL", "FLYING" }, catchRate = 45, baseExp = 158, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + }, +} + +local DATA = { + pokemon = POKEMON, + moves = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, + type = "NORMAL", accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" } }, + type_chart = { types = {}, matchups = {} }, + items = {}, +} + +local function newSave(version, gender) + return { + version = version, party = {}, + player = { name = "KRIS", id = 4242, gender = gender or "female" }, + pokedex = { seen = {}, caught = {} }, + } +end + +local function newWorld() + return { + tod = "NITE", + maps = { + ROUTE_29 = { id = "ROUTE_29", landmark = ROUTE_29 }, + NEW_BARK_TOWN = { id = "NEW_BARK_TOWN", landmark = NEW_BARK }, + }, + map = { def = { id = "ROUTE_29", landmark = ROUTE_29 } }, + timeOfDayId = World.timeOfDayId, + } +end + +local function newState(save, world, opts) + opts = opts or {} + return setmetatable({ + save = save, queue = {}, + battle = { timeOfDay = opts.timeOfDay }, + contest = opts.contest, + game = { save = save, data = DATA, world = world }, + }, { __index = UI }) +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld(), { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(save.party[1], enemy, "the catch joins the party") + T.eq(enemy.caughtTime, 2, "wTimeOfDay DAY stores as 2") + T.eq(enemy.caughtLevel, 4, "the level rides the same byte") + T.eq(enemy.caughtLocation, ROUTE_29, "the map header's landmark") + T.eq(enemy.caughtByGender, "girl", "wPlayerGender bit 0 on bit 7") +end + +do + local save = newSave("crystal", "male") + for i = 1, 6 do save.party[i] = { species = "RATTATA", level = 5 } end + local state = newState(save, newWorld(), { timeOfDay = 0 }) + local enemy = { species = "RATTATA", level = 7 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(#save.party, 6, "a full party sends the catch to the PC") + T.eq(enemy.caughtTime, 1, "MORN stores as 1, so 0 stays 'unknown'") + T.eq(enemy.caughtByGender, "boy", "a male player stamps CAUGHT_BY_BOY") + T.eq(enemy.caughtLocation, ROUTE_29, "and the same landmark") +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld()) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtTime, 3, "NITE stores as 3") +end + +do + local save = newSave("gold", "male") + local state = newState(save, newWorld(), { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtTime, nil, "Gold stamps no caught time") + T.eq(enemy.caughtByGender, nil, "and no OT gender") + T.eq(enemy.caughtLocation, nil, "and no landmark") +end + +do + local save = newSave("crystal", "female") + local state = newState(save, newWorld(), { timeOfDay = 1, contest = true }) + local enemy = { species = "RATTATA", level = 9 } + state:pushCaught(enemy, "PARK_BALL") + T.eq(save.bugContest.caught, enemy, "the contest only HOLDS the catch") + T.eq(enemy.caughtLocation, Catching.LANDMARK_NATIONAL_PARK, + "the location is rewritten to the park") + T.eq(enemy.caughtByGender, "girl", "and the gender bit survives the rewrite") + T.eq(enemy.caughtTime, 2, "the time byte is the plain SetCaughtData one") +end + +do + local world = newWorld() + world.map = { def = { id = "POKECENTER_2F", landmark = 0x7f } } + world.backupMapId = "NEW_BARK_TOWN" + local save = newSave("crystal", "male") + local state = newState(save, world, { timeOfDay = 1 }) + local enemy = { species = "RATTATA", level = 4 } + state:pushCaught(enemy, "POKE_BALL") + T.eq(enemy.caughtLocation, NEW_BARK, "the backup map's landmark is used") +end + +do + local world = newWorld() + local save = newSave("crystal", "female") + world.game = { save = save, data = DATA } + local opts = World.caughtDataOpts(world) + T.eq(opts.version, "crystal", "the save's version gates the stamp") + T.eq(opts.timeOfDay, 2, "NITE is wTimeOfDay 2") + T.eq(opts.landmark, ROUTE_29, "the landmark is resolved up front") + T.eq(opts.playerGender, "female", "and wPlayerGender comes along") + + world.map = { def = { id = "POKECENTER_2F", landmark = 0x7f } } + world.backupMapId = "NEW_BARK_TOWN" + T.eq(World.caughtDataOpts(world).landmark, NEW_BARK, + "with the POKECENTER_2F backup swap") +end + +do + local source = io.open("src/world/gen2/World.lua"):read("*a") + T.check(source:find("Mon.setGiftCaughtData(mon, opts.caughtBy", 1, true) + ~= nil, "the trainer arm stamps LANDMARK_GIFT") + T.check(source:find("Catching.stampCaughtData(mon, self:caughtDataOpts())", + 1, true) ~= nil, "the wild arm runs SetCaughtData") +end + +do + local save = newSave("crystal", "female") + local egg = Mon.new(DATA, "RATTATA", Breeding.EGG_LEVEL, {}) + egg.isEgg = true + egg.eggSteps = 0 + save.party[1] = egg + local world = newWorld() + world.game = { save = save, data = DATA } + local hatched = Breeding.hatch(DATA, save, 1, nil, World.caughtDataOpts(world)) + T.check(hatched ~= nil, "the egg hatches") + T.eq(hatched.caughtLevel, Mon.CAUGHT_EGG_LEVEL, "CAUGHT_EGG_LEVEL") + T.eq(hatched.caughtTime, 3, "NITE") + T.eq(hatched.caughtLocation, ROUTE_29, "the hatch site's landmark") + T.eq(hatched.caughtByGender, "girl", "and the player's gender") +end + +do + local save = newSave("crystal", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 3, dialog = "TRADE_DIALOGSET_GIRL", give = "RATTATA", + get = "DODRIO", nickname = "DORIS", dvs = { 0x77, 0x66 }, otName = "EMY", + otId = 283 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.check(received ~= nil, "the trade completes") + T.eq(received.caughtTime, 0, "a gift has no caught time") + T.eq(received.caughtLevel, 0, "and no caught level") + T.eq(received.caughtLocation, Mon.LANDMARK_GIFT, "LANDMARK_GIFT") + T.eq(received.caughtByGender, "girl", "TRADE_DIALOGSET_GIRL is CAUGHT_BY_GIRL") +end + +do + local save = newSave("crystal", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 0, dialog = "TRADE_DIALOGSET_COLLECTOR", give = "RATTATA", + get = "DODRIO", nickname = "MUSCLE", dvs = { 0x37, 0x66 }, otName = "MIKE", + otId = 37460 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.eq(received.caughtLocation, Mon.LANDMARK_GIFT, "still LANDMARK_GIFT") + T.eq(received.caughtByGender, "boy", + "CAUGHT_BY_UNKNOWN leaves the gender bit clear") +end + +do + local save = newSave("gold", "male") + save.party[1] = Mon.new(DATA, "RATTATA", 20, {}) + local row = { id = 3, dialog = "TRADE_DIALOGSET_GIRL", give = "RATTATA", + get = "DODRIO", nickname = "DORIS", dvs = { 0x77, 0x66 }, otName = "EMY", + otId = 283 } + local _, received = NpcTrade.perform(DATA, save, row, 1) + T.eq(received.caughtLocation, nil, "Gold trades stamp nothing") +end + +T.finish("gen2 caught data stamp bug 1886") diff --git a/tests/engine/gen2_evolution_stat_exp.lua b/tests/engine/gen2_evolution_stat_exp.lua new file mode 100644 index 00000000..b1061255 --- /dev/null +++ b/tests/engine/gen2_evolution_stat_exp.lua @@ -0,0 +1,105 @@ +-- engine/pokemon/evolve.asm:261-290 +package.path = "./?.lua;./?/init.lua;" .. package.path + +local S = require("tests.harness").suite("gen2 evolution stat exp") +local check, eq = S.check, S.eq + +love = require("tests.love_stub") + +local Evolution = require("src.core.gen2.Evolution") +local Mon = require("src.battle.gen2.Mon") + +local function base(hp, attack, defense, speed, spa, spd) + return { hp = hp, attack = attack, defense = defense, speed = speed, + specialAttack = spa, specialDefense = spd } +end + +local DATA = { + moves = {}, + pokemon = { + growthRates = { + MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, linear = 0, + constant = 0 }, + }, + CHIKORITA = { + name = "CHIKORITA", index = 152, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "GRASS" }, + baseStats = base(45, 49, 65, 45, 49, 65), + evolutions = { { method = "EVOLVE_LEVEL", level = 16, into = "BAYLEEF" } }, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + BAYLEEF = { + name = "BAYLEEF", index = 153, growthRate = "MEDIUM_FAST", + genderRatio = 0x1f, types = { "GRASS" }, + baseStats = base(60, 62, 80, 60, 63, 80), + evolutions = {}, + levelMoves = { { level = 1, move = "TACKLE" } }, + }, + }, +} +local LEVEL = 16 +local DVS = { attack = 12, defense = 9, speed = 7, special = 14 } +local ENTRY = DATA.pokemon.CHIKORITA.evolutions[1] + +local function newChikorita(statExp) + local mon = Mon.new(DATA, "CHIKORITA", LEVEL, { dvs = { + attack = DVS.attack, defense = DVS.defense, speed = DVS.speed, + special = DVS.special, + } }) + mon.statExp = statExp + Mon.refreshStats(mon, DATA) + return mon +end + +-- ---- non-zero stat exp ---------------------------------------------------- + +local trained = newChikorita({ + hp = 20000, attack = 15000, defense = 9000, speed = 6000, special = 12000, +}) +local before = trained.stats +local expected = Mon.stats(DATA.pokemon.BAYLEEF.baseStats, trained.dvs, LEVEL, + trained.statExp) +check(expected.hp > Mon.stats(DATA.pokemon.BAYLEEF.baseStats, trained.dvs, + LEVEL, nil).hp, "fixture stat exp raises max HP") + +trained.hp = trained.stats.hp - 7 + +local evolved = Evolution.apply(DATA, trained, ENTRY) +check(evolved ~= nil, "apply builds an evolved record") +eq(evolved.species, "BAYLEEF", "species is the evolution target") +for _, key in ipairs({ "hp", "attack", "defense", "speed", "specialAttack", + "specialDefense" }) do + eq(evolved.stats[key], expected[key], + "evolved " .. key .. " comes from the carried stat exp") +end +eq(evolved.maxHp, expected.hp, "maxHp matches the recomputed stats") +for _, key in ipairs(Mon.STAT_EXP_ORDER) do + eq(evolved.statExp[key], trained.statExp[key], + "stat exp " .. key .. " carries across") +end +eq(evolved.level, LEVEL, "level is unchanged") +eq(evolved.experience, trained.experience, "experience is unchanged") + +-- engine/pokemon/evolve.asm:274-290 +eq(evolved.hp, (before.hp - 7) + (expected.hp - before.hp), + "current HP gains the max HP delta") +eq(evolved.stats.hp - evolved.hp, 7, "the damage taken is preserved") + +-- ---- zero stat exp -------------------------------------------------------- + +local fresh = newChikorita(Mon.newStatExp()) +local freshExpected = Mon.stats(DATA.pokemon.BAYLEEF.baseStats, fresh.dvs, + LEVEL, fresh.statExp) +local freshMax = fresh.stats.hp +local freshEvolved = Evolution.apply(DATA, fresh, ENTRY) +check(freshEvolved ~= nil, "apply builds a zero stat exp record") +for _, key in ipairs({ "hp", "attack", "defense", "speed", "specialAttack", + "specialDefense" }) do + eq(freshEvolved.stats[key], freshExpected[key], + "zero stat exp " .. key .. " is unchanged") +end +eq(freshEvolved.hp, freshMax + (freshExpected.hp - freshMax), + "a full HP mon stays full after evolving") +eq(freshEvolved.hp, freshEvolved.stats.hp, "and that is its new max") + +S.finish() diff --git a/tests/engine/gen2_fly_bubble_bug1890.lua b/tests/engine/gen2_fly_bubble_bug1890.lua new file mode 100644 index 00000000..c25fe408 --- /dev/null +++ b/tests/engine/gen2_fly_bubble_bug1890.lua @@ -0,0 +1,55 @@ +-- engine/pokegear/pokegear.asm:2088 (#1890) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local Pokegear = require("src.ui.gen2.Pokegear") + +local tiles, texts, plates, cursors = {}, {}, 0, 0 + +local gear = setmetatable({ + fly = { { name = "NEW BARK TOWN" }, { name = "CHERRYGROVE CITY" } }, + flyIndex = 1, +}, { __index = Pokegear }) +gear.tile = function(_, id, tx, ty) + tiles[#tiles + 1] = { id = id, tx = tx, ty = ty } +end +gear.text = function(_, str, tx, ty) + texts[#texts + 1] = { str = str, tx = tx, ty = ty } +end +gear.drawPlate = function() plates = plates + 1 end + +local realCursor = Chrome.cursor +Chrome.cursor = function() cursors = cursors + 1 end +gear:drawFlyBubble() +Chrome.cursor = realCursor + +local function tileAt(tx, ty) + local id + for _, cell in ipairs(tiles) do + if cell.tx == tx and cell.ty == ty then id = cell.id end + end + return id +end + +check(tileAt(1, 0) == 0x30, "the top-left corner is $30") +check(tileAt(18, 0) == 0x31, "the top-right corner is $31") +check(tileAt(1, 2) == 0x32, "the bottom-left corner is $32") +check(tileAt(18, 2) == 0x33, "the bottom-right corner is $33") +check(tileAt(18, 1) == 0x34, "and the scroller is $34, the POI-red arrows") +check(tileAt(2, 0) == 0x7f and tileAt(17, 0) == 0x7f, + "spaces run between the top corners") +check(tileAt(1, 1) == 0x7f, "row 1 is spaces under the name") +check(plates == 0, "no square plate is laid under it") +check(cursors == 0, "and no menu cursor stands in for the arrows") + +check(texts[1] and texts[1].str == "Where?" + and texts[1].tx == 2 and texts[1].ty == 0, "\"Where?\" sits at (2,0)") +check(texts[2] and texts[2].str == "NEW BARK TOWN" + and texts[2].tx == 2 and texts[2].ty == 1, "the flypoint's name at (2,1)") + +T.finish("gen2 fly bubble bug 1890") diff --git a/tests/engine/gen2_fly_leaves_bug1889.lua b/tests/engine/gen2_fly_leaves_bug1889.lua new file mode 100644 index 00000000..244bc9e8 --- /dev/null +++ b/tests/engine/gen2_fly_leaves_bug1889.lua @@ -0,0 +1,84 @@ +-- pokegold engine/events/field_moves.asm:429-446 (#1889) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local World = require("src.world.gen2.World") + +local function newWorld() + return setmetatable({}, { __index = World }) +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + T.eq(#fa.leaves, 1, "the first FrameTimer tick spawns a leaf") + T.eq(fa.leaves[1].y, 0x40 - 1, + "on the $40 row, one pixel up after its own first step") + T.eq(fa.leaves[1].x, 2, "AnimSeq_FlyLeaf's `inc [hl] / inc [hl]` on XCOORD") +end + +do + local world, fa = newWorld(), { leaves = {} } + local rows = {} + for _ = 1, 32 do + local before = #fa.leaves + world:spawnFlyLeaves(fa) + if #fa.leaves > before then + rows[#rows + 1] = fa.leaves[#fa.leaves].y + 1 + end + end + T.eq(#rows, 4, "four leaves in thirty-two frames: one every eight") + T.eq(rows[1], 0x40, "`and $18 / sla a / add 8 * 8` row one is $40") + T.eq(rows[2], 0x50, "row two is $50") + T.eq(rows[3], 0x60, "row three is $60") + T.eq(rows[4], 0x70, "row four is $70, and then it wraps") +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + T.eq(fa.leaves[1].xoff, 0x40, "VAR1 starts at zero, so XOFFSET is the full $40") + for _ = 1, 16 do world:spawnFlyLeaves(fa) end + T.check(fa.leaves[1].xoff < 0x40, + "and it swings back in as VAR1 climbs") +end + +do + local world, fa = newWorld(), { leaves = {} } + world:spawnFlyLeaves(fa) + local first = fa.leaves[1] + local gone = false + for _ = 1, 200 do + world:spawnFlyLeaves(fa) + local live = false + for _, leaf in ipairs(fa.leaves) do + if leaf == first then live = true end + end + if not live then gone = true break end + T.check(first.x < 184 + 2, "a live leaf is still short of 184") + end + T.check(gone, "past the right edge DeinitializeSprite takes it") +end + +do + local world, fa = newWorld(), { leaves = {} } + for _ = 1, 400 do world:spawnFlyLeaves(fa) end + T.check(#fa.leaves <= 9, + "InitSpriteAnimStruct runs out of slots at nine leaves") +end + +do + local world = newWorld() + local from = { leaves = {} } + for _ = 1, 8 do world:spawnFlyLeaves(from) end + T.eq(world.flyLeafCounter, 8, "the counter is World's, not the animation's") + local to = { leaves = {} } + world:spawnFlyLeaves(to) + T.eq(#to.leaves, 1, "so FlyTo's frame 8 spawns rather than waiting for 0") + T.eq(to.leaves[1].y + 0, 0x50 - 1, "and on the row after FlyFrom's last") +end + +T.finish("gen2_fly_leaves_bug1889") diff --git a/tests/engine/gen2_ledge_shadow_bug1888.lua b/tests/engine/gen2_ledge_shadow_bug1888.lua new file mode 100644 index 00000000..0cb0208c --- /dev/null +++ b/tests/engine/gen2_ledge_shadow_bug1888.lua @@ -0,0 +1,86 @@ +-- pokegold engine/overworld/map_objects.asm:1995, :879-893 (#1888) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +local Player = require("src.world.gen2.Player") +local World = require("src.world.gen2.World") + +local SHEET = {} + +local function newWorld() + return setmetatable({ jumpShadowImage = SHEET, palettes = nil }, { + __index = World, + }) +end + +local function record(fn) + local G = love.graphics + local realDraw, realEllipse = G.draw, G.ellipse + local draws, ellipses = {}, 0 + G.draw = function(image, x, y, r, sx, sy) + draws[#draws + 1] = { image = image, x = x, y = y, sx = sx, sy = sy } + end + G.ellipse = function() ellipses = ellipses + 1 end + local ok, err = pcall(fn) + G.draw, G.ellipse = realDraw, realEllipse + if not ok then error(err, 0) end + return draws, ellipses +end + +do + local world = newWorld() + local jumper = { jumping = true, facing = "down", px = 32, py = 48 } + local draws = record(function() world:drawJumpShadow(jumper, 0, 0, 1) end) + T.eq(#draws, 2, "FacingShadow's `db 2` is two OAM entries, so two blits") + T.eq(draws[1].image, SHEET, "both of them the ripped JumpShadowGFX tile") + T.eq(draws[2].image, SHEET, "not an invented shape") + T.eq(draws[1].sx, 1, "the first entry draws unflipped") + T.eq(draws[2].sx, -1, "the second carries OAM_XFLIP") + T.eq(draws[1].x, 32, "at the jumper's own cell x") + T.eq(draws[2].x, 48, "and the flip hangs off the far edge of the 16px pair") + T.eq(draws[1].y, 58, "DOWN puts it 10 pixels under the ground cell's top") + T.eq(draws[2].y, 58, "both entries share the row") +end + +do + local world = newWorld() + for _, facing in ipairs({ "left", "right" }) do + local jumper = { jumping = true, facing = facing, px = 0, py = 0 } + local draws = record(function() world:drawJumpShadow(jumper, 0, 0, 1) end) + T.eq(draws[1].y, 8, + facing .. " takes MovementFunction_Shadow's 1 * TILE_WIDTH + 4 arm") + end +end + +do + local world = newWorld() + local standing = { jumping = false, facing = "down", px = 0, py = 0 } + local draws = record(function() world:drawJumpShadow(standing, 0, 0, 1) end) + T.eq(#draws, 0, "SpawnShadow only runs from JumpStep, so no hop no shadow") +end + +do + local world = newWorld() + world.jumpShadowImage = nil + local jumper = { jumping = true, facing = "down", px = 0, py = 0 } + local draws, ellipses = record(function() + world:drawJumpShadow(jumper, 0, 0, 1) + end) + T.eq(#draws, 0, "a cache from before the tile was extracted has no shadow") + T.eq(ellipses, 0, "and does not fall back to a drawn oval") +end + +do + local player = setmetatable({ + jumping = true, facing = "down", px = 0, py = 0, spriteYOffset = -12, + turnTimer = 0, moving = false, animClock = 0, + sprite = { draw = function() end }, + }, { __index = Player }) + local _, ellipses = record(function() player:draw(0, 0, 1) end) + T.eq(ellipses, 0, "the 40%-black love.graphics.ellipse placeholder is gone") +end + +T.finish("gen2_ledge_shadow_bug1888") diff --git a/tests/engine/gen2_new_seams.lua b/tests/engine/gen2_new_seams.lua index 39a3ed56..ad1835e8 100644 --- a/tests/engine/gen2_new_seams.lua +++ b/tests/engine/gen2_new_seams.lua @@ -650,6 +650,76 @@ end -- ------- the mod-free state is restored +-- ------- battle.enemy_switch_or_item +-- +-- AI_SwitchOrTryItem's whole choke point (src/battle/gen2/Battle.lua), the +-- companion to battle.enemy_action: enemy_action rewrites which MOVE the foe +-- picks, this one decides whether the foe spends the turn on a rotation or an +-- item instead of moving at all. Vanilla answers a boolean; a mod may answer +-- a { kind = "switch", index } or a { kind = "item", item } action. + +do + local Battle = require("src.battle.gen2.Battle") + local Mon = require("src.battle.gen2.Mon") + local data = { + pokemon = DATA.pokemon, + moves = DATA.moves, + items = { POTION = { id = "POTION", name = "POTION" } }, + type_chart = { types = {}, matchups = {} }, + } + local function fighter(level) + local mon = Mon.new(data, "SEEDMON", level, + { dvs = { attack = 15, defense = 15, speed = 15, special = 15 } }) + mon.moves = { { id = "SEED_TACKLE", pp = 35, maxPp = 35 } } + return mon + end + local roster = { fighter(10), fighter(12) } + local battle = Battle.new({ + data = data, + random = function() return 0 end, + party = { fighter(10) }, + trainer = { name = "FOE", party = roster, items = { "POTION" } }, + }) + + -- No TRNATTR_AI flags: vanilla never rotates and never drinks. + T.eq(battle:enemyTrySwitchOrItem(), false, + "battle.enemy_switch_or_item's vanilla refuses a flagless trainer") + + local ctxSeen + withHook("battle.enemy_switch_or_item", function(nextFn, b) + ctxSeen = b + return nextFn(b) + end, function() + T.eq(battle:enemyTrySwitchOrItem(), false, + "battle.enemy_switch_or_item passes vanilla's answer through") + end) + T.eq(ctxSeen, battle, "battle.enemy_switch_or_item is handed the battle") + + withHook("battle.enemy_switch_or_item", function() + return { kind = "switch", index = 2 } + end, function() + T.eq(battle:enemyTrySwitchOrItem(), true, + "battle.enemy_switch_or_item can rotate the foe") + T.eq(battle.enemyIndex, 2, "...to the slot the action names") + T.eq(battle.enemy, roster[2], "...and the battler follows") + end) + + battle.enemy.hp = 1 + withHook("battle.enemy_switch_or_item", function() + return { kind = "item", item = "POTION" } + end, function() + T.eq(battle:enemyTrySwitchOrItem(), true, + "battle.enemy_switch_or_item can spend the turn on an item") + end) + T.check(battle.enemy.hp > 1, "...and the item's effect lands") + T.eq(#battle.trainer.items, 0, "...consuming it from the roster") + + withHook("battle.enemy_switch_or_item", function() return nil end, function() + T.eq(battle:enemyTrySwitchOrItem(), false, + "battle.enemy_switch_or_item can refuse both") + end) +end + for _, name in ipairs({ "intro.boot.copyright", "intro.boot.gamefreak", "intro.boot.movie", "intro.boot.movie_ended", "intro.boot.title", @@ -667,7 +737,8 @@ end -- same residue gate_events.lua documents for the event bus, so the check is on -- the chain's contents rather than on wantsHook. for _, name in ipairs({ "held_item.trigger", "breeding.compatibility", - "phone.contact_list", "shiny.roll", "gender.roll" }) do + "phone.contact_list", "shiny.roll", "gender.roll", + "battle.enemy_switch_or_item" }) do T.eq(#(hooks.chains[name] or {}), 0, "every hook case unwrapped: " .. name) end diff --git a/tests/engine/gen2_pack_sfx_bug1893.lua b/tests/engine/gen2_pack_sfx_bug1893.lua new file mode 100644 index 00000000..a660f1f1 --- /dev/null +++ b/tests/engine/gen2_pack_sfx_bug1893.lua @@ -0,0 +1,132 @@ +-- engine/menus/scrolling_menu.asm:23 (#1893) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local PackMenu = require("src.ui.gen2.PackMenu") +local Save = require("src.core.gen2.Save") +local Sound = require("src.core.Sound") + +local ITEMS = { + POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 17, + canToss = true, canSelect = false, fieldMenu = "ITEMMENU_PARTY" }, + ESCAPE_ROPE = { id = "ESCAPE_ROPE", name = "ESCAPE ROPE", pocket = "ITEM", + index = 29, canToss = true, canSelect = true, + fieldMenu = "ITEMMENU_CLOSE" }, +} + +local played = {} +local realPlay = Sound.play +Sound.play = function(_data, name) played[#played + 1] = name end + +local function newInput() + local input = { pressed = {} } + function input:press(...) + for _, button in ipairs({ ... }) do self.pressed[button] = true end + end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function openPack() + local save = Save.newGame() + save.inventory = { POTION = 5, ESCAPE_ROPE = 1 } + save.bagOrder = { "POTION", "ESCAPE_ROPE" } + local game = { + input = newInput(), + save = save, + options = save.options, + data = { items = ITEMS, moves = {}, pokemon = {}, audio = { sfx = { + Sfx_ReadText2 = true, + Sfx_SwitchPockets = true, + Sfx_SwitchPokemon = true, + } } }, + stack = { push = function() end, pop = function() end }, + } + local pack = PackMenu.new(game, { save = save, pocket = "ITEM", + onClose = function() end, + world = { useFieldItem = function() end } }) + pack.gfx = { available = function() return false end, draw = function() end, + colorsAt = function() return nil end } + played = {} + return pack, game +end + +local function last() + return played[#played] +end + +do + local pack, game = openPack() + game.input:press("right") + pack:update(0) + eq(pack:pocket().id, "BALL", "right moves on to the next pocket") + eq(last(), "Sfx_SwitchPockets", "and rings the pocket cue") +end + +do + local pack, game = openPack() + game.input:press("down") + pack:update(0) + eq(#played, 0, "down the list is silent") + game.input:press("up") + pack:update(0) + eq(#played, 0, "so is up") + + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_ReadText2", "A on a row clicks") + check(pack.submenu ~= nil, "and opens the item submenu") + + game.input:press("b") + pack:update(0) + eq(last(), "Sfx_ReadText2", "B out of the submenu clicks too") +end + +do + local pack, game = openPack() + pack.index = 1 + game.input:press("select") + pack:update(0) + eq(#played, 0, "SELECT arming a move is silent") + eq(pack.switching, 1, "and the row is held") + + game.input:press("down") + pack:update(0) + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_SwitchPokemon", "putting it down rings the swap cue") + eq(pack.switching, nil, "and the move ends") +end + +do + local pack, game = openPack() + pack.index = 1 + game.input:press("select") + pack:update(0) + game.input:press("a") + pack:update(0) + eq(last(), "Sfx_SwitchPokemon", "placing it back on its own row still rings") +end + +do + local pack, game = openPack() + game.data.audio.sfx = {} + game.input:press("right") + pack:update(0) + eq(#played, 0, "a cache without the cue simply makes no sound") +end + +Sound.play = realPlay + +T.finish("gen2 pack sfx bug 1893") diff --git a/tests/engine/gen2_pokedex_area_landmark_bug1267.lua b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua index 8791c325..dd3e5fc9 100644 --- a/tests/engine/gen2_pokedex_area_landmark_bug1267.lua +++ b/tests/engine/gen2_pokedex_area_landmark_bug1267.lua @@ -1,7 +1,4 @@ --- Gold #DEX AREA page drew no nest markers or landmark name because --- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of --- the gen2Landmarks table Nests already resolves through (#1267). --- engine/pokegear/pokegear.asm:2427 +-- engine/pokegear/pokegear.asm:2285 (#1267) -- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -10,6 +7,7 @@ local T = require("tests.harness") local check, eq = T.check, T.eq love = love or require("tests.love_stub") +local Chrome = require("src.ui.gen2.Chrome") local PokedexMenu = require("src.ui.gen2.PokedexMenu") local Nests = require("src.core.gen2.Nests") @@ -35,54 +33,118 @@ local data = { eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30", "Nests.landmark resolves index 5 to the ROUTE 30 record") --- capture what drawArea actually paints, without needing a real tile sheet --- or font: fill/blank/current/monName are stubbed on the instance, which --- Lua resolves before the PokedexMenu metatable's own methods. -local function newSelf() - local texts = {} - local rects = {} +local function newSelf(species, region) + local seen = { texts = {}, inverted = {}, header = {}, icons = {} } local self = setmetatable({ - game = { save = {} }, + game = { save = { position = { map = "ROUTE_30" } } }, data = data, - mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet - areaRegion = "johto", - areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase - current = function() return { species = "RATTATA" } end, - monName = function() return "RATTATA" end, + mapGfx = { maps = { johto = { 1 }, kanto = { 1 } } }, + areaRegion = region, + areaBlink = 0, + current = function() return { species = species or "RATTATA" } end, + monName = function() return species or "RATTATA" end, fill = function() end, blank = function() end, + drawTilemap = function() end, text = function(_, str, tx, ty) - texts[#texts + 1] = { str = str, tx = tx, ty = ty } + seen.texts[#seen.texts + 1] = { str = str, tx = tx, ty = ty } + end, + drawAreaHeader = function(_, title) + seen.header[#seen.header + 1] = title + end, + drawNestIcon = function(_, x, y) + seen.icons[#seen.icons + 1] = { x = x, y = y } end, }, { __index = PokedexMenu }) - return self, texts, rects + return self, seen +end + +local function drawText(seen) + local out = {} + for _, t in ipairs(seen.texts) do out[#out + 1] = t.str end + return table.concat(out, "|") end -local realRect = love.graphics.rectangle -local self, texts, rects do - self, texts, rects = newSelf() - love.graphics.rectangle = function(mode, x, y, w, h) - rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } - end + local self, seen = newSelf("RATTATA", "johto") self:drawArea() - love.graphics.rectangle = realRect + eq(#seen.icons, 1, "the one Johto nest gets one marker") + local icon = seen.icons[1] + eq(icon and icon.x, 40 - 4, "the marker sits four pixels left of the landmark") + eq(icon and icon.y, 60 - 4, "and four pixels above it") + eq(seen.header[1], "RATTATA'S NEST", "the only string is the nest caption") + eq(drawText(seen), "", "nothing is printed in the dex's inverted font") + + local blinkOff = 0 + for frame = 0, 31 do + local one, s2 = newSelf("RATTATA", "johto") + one.areaBlink = frame + one:drawArea() + if #s2.icons == 0 then blinkOff = blinkOff + 1 end + end + eq(blinkOff, 16, "the marker is hidden for sixteen of every thirty-two frames") end -local function hasRect(x, y) - for _, r in ipairs(rects) do - if r.x == x and r.y == y then return true end - end - return false +do + local self, seen = newSelf("RATTATA", "kanto") + self:drawArea() + eq(#seen.icons, 0, "no Kanto nest, so nothing blinks") + eq(drawText(seen), "", "and no AREA UNKNOWN over the Kanto map") + eq(seen.header[1], "RATTATA'S NEST", "the caption stays either way") end -check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2") -local function hasText(str) - for _, t in ipairs(texts) do - if t.str == str then return true end +do + local writes = {} + local realThrough = Chrome.printThrough + Chrome.printThrough = function(str, tx, ty) + writes[#writes + 1] = { str = str, tx = tx, ty = ty } end - return false + local self = newSelf("RATTATA", "johto") + self.drawAreaHeader = nil + self:drawAreaHeader("RATTATA'S NEST") + Chrome.printThrough = realThrough + eq(#writes, 1, "the caption is placed once") + eq(writes[1] and writes[1].tx, 2, "at hlcoord 2") + eq(writes[1] and writes[1].ty, 0, "on row 0") +end + +local function newInput() + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + return input +end + +do + local self = newSelf("RATTATA", nil) + self.game.save.position.map = "ROUTE_30" + eq(self:areaRegionName(), "johto", + "the page opens on Johto whatever the player is standing in") + + local input = newInput() + input:press("right") + self:updateArea(input) + eq(self:areaRegionName(), "johto", + "right does nothing before the Hall of Fame") + + self.game.save.hallOfFame = { count = 1 } + input:press("right") + self:updateArea(input) + eq(self:areaRegionName(), "kanto", "and swaps to Kanto once it has been rung") + + input:press("left") + self:updateArea(input) + eq(self:areaRegionName(), "johto", "left always comes back") + + input:press("b") + self:updateArea(input) + eq(self.view, "entry", "B returns to the entry") end -check(hasText("ROUTE 30"), "the landmark name is printed on row 16") T.finish("gen2 pokedex area landmark bug 1267") diff --git a/tests/engine/gen2_pokegear_page_bug1884.lua b/tests/engine/gen2_pokegear_page_bug1884.lua new file mode 100644 index 00000000..8a3242eb --- /dev/null +++ b/tests/engine/gen2_pokegear_page_bug1884.lua @@ -0,0 +1,81 @@ +-- engine/pokegear/pokegear.asm:454, :740 (#1884) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Pokegear = require("src.ui.gen2.Pokegear") + +local function newInput() + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +local function newGear(ids) + local input = newInput() + local cards = {} + for index, id in ipairs(ids) do cards[index] = { id = id, label = id } end + local gear = setmetatable({ + cards = cards, cardIndex = 1, mode = "strip", station = 1, + game = { input = input }, + }, { __index = Pokegear }) + gear.updatePhone = function() end + gear.stepMapCursor = function() end + gear.ensureTuned = function() end + gear.tickRadio = function() end + gear.tuneRadio = function() end + gear.stopRadio = function() end + return gear, input +end + +local function press(gear, input, button) + input:press(button) + gear:update(0) +end + +local gear, input = newGear({ "clock", "phone" }) +check(gear:card().id == "clock", "the gear opens on the clock") +press(gear, input, "right") +check(gear:card().id == "phone", "right takes the phone") +gear:update(0) +check(gear.mode == "card", "which enters its card without an A") +press(gear, input, "left") +check(gear:card().id == "clock", "left goes back to the clock") +press(gear, input, "right") +check(gear:card().id == "phone", "and the clock still pages right") + +local full, fullInput = newGear({ "clock", "map", "phone", "radio" }) +full.cardIndex, full.mode = 3, "card" +press(full, fullInput, "left") +check(full:card().id == "map", "the phone's left takes the map first") +press(full, fullInput, "right") +check(full:card().id == "phone", "and the map pages back") +press(full, fullInput, "right") +check(full:card().id == "radio", "the phone's right takes the radio") +press(full, fullInput, "left") +check(full:card().id == "phone", "and the radio's left takes it back") + +local radioOnly, radioInput = newGear({ "clock", "radio" }) +radioOnly.mode = "card" +press(radioOnly, radioInput, "right") +check(radioOnly:card().id == "radio", "the clock falls through to the radio") +press(radioOnly, radioInput, "left") +check(radioOnly:card().id == "clock", "and the radio falls back to the clock") + +local left, leftInput = newGear({ "clock", "map" }) +left.mode = "card" +press(left, leftInput, "left") +check(left:card().id == "clock", "left does nothing on the clock") + +T.finish("gen2 pokegear paging bug 1884") diff --git a/tests/engine/gen2_pokegear_paper_bug1885.lua b/tests/engine/gen2_pokegear_paper_bug1885.lua new file mode 100644 index 00000000..c8507c40 --- /dev/null +++ b/tests/engine/gen2_pokegear_paper_bug1885.lua @@ -0,0 +1,85 @@ +-- engine/pokegear/pokegear.asm (#1885) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local Chrome = require("src.ui.gen2.Chrome") +local Pokegear = require("src.ui.gen2.Pokegear") + +local CREAM = { 224, 248, 160 } +local PALS = { { CREAM, { 168, 168, 168 }, { 84, 84, 84 }, { 0, 0, 0 } } } + +local runs, cursors, plain + +local function spy(fn) + runs, cursors, plain = {}, {}, 0 + local print_, through, cursor, cursorThrough = + Chrome.print, Chrome.printThrough, Chrome.cursor, Chrome.cursorThrough + Chrome.print = function() plain = plain + 1 end + Chrome.printThrough = function(text, tx, ty, palette) + runs[#runs + 1] = { text = text, tx = tx, ty = ty, palette = palette } + end + Chrome.cursor = function() plain = plain + 1 end + Chrome.cursorThrough = function(tx, ty, palette) + cursors[#cursors + 1] = { tx = tx, ty = ty, palette = palette } + end + local ok, err = pcall(fn) + Chrome.print, Chrome.printThrough = print_, through + Chrome.cursor, Chrome.cursorThrough = cursor, cursorThrough + if not ok then error(err, 0) end +end + +local function newGear() + local gear = setmetatable({}, { __index = Pokegear }) + gear.pals = function() return PALS end + gear.paperColor = function() return CREAM end + return gear +end + +local gear = newGear() +spy(function() gear:printBoxText("Press any button to exit.") end) +check(#runs == 2, "the exit prompt is two runs") +check(runs[1] and runs[1].text == "Press any button", "the first line as printed") +check(runs[1] and runs[1].tx == 1 and runs[1].ty == 14, "at (1,14)") +check(runs[2] and runs[2].ty == 16, "and the second two rows under it") +check(runs[1] and runs[1].palette == PALS[1], "both on the gear's cream paper") +check(runs[2] and runs[2].palette == PALS[1], "not Chrome's white box palette") +check(plain == 0, "and nothing goes through the white printer") + +local clock = newGear() +clock.clockParts = function() return 15, 47, 5 end +clock.drawTilemap = function() end +clock.drawStrip = function() end +clock.textbox = function() end +clock.phoneText = function() return "Press any button to exit." end +spy(function() clock:drawClock() end) +check(runs[1] and runs[1].text == " SWITCH" and runs[1].tx == 12, + "SWITCH is placed at column 12 with its leading space") +check(#cursors == 1 and cursors[1].tx == 19 and cursors[1].ty == 1, + "its arrow sits at (19,1)") +check(cursors[1] and cursors[1].palette == PALS[1], + "and wears the cream paper too") +check(plain == 0, "no white cell anywhere on the clock card") + +local radio = newGear() +radio.ensureTuned = function() end +radio.drawTilemap = function() end +radio.drawStrip = function() end +radio.drawTuningKnob = function() end +radio.textbox = function() end +radio.currentStation = function() + return { name = "OAK's PKMN Talk", station = "OAKS_POKEMON_TALK" } +end +radio.radio = { top = "... ...Ahem, we are", bottom = "TEAM ROCKET!" } +spy(function() radio:drawRadio() end) +check(runs[2] and runs[2].text == "... ...Ahem, we are" and runs[2].ty == 14, + "the show's top line lands on row 14") +check(runs[3] and runs[3].text == "TEAM ROCKET!" and runs[3].ty == 16, + "and the bottom line on row 16") +check(runs[2] and runs[2].palette == PALS[1], "on cream, like the station name") +check(plain == 0, "with no white bar behind the speech") + +T.finish("gen2 pokegear paper bug 1885") diff --git a/tests/engine/gen2_radio_rocket_flag_bug1871.lua b/tests/engine/gen2_radio_rocket_flag_bug1871.lua new file mode 100644 index 00000000..094c2fa3 --- /dev/null +++ b/tests/engine/gen2_radio_rocket_flag_bug1871.lua @@ -0,0 +1,55 @@ +-- engine/pokegear/radio.asm:1 (#1871) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +local FlagNames = require("src.core.gen2.FlagNames") +local Pokegear = require("src.ui.gen2.Pokegear") + +local TOWER = FlagNames.engine.ENGINE_ROCKETS_IN_RADIO_TOWER +local SIGNAL = FlagNames.engine.ENGINE_ROCKET_SIGNAL_ON_CH20 + +local function newGear(save, world) + local gear = setmetatable({ + save = save, + game = { world = world }, + landmarks = { landmarks = {} }, + }, { __index = Pokegear }) + gear.region = function() return "johto" end + gear.hiddenPeople = function() return {} end + gear.radioWeekday = function() return 1 end + gear.timeOfDayIndex = function() return 1 end + return gear +end + +check(TOWER == 18, "ENGINE_ROCKETS_IN_RADIO_TOWER is gold id 18") +check(SIGNAL == 14, "ENGINE_ROCKET_SIGNAL_ON_CH20 is gold id 14") + +local armed = newGear({ engineFlags = { [TOWER] = true } }) +check(armed:radioData().rocketsInRadioTower == true, + "the takeover reads straight off save.engineFlags") + +local quiet = newGear({ engineFlags = {} }) +check(quiet:radioData().rocketsInRadioTower == false, + "and stays off while the tower is clear") + +local legacy = newGear({ flags = { ROCKETS_IN_RADIO_TOWER = true } }) +check(legacy:radioData().rocketsInRadioTower == false, + "save.flags is not the takeover's store") + +local crystal = newGear({ engineFlags = { [TOWER + 1] = true } }, { + engineFlagId = function(_, name) + return name == "ENGINE_ROCKETS_IN_RADIO_TOWER" and TOWER + 1 or nil + end, +}) +check(crystal:radioData().rocketsInRadioTower == true, + "and Crystal's shifted id resolves through the world") + +local rage = newGear({ engineFlags = { [SIGNAL] = true } }) +check(rage:radioContext().rocketSignal == true, + "the Lake of Rage signal reads the same store") + +T.finish("gen2 radio rocket flag bug 1871") diff --git a/tests/engine/gen2_save_import.lua b/tests/engine/gen2_save_import.lua index a53190ca..abfa88f9 100644 --- a/tests/engine/gen2_save_import.lua +++ b/tests/engine/gen2_save_import.lua @@ -65,8 +65,7 @@ local function pack(b) return table.concat(out) end --- A save with one known Pokemon in the party and one in box 3. -local function build(version) +local function build(version, bare) local L = Gen2Save.layoutFor(version) local b = blank() putName(b, L.wPlayerName, "ASH") @@ -81,6 +80,7 @@ local function build(version) put(b, mon, 155) -- species put(b, mon + 1, 0) -- no held item put(b, mon + 2, 33, 43, 0, 0) -- two moves + put(b, mon + 0x17, 0x63, 40, 0, 0) -- 35 PP with one PP Up, then 40 put(b, mon + 6, 0x12, 0x34) -- OT id put(b, mon + 0x15, 0x9F, 0x6A) -- DVs: a=9 d=15 s=6 sp=10 put(b, mon + 0x1B, 200) -- happiness @@ -92,6 +92,10 @@ local function build(version) putName(b, L.wPartyMonNicknames, "FLAME") putName(b, L.wPartyMonOTs, "ASH") put(b, L.wMapGroup, 21); put(b, L.wMapNumber, 14) + if not bare then + put(b, L.wStatusFlags, 0x01) -- STATUSFLAGS_POKEDEX_F + put(b, L.wPokegearFlags, 0x81) -- POKEGEAR_OBTAINED_F + map card + end put(b, L.wNumItems, 1); put(b, L.wItems, 20, 3); put(b, L.wItems + 2, 0xFF) put(b, L.wNumKeyItems, 1); put(b, L.wKeyItems, 7); put(b, L.wKeyItems + 1, 0xFF) put(b, L.wNumBalls, 1); put(b, L.wBalls, 5, 9); put(b, L.wBalls + 2, 0xFF) @@ -144,6 +148,15 @@ for _, version in ipairs({ "gold", "silver", "crystal" }) do eq(save.boxes[3][1].nickname, "SQUIRT", version .. ": box nicknames follow the OTs") eq(save.boxes[3][1].ot, "ASH", version .. ": box OT") eq(save.boxes[3][1].hp, nil, version .. ": a box mon carries no computed stats") + -- data/events/engine_flags.asm (#1900) + eq(save.engineFlags[11], true, version .. ": ENGINE_POKEDEX out of wStatusFlags") + eq(save.engineFlags[4], true, version .. ": ENGINE_POKEGEAR out of wPokegearFlags") + eq(save.engineFlags[1], true, version .. ": the map card is engine flag 1") + eq(save.engineFlags[0], nil, version .. ": the radio card bit is clear") + local bare = assert(Gen2Save.decode(build(version, true), version)) + eq(bare.engineFlags[11], nil, + version .. ": a save from before Mr. Pokemon's house has no dex flag") + eq(bare.engineFlags[4], nil, version .. ": nor a POKEGEAR") end end @@ -157,7 +170,8 @@ end local CROSSWALK = { pokemon = { CYNDAQUIL = { index = 155 }, SQUIRTLE = { index = 7 } }, - moves = { TACKLE = { index = 33 }, LEER = { index = 43 } }, + moves = { TACKLE = { index = 33, pp = 35, name = "TACKLE" }, + LEER = { index = 43, pp = 30, name = "LEER" } }, items = { POTION = { index = 20 }, BICYCLE = { index = 7 }, POKE_BALL = { index = 5 } }, maps = { GOLDENROD_CITY = { group = 21, map = 14 } }, @@ -167,8 +181,13 @@ do local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK)) local m = save.party[1] eq(m.species, "CYNDAQUIL", "species is the engine's id, not the cart's number") - eq(m.moves[1], "TACKLE", "and so are moves") - eq(m.moves[2], "LEER", "both of them") + eq(m.moves[1].id, "TACKLE", "and so are moves") + eq(m.moves[2].id, "LEER", "both of them") + -- engine/pokemon/mon_stats.asm (#1899) + eq(m.moves[1].pp, 35, "a move slot carries the PP the cart stored") + eq(m.moves[1].ppUps, 1, "and its PP Ups, out of the top two bits") + eq(m.moves[1].maxPp, 42, "max PP is the base plus one PP Up's bonus") + eq(m.pp, nil, "and there is no parallel PP array beside it") eq(save.boxes[3][1].species, "SQUIRTLE", "boxes translate too") -- One flat bag. Nothing in src reads save.keyItems or save.balls; PackMenu @@ -205,6 +224,9 @@ end do local save = assert(Gen2Save.decode(build("gold"), "gold")) eq(save.party[1].species, 155, "an unknown species keeps its cart number") + eq(save.party[1].moves[1].id, 33, "and an unnamed move keeps its own") + eq(save.party[1].moves[1].maxPp, nil, + "with no move table there is no base PP to grow a max out of") end -- ------------------------------------------------------------------ @@ -236,6 +258,8 @@ do save.party[1].pokerus = 0x34 save.party[1].caughtData = 0x1234 save.events[9] = 0xA5 + save.party[1].moves[1].pp = 12 + save.engineFlags[11] = nil local out = assert(Gen2Save.encode(save, "gold", cart, data)) eq(#out, #cart, "the image keeps its size") @@ -257,6 +281,54 @@ do eq(back.party[1].status, "slp", "status survives as a class") eq(back.party[1].statusTurns, 3, "with its turn count") eq(back.events[9], 0xA5, "event bytes are written") + + eq(back.party[1].moves[1].id, "TACKLE", "a move slot survives the round trip") + eq(back.party[1].moves[1].pp, 12, "with the PP it was spent down to") + eq(back.party[1].moves[1].ppUps, 1, "and its PP Ups still in the top bits") + -- engine/menus/save.asm + eq(back.engineFlags[11], nil, "a cleared engine flag clears its SRAM bit") + eq(back.engineFlags[4], true, "and the ones left alone stay set") +end + +-- engine/pokemon/stats_screen.asm (#1899) + +do + local SummaryMenu = require("src.ui.gen2.SummaryMenu") + local save = assert(Gen2Save.decode(build("gold"), "gold", CROSSWALK)) + local view = setmetatable( + { mon = save.party[1], moves = CROSSWALK.moves, items = CROSSWALK.items, + party = {}, page = SummaryMenu.GREEN_PAGE }, + { __index = SummaryMenu }) + local rows = {} + for _, row in ipairs(view:greenPlacements()) do + rows[#rows + 1] = row.text + end + local text = table.concat(rows, "|") + check(text:find("TACKLE", 1, true) ~= nil, + "the green page names the imported move -- got: " .. text) + check(text:find("35", 1, true) ~= nil and text:find("42", 1, true) ~= nil, + "and prints its PP over its max rather than 0/ 0 -- got: " .. text) +end + +-- engine/menus/start_menu.asm (#1900) +do + local StartMenu = require("src.ui.gen2.StartMenu") + local imported = assert(SaveConvert.importSav(build("gold"), "gold", "gold")) + local menu = setmetatable({ save = imported }, { __index = StartMenu }) + local rows = {} + for _, item in ipairs(menu:visibleItems()) do rows[#rows + 1] = item.value end + local list = table.concat(rows, ",") + check(list:find("pokedex", 1, true) ~= nil, + "an imported save shows #DEX on the START menu -- got: " .. list) + check(list:find("pokegear", 1, true) ~= nil, + "and the POKEGEAR row too -- got: " .. list) + + local before = assert(SaveConvert.importSav(build("gold", true), "gold", "gold")) + local early = setmetatable({ save = before }, { __index = StartMenu }) + local earlyRows = {} + for _, item in ipairs(early:visibleItems()) do earlyRows[#earlyRows + 1] = item.value end + check(table.concat(earlyRows, ","):find("pokedex", 1, true) == nil, + "and a save from before the dex was given still hides it") end -- A save with no cartridge image behind it is refused, not invented. diff --git a/tests/engine/gen2_scy_deform_blit_bug1895.lua b/tests/engine/gen2_scy_deform_blit_bug1895.lua new file mode 100644 index 00000000..5482f0f7 --- /dev/null +++ b/tests/engine/gen2_scy_deform_blit_bug1895.lua @@ -0,0 +1,77 @@ +-- engine/battle_anims/bg_effects.asm:2638 (#1895) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local SCREEN_H = BattleAnimView.SCREEN_H + +local function bg(fields) + local out = { scx = 0, scy = 0, lyStart = 0, lyEnd = 0, lyBackup = {} } + for k, v in pairs(fields) do out[k] = v end + return out +end + +local function byRow(lines) + local map = {} + for _, line in ipairs(lines) do map[line.dest] = line end + return map +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) + T.eq(lines[10] and lines[10].src, 12, "rSCY 2 on LY 10 samples BG row 12") + T.eq(lines[10] and lines[10].dest, 10, "and still draws on scanline 10") + T.eq(lines[10] and lines[10].dx, 0, "rSCY never moves the row sideways") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCX", lyStart = 0, lyEnd = 0x36, lyBackup = { [10] = 2 } }))) + T.eq(lines[10] and lines[10].src, 10, "rSCX leaves the sampled row alone") + T.eq(lines[10] and lines[10].dx, -2, "and shifts the destination x instead") +end + +do + local backup = {} + for row = 0, 0x35 do + backup[row] = ({ 0, 1, 2, 3, 2, 1, 0, 0xff, 0xfe, 0xfd, 0xfe, 0xff }) + [(row % 12) + 1] + end + local lines = BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, lyBackup = backup })) + local seen = {} + for _, line in ipairs(lines) do + T.check(not seen[line.dest], "scanline " .. line.dest .. " is drawn once") + seen[line.dest] = true + T.check(line.src >= 0 and line.src < SCREEN_H, + "scanline " .. line.dest .. " samples inside the panel") + end + local gaps = 0 + for row = 3, 0x32 do + if not seen[row] then gaps = gaps + 1 end + end + T.eq(gaps, 0, "a per-scanline rSCY sine leaves no blank rows in the window") +end + +do + local lines = byRow(BattleAnimView.scanlines( + bg({ lcdc = "SCY", lyStart = 0, lyEnd = 0x36, + lyBackup = { [10] = 0x90, [120] = 0x90 } }))) + T.eq(lines[10], nil, "a $90 row inside the window is skipped") + T.check(lines[120] ~= nil, "a $90 row outside the window still draws") +end + +do + local lines = byRow(BattleAnimView.scanlines(bg({ scy = 4 }))) + T.eq(lines[0] and lines[0].src, 4, "hSCY 4 puts BG row 4 on scanline 0") + T.eq(lines[0] and lines[0].dest, 0, "with the destination untouched") + T.eq(lines[SCREEN_H - 1], nil, + "and the rows that would sample past the panel are left blank") +end + +T.finish("gen2 scy deform blit bug 1895") diff --git a/tests/engine/gen2_widescreen_clip_bug1869.lua b/tests/engine/gen2_widescreen_clip_bug1869.lua new file mode 100644 index 00000000..ff3952b2 --- /dev/null +++ b/tests/engine/gen2_widescreen_clip_bug1869.lua @@ -0,0 +1,89 @@ +-- engine/events/halloffame.asm:270 (#1869) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local G = love.graphics + +local scissors = {} +G.intersectScissor = nil +G.setScissor = function(x, y, w, h) + scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } +end + +local Chrome = require("src.ui.gen2.Chrome") + +local WIN_W, WIN_H = 1280, 840 +local PANEL_W, PANEL_H = Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8 + +local function only(label) + T.eq(#scissors, 1, label .. " sets exactly one scissor") + local rect = scissors[1] + scissors = {} + return rect or {} +end + +local function checkPanel(label, scale) + local rect = only(label) + local ox, oy = Chrome.fitOrigin(WIN_W, WIN_H, scale) + T.eq(rect.x, ox, label .. " clips from the panel's own origin x") + T.eq(rect.y, oy, label .. " clips from the panel's own origin y") + T.eq(rect.w, PANEL_W * scale, label .. " clips to the scaled panel width") + T.eq(rect.h, PANEL_H * scale, label .. " clips to the scaled panel height") +end + +do + local scale = Chrome.fitScale(WIN_W, WIN_H) + T.check(scale >= 1, "1280x840 fits at least one whole GB pixel per window pixel") + local drew = false + Chrome.withPanel(WIN_W, WIN_H, 0, 0, 0, function() drew = true end) + T.eq(drew, true, "Chrome.withPanel runs the panel body") + checkPanel("Chrome.withPanel", scale) + + Chrome.withPanel(WIN_W, WIN_H, 1, 1, 1, function() end, 3) + checkPanel("an explicit panel scale", 3) + + drew = false + Chrome.withClip(function() drew = true end) + T.eq(drew, true, "Chrome.withClip runs the panel body") + local rect = only("Chrome.withClip") + T.eq(rect.x, 0, "the 1:1 clip starts at the panel origin") + T.eq(rect.y, 0, "on both axes") + T.eq(rect.w, PANEL_W, "and is 160 wide") + T.eq(rect.h, PANEL_H, "by 144 tall") +end + +do + local HallOfFame = require("src.ui.gen2.HallOfFame") + local hof = setmetatable({ drawPanel = function() end }, + { __index = HallOfFame }) + hof:drawWidescreen(WIN_W, WIN_H) + checkPanel("HallOfFame:drawWidescreen", Chrome.fitScale(WIN_W, WIN_H)) + hof:draw() + T.eq(only("HallOfFame:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +do + local Credits = require("src.ui.gen2.Credits") + local credits = setmetatable({ drawPanel = function() end }, + { __index = Credits }) + credits:drawWidescreen(WIN_W, WIN_H) + checkPanel("Credits:drawWidescreen", Chrome.fitScale(WIN_W, WIN_H)) + credits:draw() + T.eq(only("Credits:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +do + local BattleState = require("src.ui.gen2.BattleState") + local battle = setmetatable({ drawScene = function() end }, + { __index = BattleState }) + local scale = battle:battlePanelScale(WIN_W, WIN_H) + battle:drawWidescreen(WIN_W, WIN_H) + checkPanel("BattleState:drawWidescreen", scale) + battle:draw() + T.eq(only("BattleState:draw").w, PANEL_W, "and its 1:1 draw clips to the LCD") +end + +T.finish("gen2 widescreen clip bug 1869") diff --git a/tests/engine/kit_transition.lua b/tests/engine/kit_transition.lua new file mode 100644 index 00000000..e6e73e05 --- /dev/null +++ b/tests/engine/kit_transition.lua @@ -0,0 +1,112 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +love = love or require("tests.love_stub") + +local clock = 0 +love.timer = love.timer or {} +local realGetTime = love.timer.getTime +love.timer.getTime = function() return clock end + +local Transition = require("src.ui.kit.Transition") +local Kit = require("src.ui.kit.Kit") + +local function near(a, b, tol) + return math.abs(a - b) <= (tol or 1e-9) +end + +eq(Transition.ease(0), 0, "the easing starts at zero") +eq(Transition.ease(1), 1, "and ends at one") +check(Transition.ease(0.5) > 0.5, "easeOutCubic runs ahead of linear") +eq(Transition.ease(-1), 0, "a negative input clamps to zero") +eq(Transition.ease(2), 1, "an overshoot clamps to one") + +Transition.reset() +Transition.reduceMotion = false +Transition.armed = false + +clock = 10 +check(not Transition.start("tabs", "tab", { dir = 1 }), + "an unarmed launcher does not animate") +check(not Transition.active(), "so nothing is running") +eq(Transition.progress("tabs"), 1, "and the layer reads as finished") + +Transition.armed = true +clock = 10 +check(Transition.start("tabs", "tab", { dir = -1, from = "red", to = "blue" }), + "an armed launcher starts the tab slide") +check(Transition.active("tabs"), "which is active on its own layer") +check(Transition.active(), "and answers the any-layer question") +eq(Transition.kind("tabs"), "tab", "the kind is kept for the draw") +eq(Transition.dir("tabs"), -1, "and so is the direction") +eq(Transition.progress("tabs"), 0, "progress starts at zero") + +clock = 10 + Transition.DURATIONS.tab / 2 +Transition.update() +check(Transition.progress("tabs") > 0.5, + "halfway through the clock the eased progress is past half") +check(Transition.active("tabs"), "and the layer is still running") + +clock = 10 + Transition.DURATIONS.tab * 1.5 +Transition.update() +eq(Transition.progress("tabs"), 1, "the end of the duration is progress 1") +check(not Transition.active("tabs"), "and the layer retires itself") +eq(Transition.get("tabs"), nil, "a retired layer hands back no record") + +-- ------------------------------------------------- one per layer, replaced + +clock = 20 +Transition.start("online", "push", { dir = 1, from = "home", to = "play" }) +clock = 20 + Transition.DURATIONS.push / 2 +Transition.update() +check(near(Transition.progress("online"), Transition.ease(0.5)), + "the online layer eases off its own start time") +Transition.start("online", "pop", { dir = -1, from = "play", to = "home" }) +eq(Transition.kind("online"), "pop", "a second start replaces the first") +eq(Transition.progress("online"), 0, "and restarts its clock") +eq(Transition.dir("online"), -1, "with the new direction") + +Transition.start("modal", "in") +check(Transition.active("modal") and Transition.active("online"), + "layers run independently") +eq(Transition.DURATIONS["in"], 0.12, "a modal pops in over 120 ms") +eq(Transition.DURATIONS.out, 0.09, "and out over 90 ms") +eq(Transition.DURATIONS.tab, 0.18, "a tab change takes 180 ms") +eq(Transition.DURATIONS.push, 0.16, "an online push takes 160 ms") +eq(Transition.DURATIONS.pop, 0.16, "and so does a pop") + +clock = 30 +Transition.update() +check(not Transition.active(), "every layer retires once its time is up") + +-- ------------------------------------------------------------ input block + +clock = 40 +Transition.start("tabs", "tab", { dir = 1 }) +Kit.beginFrame(0, 0, false, 0) +check(Kit.blockClicks, "a running transition shields the frame from clicks") +Kit.endFrame() + +clock = 40 + Transition.DURATIONS.tab * 1.5 +Kit.beginFrame(0, 0, false, 0) +check(not Kit.blockClicks, "and lets go on the frame it ends") +Kit.endFrame() + +-- ----------------------------------------------------------- reduce motion + +Transition.reduceMotion = true +clock = 50 +check(not Transition.start("modal", "in"), "reduce motion refuses to animate") +check(not Transition.active("modal"), "so no transition is running") +eq(Transition.progress("modal"), 1, "and the layer snaps to done") +eq(Transition.kind("modal"), nil, "with no kind for the draw to honour") + +Transition.reduceMotion = false +Transition.reset() +Transition.armed = false +love.timer.getTime = realGetTime + +T.finish("kit transition") diff --git a/tests/engine/launcher_find_details.lua b/tests/engine/launcher_find_details.lua new file mode 100644 index 00000000..b2756be6 --- /dev/null +++ b/tests/engine/launcher_find_details.lua @@ -0,0 +1,110 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local Fetch = require("src.net.Fetch") +local ModIndex = require("src.mods.ModIndex") +local HostShell = require("src.core.HostShell") +local RomImporter = require("src.import.RomImporter") + +ModIndex.fetchText = function() error("Details fetched on the render thread") end +ModIndex.httpGet = function() error("Details fetched on the render thread") end +HostShell.httpGet = function() error("Details fetched on the render thread") end + +local requested, released, cancelled, answer +Fetch.get = function(url) + requested[#requested + 1] = url + return #requested +end +Fetch.poll = function() return answer end +Fetch.release = function(id) released[#released + 1] = id end +Fetch.cancel = function(id) cancelled[#cancelled + 1] = id end + +local function launcher() + requested, released, cancelled = {}, {}, {} + answer = { status = "pending" } + return setmetatable({ tab = "find" }, RomImporter) +end + +local ENTRY = { id = "rare_soda", title = "Rare Soda", + summary = "A soda, but rare.", + description_url = "mods/rare_soda.md", + _base = "https://example.invalid/" } + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + check(ri._findDetails ~= nil, "Details opens on the click that asked for it") + eq(ri._findDetails.title, "Rare Soda", "titled with the entry") + eq(ri._findDetails.body, "A soda, but rare.", + "showing the listing summary while the markdown is in flight") + check(ri._findDetails.loading, "and saying so") + + ri:_pumpFindDetails() + eq(#requested, 1, "the frame after the click is what goes to the network") + eq(requested[1], "https://example.invalid/mods/rare_soda.md", + "asking for the entry's description, resolved against its index") + check(ri._findDetails.loading, "and the modal is still waiting") + + ri:_pumpFindDetails() + check(ri._findDetails.loading, "a pending poll leaves the modal as it was") + eq(#released, 0, "and holds the job") + + answer = { status = "ok", body = "# Rare Soda\n\nIt is rare." } + ri:_pumpFindDetails() + eq(ri._findDetails.body, "# Rare Soda\n\nIt is rare.", + "the markdown replaces the summary when it lands") + eq(ri._findDetails.loading, false, "and the modal stops waiting") + eq(#released, 1, "the job is released") + eq(ri._findDetailsFetch, nil, "and the handle is dropped") + + ri:_pumpFindDetails() + eq(#requested, 1, "a settled modal asks for nothing more") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + answer = { status = "error", err = "timeout" } + ri:_pumpFindDetails() + eq(ri._findDetails.body, "A soda, but rare.", + "a failed description keeps the summary") + eq(ri._findDetails.loading, false, "and the modal is done waiting") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + ri._findDetails = nil + ri:_pumpFindDetails() + eq(#cancelled, 1, "closing the modal cancels the request behind it") + eq(ri._findDetailsFetch, nil, "and drops the handle") + answer = { status = "ok", body = "# Too late" } + ri:_pumpFindDetails() + eq(ri._findDetails, nil, "so a late answer does not reopen the modal") +end + +do + local ri = launcher() + ri:_findShowDetails(ENTRY) + ri:_pumpFindDetails() + ri:_findShowDetails(ENTRY) + eq(#cancelled, 1, "the first request is cancelled") + ri:_pumpFindDetails() + eq(#requested, 2, "and the second one is what the modal is waiting on") +end + +do + local ri = launcher() + ri:_findShowDetails({ id = "bare", title = "Bare", summary = "Nothing more." }) + check(ri._findDetails ~= nil, "the modal still opens") + eq(ri._findDetails.loading, false, "with nothing to wait for") + ri:_pumpFindDetails() + eq(#requested, 0, "and no request at all") +end + +T.finish("launcher find details") diff --git a/tests/engine/launcher_game_popup_fill_bug1874.lua b/tests/engine/launcher_game_popup_fill_bug1874.lua new file mode 100644 index 00000000..02b43f3a --- /dev/null +++ b/tests/engine/launcher_game_popup_fill_bug1874.lua @@ -0,0 +1,109 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Kit = require("src.ui.kit.Kit") +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local realFill = Theme.fillRounded +local fills = nil + +local function sameColor(a, b) + if type(a) ~= "table" or type(b) ~= "table" then return false end + return a[1] == b[1] and a[2] == b[2] and a[3] == b[3] +end + +local function spyOn() + fills = {} + Theme.fillRounded = function(x, y, w, h, color, alpha, radius) + fills[#fills + 1] = { x = x, y = y, w = w, h = h, + color = color, alpha = alpha } + return realFill(x, y, w, h, color, alpha, radius) + end +end + +local function spyOff() + Theme.fillRounded = realFill +end + +local function filledWith(rect, color) + for _, f in ipairs(fills) do + if math.abs(f.x - rect.x) < 1 and math.abs(f.y - rect.y) < 1 + and math.abs(f.w - rect.w) < 1 and (f.alpha or 1) > 0.5 + and sameColor(f.color, color) then + return true + end + end + return false +end + +local function drawGamePopup(tab) + love.graphics.getDimensions = function() return 1280, 720 end + love.graphics.getPixelDimensions = function() return 1280, 720 end + local imp = RomImporter.new(function() end, { launcher = true }) + imp.tab = tab + imp.ready = { red = true, blue = true, yellow = true, gold = true } + imp._gamePopup = true + Kit.focusId = nil + local ok, err = pcall(LauncherView.draw, imp) + check(ok, "first Choose-game frame draws: " .. tostring(err)) + spyOn() + ok, err = pcall(LauncherView.draw, imp) + spyOff() + check(ok, "second Choose-game frame draws: " .. tostring(err)) + local rects = {} + for i = 1, (Kit._navPrevN or 0) do + local slot = Kit._nav[i] + if slot and slot.id then + rects[slot.id] = { x = slot.x, y = slot.y, w = slot.w, h = slot.h } + end + end + return rects +end + +local rects = drawGamePopup("blue") +check(rects["gamepop-blue"] and rects["gamepop-red"], + "the Choose-game list is in the nav graph") +check(filledWith(rects["gamepop-blue"], PAL.railBlue), + "Blue cartridge: only the Blue row carries a solid rail fill") +check(not filledWith(rects["gamepop-red"], PAL.railRed), + "Blue cartridge: Red is not filled even when the ring parks on it") + +Kit.setFocus("gamepop-blue") +rects = drawGamePopup("red") +check(filledWith(rects["gamepop-red"], PAL.railRed), + "Red cartridge: Red stays filled while the ring sits elsewhere") +check(not filledWith(rects["gamepop-blue"], PAL.railBlue), + "Red cartridge: the focused Blue row is ring-only") + +local function tabFace(opts) + Kit.blockClicks = false + Kit.beginFrame() + spyOn() + Kit.button(10, 10, 100, 20, "Red", opts) + spyOff() + Kit.endFrame() + return { x = 10, y = 10, w = 100, h = 20 } +end + +Kit.setFocus("tab-face") +local r = tabFace({ face = "tab", id = "tab-face", color = PAL.railRed, + active = false }) +check(not filledWith(r, PAL.railRed), + "tab face: the focused-but-inactive row is not filled with its cart colour") + +Kit.setFocus("tab-face") +r = tabFace({ face = "tab", id = "tab-face", color = PAL.railRed, + active = true }) +check(filledWith(r, PAL.railRed), + "tab face: the active row is filled with its cart colour") + +print("ok launcher choose-game fill vs focus") diff --git a/tests/engine/launcher_navigation_perf.lua b/tests/engine/launcher_navigation_perf.lua index 6e50d090..5f31cc3f 100644 --- a/tests/engine/launcher_navigation_perf.lua +++ b/tests/engine/launcher_navigation_perf.lua @@ -112,4 +112,42 @@ do LauncherMods.list = old end +do + local calls = 0 + local old = LauncherMods.list + LauncherMods.list = function() + calls = calls + 1 + return { { id = "scoped", name = "Scoped", targetsHere = true, + manifest = { id = "scoped", version = "1.0.0" } } } + end + local imp = setmetatable({ + modScope = "red", + modStraysChecked = true, + activeCart = { red = "somecart" }, + activeSlot = {}, slots = { ["cart:somecart"] = {} }, + }, RomImporter) + imp:_refreshMods() + eq(calls, 1, "a scoped relist reads the installed mods once, not per consumer") + local before = calls + imp:modCartPlan() + imp:modCartPlan() + eq(calls, before, "and the cart plan the panel asks for every frame reuses it") + LauncherMods.list = old +end + +do + local ModProfile = require("src.mods.ModProfile") + local seeded = 0 + local oldEnsure = ModProfile.ensureFirst + ModProfile.ensureFirst = function() seeded = seeded + 1 end + local options = { modProfilesSeeded = true, + modProfiles = { { name = "PROFILE 1" } }, + activeProfile = "PROFILE 1" } + local list, active = LauncherMods.getProfiles(options) + eq(active, "PROFILE 1", "the active profile is read straight off options") + eq(#list, 1, "with the profiles already stored there") + eq(seeded, 0, "and no first-run seeding pass behind it") + ModProfile.ensureFirst = oldEnsure +end + T.finish("launcher_navigation_perf") diff --git a/tests/engine/launcher_nx_pad_cursor_test.lua b/tests/engine/launcher_nx_pad_cursor_test.lua index 3d9a8289..3b4b87de 100644 --- a/tests/engine/launcher_nx_pad_cursor_test.lua +++ b/tests/engine/launcher_nx_pad_cursor_test.lua @@ -70,7 +70,6 @@ do local gx, gy = love.mouse.getPosition() eq(gx, imp._padCursor.x, "NX getPosition X matches pad cursor") eq(gy, imp._padCursor.y, "NX getPosition Y matches pad cursor") - -- Real (stored) mouse must stay where the stub left it — bridge only. eq(mouseX, 0, "NX does not warp the underlying mouse X") eq(mouseY, 0, "NX does not warp the underlying mouse Y") imp:_restoreNxPointerBridge() @@ -279,10 +278,10 @@ do "RomImporter exports prepareOverlayHandoff") check(impSrc:find("function RomImporter:resumeAfterOverlay", 1, true) ~= nil, "RomImporter exports resumeAfterOverlay") - check(impSrc:find("if not self.isNX then", 1, true) ~= nil, - "NX skips desktop mouse-yield path") - check(impSrc:find("if not self.isNX and love.mouse.setPosition", 1, true) ~= nil, - "RomImporter skips setPosition on NX") + check(impSrc:find("if not self:_consolePointerHost() then", 1, true) ~= nil, + "consoles skip the desktop mouse-yield path") + check(impSrc:find("if not self:_consolePointerHost() and love.mouse.setPosition", + 1, true) ~= nil, "RomImporter skips setPosition on consoles") check(impSrc:find("dt > 1 / 30", 1, true) ~= nil, "NX clamps pad cursor dt") diff --git a/tests/engine/launcher_osk_touch_bug1861.lua b/tests/engine/launcher_osk_touch_bug1861.lua new file mode 100644 index 00000000..fd995580 --- /dev/null +++ b/tests/engine/launcher_osk_touch_bug1861.lua @@ -0,0 +1,80 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end +love.keyboard.setTextInput = love.keyboard.setTextInput or function() end + +local Kit = require("src.ui.kit.Kit") +local VK = require("src.ui.kit.VirtualKeyboard") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local realGetenv = os.getenv +local handheld = false +os.getenv = function(name) + if handheld and name == "HANDHELD" then return "1" end + return realGetenv(name) +end + +love.graphics.getDimensions = function() return 1280, 720 end +love.graphics.getPixelDimensions = function() return 1280, 720 end + +local imp = RomImporter.new(function() end, { launcher = true }) +imp.tab = "find" +imp.ready = { red = true, blue = true } + +handheld = false +eq(VK.open({ title = "Search mods" }), false, + "off a Linux handheld the OSK refuses to open") +check(VK.active == false, "no OSK overlay on a touch-only host") + +handheld = true +check(VK.open({ text = "", title = "Search mods", + onDone = function(newText, confirmed) + if confirmed then imp.findQuery = newText end + end }) == true, "the OSK opens on a handheld") + +local pressRects = {} +local realPress = Kit.press +local recording = false +Kit.press = function(x, y, w, h) + if recording and not Kit.blockClicks then + pressRects[#pressRects + 1] = { x = x, y = y, w = w, h = h } + end + return realPress(x, y, w, h) +end + +local function frame(click) + imp._clickPt = click + pressRects = {} + recording = true + local ok, err = pcall(LauncherView.draw, imp) + recording = false + check(ok, "OSK frame draws: " .. tostring(err)) + return pressRects +end + +local rects = frame(nil) +check(#rects > 0, + "the OSK keys register with Kit.press instead of drawing behind a shield") + +local first = rects[1] +if first then + frame({ x = first.x + first.w / 2, y = first.y + first.h / 2 }) + eq(VK.text, "1", "tapping the top-left key types it") +end + +VK.text = "pikachu" +VK.close(true) +check(VK.active == false, "Done closes the OSK") +eq(imp.findQuery, "pikachu", "Done writes findQuery, not a dead _findQuery") +eq(imp._findQuery, nil, "nothing writes the dead _findQuery field") + +Kit.press = realPress +os.getenv = realGetenv + +print("ok launcher on-screen keyboard taps") diff --git a/tests/engine/launcher_skins_tab.lua b/tests/engine/launcher_skins_tab.lua index 76a078bf..1d8c9b77 100644 --- a/tests/engine/launcher_skins_tab.lua +++ b/tests/engine/launcher_skins_tab.lua @@ -50,30 +50,36 @@ local imp = read("src/import/RomImporter.lua") check(view:find('id = "skins"', 1, true) ~= nil, "LauncherView registers a skins tab") -check(view:find('id = "bug"', 1, true) ~= nil, - "LauncherView registers a bug tab") +check(view:find('id = "bug"', 1, true) == nil, + "the bug tab has left the header") check(view:find("drawSkinGlyph", 1, true) ~= nil, "the skins tab draws its own glyph rather than shipping an asset") check(view:find('assets/launcher/bug.png', 1, true) ~= nil, - "the bug tab uses the standard bug report asset") + "the bug report asset is still loaded for the panel") -- the tab has to be next to Find, which is what the request was local order = view:match("local HEADER_TABS = %{(.-)%}\n") check(order ~= nil, "HEADER_TABS found") if order then local findAt = order:find('id = "find"', 1, true) local skinsAt = order:find('id = "skins"', 1, true) - local bugAt = order:find('id = "bug"', 1, true) + local onlineAt = order:find('id = "online"', 1, true) check(findAt and skinsAt and skinsAt > findAt, "the skins tab sits immediately after Find") - check(skinsAt and bugAt and bugAt > skinsAt, - "the bug tab sits after Skins") + check(onlineAt ~= nil, "the online tab is in the header") + local onlineRow = order:match('{ id = "online".-}') + check(onlineRow and onlineRow:find("beta = true", 1, true) ~= nil, + "and carries the BETA badge the skins tab uses") end check(view:find('imp.tab == "skins"', 1, true) ~= nil, "the panel dispatch routes the skins tab") check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it") -check(view:find('imp.tab == "bug"', 1, true) ~= nil, - "the panel dispatch routes the bug tab") -check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it") +check(view:find('imp.tab == "bug"', 1, true) == nil, + "no tab dispatch routes the bug panel any more") +check(view:find("buildBugModal", 1, true) ~= nil, + "the gear opens it as a modal instead") +check(view:find("buildBugPanel", 1, true) ~= nil, "over the same panel code") +check(view:find('"settings-bug"', 1, true) ~= nil, + "reached from a button inside the settings modal") check(view:find('Kit.toggle', 1, true) ~= nil, "the bug panel uses a switch for safe mode") check(view:find('bug-report', 1, true) ~= nil, @@ -99,8 +105,8 @@ for _ = 1, #GameVersion.ORDER + 3 do RomImporter._cycleTab(probe, 1) end local reached = " " .. table.concat(cycled, " ") .. " " check(reached:find(" skins ", 1, true) ~= nil, "shoulder-button tab cycling reaches the skins tab") -check(reached:find(" bug ", 1, true) ~= nil, - "shoulder-button tab cycling reaches the bug tab") +check(reached:find(" bug ", 1, true) == nil, + "shoulder-button tab cycling no longer stops on the bug panel") for _, id in ipairs(GameVersion.ORDER) do if id ~= GameVersion.ORDER[1] then check(reached:find(" " .. id .. " ", 1, true) ~= nil, diff --git a/tests/engine/launcher_uwp_pad_nav_test.lua b/tests/engine/launcher_uwp_pad_nav_test.lua new file mode 100644 index 00000000..eb59b3d8 --- /dev/null +++ b/tests/engine/launcher_uwp_pad_nav_test.lua @@ -0,0 +1,142 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local setPositionCalls = 0 +local mouseX, mouseY = 0, 0 +love.mouse.getPosition = function() return mouseX, mouseY end +love.mouse.setPosition = function(x, y) + setPositionCalls = setPositionCalls + 1 + mouseX, mouseY = x, y +end + +local Platform = require("src.core.Platform") +local Kit = require("src.ui.kit.Kit") +local RomImporter = require("src.import.RomImporter") + +local osName = "OS X" +love.system.getOS = function() return osName end + +local function setHost(name) + osName = name + Platform._resetForTests() +end + +local function freshImporter() + Kit.focusId = nil + Kit._navQueue = nil + return setmetatable({ + isNX = false, + _padCursor = { x = 100, y = 100 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _rawHatDirs = {}, + _padInited = true, + _flex = true, + tab = "red", + }, RomImporter) +end + +local navLog = {} +local realNavigate = Kit.navigate +Kit.navigate = function(dir) + navLog[#navLog + 1] = dir + Kit._navQueue = nil +end + +local function stick(imp, ax, ay, frames, dt) + dt = dt or (1 / 60) + imp._padAxis.leftx = ax + imp._padAxis.lefty = ay + for _ = 1, frames do imp:_updatePadCursor(dt) end +end + +setHost("UWP") +do + navLog = {} + setPositionCalls = 0 + local imp = freshImporter() + check(imp:_consolePointerHost(), "UWP counts as a console pointer host") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "UWP: a stick push does not arm the virtual mouse") + eq(#navLog, 1, "a held stick steps the ring once before the repeat delay") + eq(navLog[1], "right", "right on the stick is right on the ring") + eq(setPositionCalls, 0, "UWP never warps the system mouse") + eq(imp._padCursor.x, 100, "the pad cursor stays parked in menu-nav mode") + + stick(imp, 1, 0, 30) + check(#navLog > 1, "holding the stick repeats the step") + stick(imp, 0.35, 0, 5) + check(#navLog > 0, "0.35 is inside the release hysteresis, so the latch holds") + stick(imp, 0, 0, 2) + local released = #navLog + stick(imp, 0.4, 0, 5) + eq(#navLog, released, "0.4 is under the latch threshold, so nothing steps") + stick(imp, 0, 0, 2) + stick(imp, 0, -1, 1) + eq(navLog[#navLog], "up", "up on the stick is up on the ring") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + stick(imp, 1, 0, 10) + check(imp._padCursor.x > 100, + "UWP: cursor mode still moves the pad pointer") + eq(#navLog, 0, "cursor mode does not also walk the ring") + eq(setPositionCalls, 0, "UWP cursor mode never warps the system mouse") + check(imp._nxPointerBridge, "UWP installs the getPosition bridge") + imp:_restoreNxPointerBridge() + + mouseX, mouseY = 400, 400 + imp:_updatePadCursor(1 / 60) + check(imp._padCursorActive, + "UWP: system-mouse drift does not yield the pad cursor") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + imp:gamepadpressed(nil, "y") + check(not imp._padCursorActive, "Y drops back to Controller Menu Navigation") + eq(imp._cursorModeToast, "Controller Menu Navigation [Y]", + "the toast names the mode Y selected") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "a stick still off-centre does not undo the Y toggle") +end + +setHost("OS X") +do + navLog = {} + setPositionCalls = 0 + local imp = freshImporter() + check(not imp:_consolePointerHost(), "desktop is not a console pointer host") + stick(imp, 1, 0, 10) + check(imp._padCursorActive, "desktop: the stick still arms the virtual mouse") + check(setPositionCalls > 0, "desktop still warps the system mouse") + eq(#navLog, 0, "desktop stick does not walk the ring by default") +end + +do + navLog = {} + local imp = freshImporter() + imp._padCursorActive = true + imp:gamepadpressed(nil, "y") + check(imp._padNavChosen, "Y records that menu nav was the deliberate choice") + stick(imp, 1, 0, 10) + check(not imp._padCursorActive, + "desktop: after Y the stick keeps the ring instead of the mouse") + eq(#navLog, 1, "desktop menu nav steps the ring") +end + +Kit.navigate = realNavigate +setHost("OS X") + +print("ok launcher UWP pad menu navigation") diff --git a/tests/engine/link_addr_entry_bug1295.lua b/tests/engine/link_addr_entry_bug1295.lua new file mode 100644 index 00000000..8f4cee9b --- /dev/null +++ b/tests/engine/link_addr_entry_bug1295.lua @@ -0,0 +1,47 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local CodeEntry = require("src.link.CodeEntry") +local LinkState = require("src.link.LinkState") + +local function roundTrip(ip) + return LinkState.addrText(LinkState.addrEntry(ip)) +end + +do + T.eq(roundTrip("10.0.0.1"), "10.0.0.1", "a two-digit first octet survives") + T.eq(roundTrip("10.42.0.1"), "10.42.0.1", "hotspot addresses survive") + T.eq(roundTrip("192.168.1.40"), "192.168.1.40", "short tails do not zero-extend") + T.eq(roundTrip("255.255.255.255"), "255.255.255.255", "a full-width address fits") + T.eq(roundTrip(nil), "192.168.0.1", "no LAN IP falls back to a usable seed") + T.eq(roundTrip("not an address"), "192.168.0.1", "junk falls back to the seed") +end + +do + local state = LinkState.addrEntry("10.0.0.1") + T.eq(state.pos, 8, "the cursor starts on the last filled slot") + T.eq(state.length, LinkState.ADDR_LENGTH, "the widget is the address shape") + T.eq(CodeEntry.charAt(state, 9), " ", "unused slots are blank, not '0'") +end + +do + local function entryOf(text) + return CodeEntry.fromText(text, { length = LinkState.ADDR_LENGTH, + charset = LinkState.ADDR_CHARSET }) + end + T.eq(LinkState.addrText(entryOf("10.0.0")), nil, "three octets are rejected") + T.eq(LinkState.addrText(entryOf("10.0.0.256")), nil, "an octet over 255 is rejected") + T.eq(LinkState.addrText(entryOf("10.0.0.1.2")), nil, "five octets are rejected") + T.eq(LinkState.addrText(entryOf("")), nil, "an empty widget is rejected") +end + +do + local state = LinkState.addrEntry("10.0.0.1") + state.pos = 1 + CodeEntry.up(state) + T.eq(LinkState.addrText(state), "20.0.0.1", "scrubbing edits one slot") + for _ = 1, 11 do CodeEntry.up(state) end + T.eq(LinkState.addrText(state), "10.0.0.1", "a full cycle returns the digit") +end + +T.finish("link_addr_entry_bug1295") diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 0c1ef8c8..f91f3148 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -328,22 +328,4 @@ do "LinkState uses the session lifecycle instead of raw terminal flags") end -do - local source = readFile("src/link/Tournament.lua") - T.check(source:find('require("src.link.Session")', 1, true) ~= nil, - "Tournament depends on the session boundary") - T.check(source:find('kind = "tournament"', 1, true) ~= nil, - "Tournament assigns its connection role and kind locally") - T.check(source:find("self.net.inbox", 1, true) == nil, - "Tournament never mutates a transport inbox") - T.check(source:find("self.net = Net.new()", 1, true) == nil, - "Tournament stores only a successful session wrapper") - T.check(source:find(':take("hello")', 1, true) ~= nil, - "Tournament retrieves match hello without draining its tail") - T.check(source:find(":pollOne()", 1, true) ~= nil, - "Tournament processes handoff prefixes one packet at a time") - T.check(source:find("getStatus()", 1, true) ~= nil, - "Tournament uses the session lifecycle instead of raw terminal flags") -end - T.finish("link_session") diff --git a/tests/engine/mart_list_box_bug1887.lua b/tests/engine/mart_list_box_bug1887.lua new file mode 100644 index 00000000..8e3cf7d9 --- /dev/null +++ b/tests/engine/mart_list_box_bug1887.lua @@ -0,0 +1,159 @@ +-- engine/events/pokemart.asm (#1887) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local realFont = package.loaded["src.render.Font"] +local calls = {} +local FontStub +FontStub = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, + encode = function() return {} end, + spansFitting = function(spans) return #spans end, + advanceOf = function() return 8 end, +} +package.loaded["src.render.Font"] = FontStub +for _, mod in ipairs({ "src.ui.ListMenu", "src.ui.Theme", "src.ui.Menu", + "src.ui.ShopMenu", "src.render.TextBox" }) do + package.loaded[mod] = nil +end +local ShopMenu = require("src.ui.ShopMenu") +local ListMenu = require("src.ui.ListMenu") + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed +local game +game = { + data = { + text = {}, + items = { + POKE_BALL = { name = "POKe BALL", price = 200 }, + GREAT_BALL = { name = "GREAT BALL", price = 600 }, + TOWN_MAP = { name = "TOWN MAP", price = 0, keyItem = true }, + HM_CUT = { name = "HM01", price = 0 }, + POTION = { name = "POTION", price = 300 }, + }, + }, + save = { money = 3000, inventory = {}, bagOrder = {} }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, +} + +local function openList(which) + game.stack.states = {} + local menu = ShopMenu.new(game, { "POKE_BALL", "GREAT_BALL" }, function() end) + game.stack:push(menu) + menu.index = which + pressed = "a" + menu:update(1 / 60) + pressed = nil + return menu, game.stack:top() +end + +do + local menu = ShopMenu.new(game, { "POKE_BALL" }, function() end) + eq(menu.tx, 0, "BUY/SELL/QUIT sits at column 0") + eq(menu.ty, 0, "and row 0") + eq(menu.tw, 11, "11 tiles wide, flush with the money box at column 11") + eq(menu.th, 7, "7 tiles tall (data/text_boxes.asm:34)") + + calls = {} + menu:draw() + check(found("draw", function(c) + return c[2] == "BUY" and c[4] == 8 + end) ~= nil, "BUY prints on interior row 1 (wTopMenuItemY 1)") + -- text_boxes.asm:35 + check(found("box", function(c) + return c[2] == 11 and c[3] == 0 and c[4] == 9 and c[5] == 3 + end) ~= nil, "the money box is MONEY_BOX 11,0 - 19,2") + check(found("draw", function(c) + return c[2] == "MONEY" and c[3] == 104 and c[4] == 0 + end) ~= nil, "with MONEY captioned on its top border at column 13") +end + +do + local menu, list = openList(1) + check(getmetatable(list) == ListMenu, "BUY opens a list menu") + eq(list.itemBox, true, "PRICEDITEMLISTMENU draws LIST_MENU_BOX") + eq(list.isOpaque, false, "so the mart floor and clerk keep drawing") + eq(list.title, nil, "no BUY header row: the ROM list has no title") + eq(list.items[#list.items].cancel, true, "the terminator's CANCEL row") + eq(list.items[2].price, "¥600", "prices ride item.price, not the name row") + + calls = {} + list:draw() + check(found("box", function(c) + return c[2] == 4 and c[3] == 2 and c[4] == 16 and c[5] == 11 + end) ~= nil, "LIST_MENU_BOX 4,2 - 19,12") + check(found("draw", function(c) + return c[2] == "GREAT BALL" and c[3] == 48 and c[4] == 48 + end) ~= nil, "the second name sits at (48, 48)") + check(found("draw", function(c) + return c[2] == "¥600" and c[4] == 56 + end) ~= nil, "its price is one tile row below the name") + local price = found("draw", function(c) return c[2] == "¥600" end) + if price then + eq(price[3] + FontStub.width("¥600"), 136, + "right-aligned through the BCD field (home/list_menu.asm:410-424)") + end + check(found("draw", function(c) return c[2] == "CANCEL" end) ~= nil, + "CANCEL is a real row (home/list_menu.asm:523-528)") + + list.index = #list.items + pressed = "a" + list:update(1 / 60) + pressed = nil + eq(game.stack:top(), menu, "A on CANCEL returns to the mart menu") +end + +do + game.save.inventory = { POTION = 3, TOWN_MAP = 1, HM_CUT = 1 } + local _, list = openList(2) + check(getmetatable(list) == ListMenu, "SELL opens a list menu") + eq(list.itemBox, true, "ITEMLISTMENU draws the same box") + eq(list.title, nil, "and carries no SELL header") + local byId = {} + for _, it in ipairs(list.items) do + if it.value then byId[it.value] = it end + end + eq(byId.POTION.right, "x3", "ordinary stock keeps its quantity") + eq(byId.TOWN_MAP.right, nil, "IsKeyItem_ skips the quantity") + eq(byId.HM_CUT.right, nil, "and so do the HMs") + eq(list.items[#list.items].cancel, true, "CANCEL closes the sell list too") + game.save.inventory = {} +end + +package.loaded["src.render.Font"] = realFont +for _, mod in ipairs({ "src.ui.ListMenu", "src.ui.Theme", "src.ui.Menu", + "src.ui.ShopMenu", "src.render.TextBox" }) do + package.loaded[mod] = nil +end +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/menu_hold_scroll_bug1898.lua b/tests/engine/menu_hold_scroll_bug1898.lua new file mode 100644 index 00000000..39d2799e --- /dev/null +++ b/tests/engine/menu_hold_scroll_bug1898.lua @@ -0,0 +1,235 @@ +-- home/joypad2.asm:16-53 (#1898) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function() end, + drawCode = function() end, + drawBox = function() end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local spans = {} + for i = 1, #tostring(text) do spans[i] = { from = i, to = i } end + return spans + end, + spansFitting = function(spans) return #spans end, +} + +local MenuRepeat = require("src.ui.MenuRepeat") +local ListMenu = require("src.ui.ListMenu") + +local function newInput() + local input = { edges = {}, held = {} } + function input:press(button) + self.edges[button] = true + self.held[button] = true + end + function input:release(button) + self.edges[button] = nil + self.held[button] = nil + end + function input:wasPressed(button) + if self.edges[button] then + self.edges[button] = nil + return true + end + return false + end + function input:isDown(button) return self.held[button] == true end + return input +end + +do + eq(MenuRepeat.GEN1_DELAY, 30, "half a second before the first repeat") + eq(MenuRepeat.GEN1_RATE, 5, "then 1/12 of a second between repeats") + eq(MenuRepeat.GEN2_DELAY, 15, "JoyTextDelay seeds wTextDelayFrames with 15") + eq(MenuRepeat.GEN2_RATE, 5, "and reseeds it with 5") + + local input = newInput() + local state = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE) + input:press("down") + local dir, edge = MenuRepeat.direction(state, input, { "up", "down" }) + eq(dir, "down", "the press itself reports immediately") + eq(edge, true, "and is flagged as the edge") + + local fired = {} + for frame = 1, 60 do + local d = MenuRepeat.direction(state, input, { "up", "down" }) + if d then fired[#fired + 1] = frame end + end + eq(fired[1], 30, "the hold is ignored for half a second") + eq(fired[2], 35, "then repeats every five frames") + eq(fired[3], 40, "and keeps repeating") + eq(#fired, 7, "seven repeats in the second after the press") + + input:release("down") + eq(MenuRepeat.direction(state, input, { "up", "down" }), nil, + "letting go stops it") + eq(state.frames, 0, "and clears the counter, so the next hold waits again") + + local off = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE, false) + input:press("down") + MenuRepeat.direction(off, input, { "up", "down" }) + local repeated = false + for _ = 1, 120 do + if MenuRepeat.direction(off, input, { "up", "down" }) then repeated = true end + end + check(not repeated, "a disabled state stays edge-only") + input:release("down") +end + +do + local items = {} + for i = 1, 10 do items[i] = { value = i, label = "ITEM " .. i } end + local input = newInput() + local game = { input = input, + stack = { push = function() end, pop = function() end, + top = function() end } } + local list = ListMenu.new(game, "ITEMS", items, { kind = "bag", itemBox = true }) + eq(list.keyRepeat, true, + "DisplayListMenuID runs with hJoy7 = 1, with no mod hook in sight") + eq(list.repeatDelay, 30, "at the cart's half-second delay") + eq(list.repeatRate, 5, "and the cart's repeat rate") + + input:press("down") + list:update(1 / 60) + eq(list.index, 2, "the press moves the cursor one row") + for _ = 1, 29 do list:update(1 / 60) end + eq(list.index, 2, "a short hold moves nothing more") + list:update(1 / 60) + eq(list.index, 3, "the 30th held frame is the first repeat") + for _ = 1, 5 do list:update(1 / 60) end + eq(list.index, 4, "and every fifth frame after that") + for _ = 1, 60 do list:update(1 / 60) end + eq(list.index, 10, "holding Down walks to the end of the list") + eq(list.scroll, 7, "with the window scrolled to the bottom") + + input:release("down") + input:press("up") + list:update(1 / 60) + for _ = 1, 200 do list:update(1 / 60) end + eq(list.index, 1, "holding Up walks back to the top") + eq(list.scroll, 0, "and the window comes with it") +end + +do + local data = { pokemon = {}, constants = { dexSize = 151, dexDigits = 3 } } + for n = 1, 151 do + local id = ("DEXMON_%03d"):format(n) + data.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n } + end + local save = { pokedex = { seen = {}, owned = {} } } + for n = 1, 20 do save.pokedex.seen[("DEXMON_%03d"):format(n)] = true end + local input = newInput() + local game = { data = data, save = save, input = input, + stack = { push = function() end, pop = function() end, + top = function() end } } + local PokedexMenu = require("src.ui.PokedexMenu") + local dex = PokedexMenu.new(game, {}) + eq(#dex.items, 20, "twenty seen species in the list") + + input:press("down") + dex:update(1 / 60) + eq(dex.index, 2, "ShowPokedexMenu's own list moves on the press") + for _ = 1, 29 do dex:update(1 / 60) end + eq(dex.index, 2, "and waits out the same half second") + dex:update(1 / 60) + eq(dex.index, 3, "before the first repeat") + for _ = 1, 200 do dex:update(1 / 60) end + eq(dex.index, 20, "a long hold reaches the last seen species") + + input:release("down") + input:press("right") + dex:update(1 / 60) + eq(dex.scroll, 13, "Right pages the scroll offset and clamps at the end") + for _ = 1, 60 do dex:update(1 / 60) end + eq(dex.scroll, 13, "holding Right at the bottom cannot page past it") + input:release("right") + + input:press("left") + dex:update(1 / 60) + eq(dex.scroll, 6, "Left pages back seven rows") + for _ = 1, 40 do dex:update(1 / 60) end + eq(dex.scroll, 0, "and a held Left runs the list back to the top") + input:release("left") +end + +do + local PackMenu = require("src.ui.gen2.PackMenu") + local Save = require("src.core.gen2.Save") + local items = {} + local order = {} + for i = 1, 8 do + local id = "FAKEITEM_" .. i + items[id] = { id = id, name = "ITEM " .. i, pocket = "ITEM", index = i, + canToss = true, canSelect = false, + fieldMenu = "ITEMMENU_CLOSE" } + order[i] = id + end + local save = Save.newGame() + save.inventory = {} + for _, id in ipairs(order) do save.inventory[id] = 1 end + save.bagOrder = order + local input = newInput() + local game = { input = input, save = save, options = save.options, + data = { items = items, moves = {}, pokemon = {}, + audio = { sfx = {} } }, + stack = { push = function() end, pop = function() end } } + local pack = PackMenu.new(game, { save = save, pocket = "ITEM", + onClose = function() end, world = { useFieldItem = function() end } }) + pack.gfx = { available = function() return false end, draw = function() end, + colorsAt = function() return nil end } + eq(pack.hold.delay, 15, "the pocket list runs at JoyTextDelay's cadence") + eq(pack.hold.rate, 5, "with the cart's five-frame repeat") + + local total = pack:total() + check(total >= 9, "eight items plus the CANCEL row") + input:press("down") + pack:update(1 / 60) + eq(pack.index, 2, "the press moves one row") + for _ = 1, 14 do pack:update(1 / 60) end + eq(pack.index, 2, "fifteen frames of delay first") + pack:update(1 / 60) + eq(pack.index, 3, "then the first repeat") + for _ = 1, 200 do pack:update(1 / 60) end + eq(pack.index, total, + "a held Down stops on the last row rather than wrapping round") + input:release("down") +end + +do + local Gen2Dex = require("src.ui.gen2.PokedexMenu") + local entries, seen = {}, {} + for n = 1, 30 do + local id = ("G2MON_%03d"):format(n) + entries[id] = { dex = n, species = id, name = ("MON%03d"):format(n) } + seen[id] = true + end + local input = newInput() + local save = { pokedex = { seen = seen, caught = {} } } + local game = { data = {}, save = save, input = input } + local dex = Gen2Dex.new(game, { save = save, + pokedex = { entries = entries }, + pokemon = {} }) + eq(dex.hold.delay, 15, "the listing runs at JoyTextDelay's cadence") + check(#dex.rows >= 30, "thirty seen species in the listing") + + input:press("down") + dex:update(1 / 60) + eq(dex.index, 2, "the press moves one row") + for _ = 1, 14 do dex:update(1 / 60) end + eq(dex.index, 2, "after the same fifteen-frame delay") + dex:update(1 / 60) + eq(dex.index, 3, "the hold starts repeating") + for _ = 1, 400 do dex:update(1 / 60) end + eq(dex.index, #dex.rows, + "Pokedex_ListingMoveCursorDown stops at the end of the listing") + input:release("down") +end + +T.finish("menu hold-to-scroll bug 1898") diff --git a/tests/engine/mod_update_badge.lua b/tests/engine/mod_update_badge.lua new file mode 100644 index 00000000..4837a9b1 --- /dev/null +++ b/tests/engine/mod_update_badge.lua @@ -0,0 +1,97 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local ModUpdate = require("src.mods.ModUpdate") +local LauncherMods = require("src.mods.LauncherMods") +local RomImporter = require("src.import.RomImporter") + +local RELEASES = { + { version = "1.1.10", zip = { url = "https://example.invalid/a.zip" } }, + { version = "1.0.0", zip = { url = "https://example.invalid/b.zip" } }, +} + +local oldPump = ModUpdate.pumpDownloadZip +local oldInstall = LauncherMods.installDownloadedZip +local oldDeps = LauncherMods.checkDependencies +local installedAs + +ModUpdate.pumpDownloadZip = function() return true, "install.zip" end +LauncherMods.installDownloadedZip = function() return true, installedAs end +LauncherMods.checkDependencies = function() return { hasIssues = false } end + +local function launcher(installed, wanted) + installedAs = wanted + local ri = setmetatable({ + mods = { { id = "gym", name = "Gym Challenge", version = installed, + github = "someone/gym" } }, + modUpdateInfo = { + gym = { status = "available", latest = "1.1.10", best = RELEASES[1], + releases = RELEASES, checkedAt = 1 }, + }, + _modInstall = { + h = {}, version = wanted, + spec = { modId = "gym", name = "Gym Challenge", verb = "Updated", + notice = "mod" }, + }, + }, RomImporter) + ri._refreshMods = function(self) self.mods[1].version = wanted end + return ri +end + +do + local ri = launcher("1.0.0", "1.1.10") + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym").status, "current", + "the mod that just installed the latest release is up to date") + eq(ri:_modUpdateInfo("gym").latest, "1.1.10", + "and the release list it was judged against is still on the row") + check(ri.modNotice and ri.modNotice.ok, "the install still reports success") + check(ri.modNotice.text:find("1.1.10", 1, true) ~= nil, + "with the version it landed") + eq(ri._modInstall, nil, "and the job is done") +end + +do + local ri = launcher("0.9.0", "1.0.0") + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym").status, "available", + "an older release is still behind the newest one") + eq(ri:_modUpdateInfo("gym").latest, "1.1.10", + "which is the release the badge names") +end + +do + local ri = launcher("1.0.0", "1.1.10") + local pending = { mod = ri.mods[1], h = {} } + ri._modInfoFetch = { pending } + ri:_pumpModInstall() + eq(pending.mod.version, "1.1.10", + "the in-flight check judges against the version now on disk") + + local oldFetch = ModUpdate.pumpFetchReleases + local oldCache = ModUpdate.readCache + ModUpdate.pumpFetchReleases = function() return true, RELEASES end + ModUpdate.readCache = function() return nil end + ri:_pumpModInfoFetch() + ModUpdate.pumpFetchReleases = oldFetch + ModUpdate.readCache = oldCache + eq(ri:_modUpdateInfo("gym").status, "current", + "so it lands on the same answer instead of bringing the chip back") +end + +do + local ri = launcher("1.0.0", "1.1.10") + ri.modUpdateInfo = {} + ri:_pumpModInstall() + eq(ri:_modUpdateInfo("gym"), nil, + "no release list means no verdict to invent") +end + +ModUpdate.pumpDownloadZip = oldPump +LauncherMods.installDownloadedZip = oldInstall +LauncherMods.checkDependencies = oldDeps + +T.finish("mod update badge") diff --git a/tests/engine/online_arena_data.lua b/tests/engine/online_arena_data.lua new file mode 100644 index 00000000..d9a0ee34 --- /dev/null +++ b/tests/engine/online_arena_data.lua @@ -0,0 +1,3 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +dofile("tests/online_arena_data.lua") diff --git a/tests/engine/online_convert.lua b/tests/engine/online_convert.lua new file mode 100644 index 00000000..80e96207 --- /dev/null +++ b/tests/engine/online_convert.lua @@ -0,0 +1,3 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +dofile("tests/online_convert.lua") diff --git a/tests/engine/online_panel.lua b/tests/engine/online_panel.lua new file mode 100644 index 00000000..2b2c1008 --- /dev/null +++ b/tests/engine/online_panel.lua @@ -0,0 +1,3 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +dofile("tests/online_panel.lua") diff --git a/tests/engine/online_trade.lua b/tests/engine/online_trade.lua new file mode 100644 index 00000000..9e439451 --- /dev/null +++ b/tests/engine/online_trade.lua @@ -0,0 +1,3 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +dofile("tests/online_trade.lua") diff --git a/tests/engine/open_menu_bugs_949_1149.lua b/tests/engine/open_menu_bugs_949_1149.lua index c9af0053..7999bf8c 100644 --- a/tests/engine/open_menu_bugs_949_1149.lua +++ b/tests/engine/open_menu_bugs_949_1149.lua @@ -16,9 +16,13 @@ T.eq(romText(localized, "_MoveIsDisabledText", "%s's %s is disabled!", { GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) local data = { - pokemon = {}, moves = {}, items = {}, maps = {}, + pokemon = {}, moves = {}, items = {}, + -- home/overworld.asm:2016 (#1691) + maps = { PALLET_TOWN = { id = "PALLET_TOWN", index = 0, tileset = "OVERWORLD", + width = 10, height = 9 } }, hiddenItems = loadfile("src/save_convert/data/hidden_items.lua")(), } +loadfile("tests/fixture_data/map_window.lua")()(data, "PALLET_TOWN") local save = { player = { name = "RED", id = 1, map = "PALLET_TOWN", x = 0, y = 0 }, rival = { name = "BLUE" }, party = {}, boxes = {}, inventory = {}, diff --git a/tests/engine/party_battle_prompt_bug1901.lua b/tests/engine/party_battle_prompt_bug1901.lua new file mode 100644 index 00000000..888130c7 --- /dev/null +++ b/tests/engine/party_battle_prompt_bug1901.lua @@ -0,0 +1,50 @@ +-- pokered engine/battle/core.asm:2315 (#1901) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local PartyMenu = require("src.ui.PartyMenu") + +local game = { + data = { text = {}, pokemon = { LAPRAS = { name = "LAPRAS" } } }, + save = { + party = { { species = "LAPRAS", hp = 50, stats = { hp = 50 }, + level = 30, moves = {} } }, + inventory = {}, options = {}, flags = {}, + }, +} +game.stack = { states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end } + +local battle = { playerParty = game.save.party } + +local function message(opts) + return PartyMenu.new(game, opts):bottomMessage() +end + +eq(message({}), "Choose a POKéMON.", + "field START -> POKéMON is NORMAL_PARTY_MENU") +eq(message({ battle = battle }), "Choose a POKéMON.", + "the voluntary PKMN option is NORMAL_PARTY_MENU too") +eq(message({ battle = battle, forceSwitch = true }), + "Bring out which\nPOKéMON?", + "ChooseNextMon / SHIFT is BATTLE_PARTY_MENU") +eq(message({ battle = battle, itemUse = true }), + "Use item on which\nPOKéMON?", + "in-battle medicine keeps USE_ITEM_PARTY_MENU") +eq(message({ tmhm = { move = "FIX_CUT", kind = "TM" } }), + "Use TM on which\nPOKéMON?", "TMHM_PARTY_MENU") + +game.data.text._PartyMenuNormalText = "CHOOSE A #MON." +game.data.text._PartyMenuBattleText = "BRING OUT WHICH #MON?" +eq(message({ battle = battle }), "CHOOSE A #MON.", + "the voluntary open prints PartyMenuNormalText") +eq(message({ battle = battle, forceSwitch = true }), "BRING OUT WHICH #MON?", + "the forced open prints PartyMenuBattleText") + +T.finish("party_battle_prompt_bug1901") diff --git a/tests/engine/poison_flicker_bug1872.lua b/tests/engine/poison_flicker_bug1872.lua new file mode 100644 index 00000000..2d18d9e9 --- /dev/null +++ b/tests/engine/poison_flicker_bug1872.lua @@ -0,0 +1,111 @@ +-- engine/gfx/screen_effects.asm:1-12 (#1872) + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.modkit") +local check, eq = T.check, T.eq + +local Data = T.fixtures.fresh() + +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local PaletteFX = require("src.render.PaletteFX") +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +do + local bgp = 0xE4 + local want = {} + for i = 0, 3 do + local shade = math.floor(bgp / (4 ^ i)) % 4 + want[i] = (i == 0) and (shade % 4 >= 2 and shade or shade + 2) or shade + end + for i = 0, 3 do + eq(PaletteFX.POISON_BGP[i], want[i], + ("POISON_BGP color %d matches `or $2` on rBGP"):format(i)) + end + + local colors = { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } } + local out = PaletteFX.permute(colors, PaletteFX.POISON_BGP) + eq(out[1][1], 3, "poison shows DMG white as shade 2") + eq(out[2][1], 2, "poison leaves shade 1 alone") + eq(out[4][1], 4, "poison leaves shade 3 alone") +end + +do + local realSound = package.loaded["src.core.Sound"] + package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end } + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 20) } + save.party[1].status = "PSN" + save.party[1].hp = 20 + save.poisonSteps = 3 + local game = { data = Data, save = save, stack = { push = function() end } } + setUpvalue(OW.applyFieldPoison, "Game", game) + local self_ = setmetatable({}, { __index = OW }) + self_:applyFieldPoison() + eq(self_.poisonFlash, 4, "a poison tick arms four frames of flicker") + package.loaded["src.core.Sound"] = realSound +end + +do + local rects = {} + local savedRect = love.graphics.rectangle + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h } + end + local savedShader = PaletteFX.shader + PaletteFX.shader = function() return { send = function() end } end + + local renderer = {} + setUpvalue(OW.drawUI, "Game", { renderer = renderer }) + local self_ = setmetatable({ poisonFlash = 4 }, { __index = OW }) + + PaletteFX.setShadeMap(nil) + self_:drawUI() + eq(#rects, 0, "the poison flicker paints no rect on the UI canvas") + check(PaletteFX.shadeMap() == PaletteFX.POISON_BGP, + "the flicker arms the poison BGP map, which the world and UI blits both read") + eq(self_.poisonFlash, 3, "and ticks the counter down") + + for _ = 1, 3 do + PaletteFX.setShadeMap(nil) + self_:drawUI() + end + eq(self_.poisonFlash, 0, "four draws spend the flicker") + PaletteFX.setShadeMap(nil) + self_:drawUI() + check(PaletteFX.shadeMap() == nil, "a spent flicker arms nothing") + + -- home/fade.asm:66 + self_.poisonFlash = 4 + PaletteFX.setShadeMap(PaletteFX.DARK_BGP) + self_:drawUI() + check(PaletteFX.shadeMap() == PaletteFX.DARK_BGP, + "the flicker leaves a dark map's shade map in place") + eq(#rects, 0, "and still paints no rect") + + PaletteFX.shader = function() return nil end + PaletteFX.setShadeMap(nil) + self_.poisonFlash = 4 + renderer.screenVeil = nil + self_:drawUI() + check(renderer.screenVeil ~= nil and renderer.screenVeil[1] == 0 + and renderer.screenVeil[2] > 0, + "with no shader the flicker becomes a screen-space veil") + eq(#rects, 0, "and still not a 160x144 fill") + + PaletteFX.setShadeMap(nil) + PaletteFX.shader = savedShader + love.graphics.rectangle = savedRect +end diff --git a/tests/engine/poke_flute_battle_turn_bug1882.lua b/tests/engine/poke_flute_battle_turn_bug1882.lua new file mode 100644 index 00000000..a6a0f161 --- /dev/null +++ b/tests/engine/poke_flute_battle_turn_bug1882.lua @@ -0,0 +1,105 @@ +-- pokered engine/items/item_effects.asm:1-3 (#1882) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local ItemEffects = require("src.inventory.ItemEffects") + +local data = { + items = { POKE_FLUTE = { id = "POKE_FLUTE", index = 49, name = "POKé FLUTE" }, + POTION = { id = "POTION", index = 1, name = "POTION", price = 300 } }, + text = {}, +} + +local function newSave(status) + return { + party = { { species = "CHARMANDER", hp = 20, stats = { hp = 20 }, + level = 12, status = status } }, + player = { name = "RED" }, + inventory = { POKE_FLUTE = 1, POTION = 1 }, + options = {}, flags = {}, + } +end + +local function newBattle(save, enemyStatus) + return { + player = { mon = save.party[1] }, + enemy = { mon = { species = "PIDGEY", hp = 15, stats = { hp = 15 }, + level = 8, status = enemyStatus } }, + enemyParty = {}, + } +end + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local result, msgs = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "kept", "nothing asleep still takes the turn") + check(msgs and msgs[1] and msgs[1]:find("catchy"), + "and prints PlayedFluteNoEffectText") + eq(save.inventory.POKE_FLUTE, 1, "the flute is a key item, never consumed") +end + +do + local save = newSave("SLP") + local battle = newBattle(save, nil) + local result = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "flute", "a sleeping party mon is the had-effect arm") + eq(save.party[1].status, nil, "WakeUpEntireParty cleared the sleep") +end + +do + local save = newSave(nil) + local battle = newBattle(save, "SLP") + local result = ItemEffects.use(data, save, "POKE_FLUTE", nil, battle) + eq(result, "flute", "a sleeping active enemy is the had-effect arm too") +end + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local result = ItemEffects.use(data, save, "POTION", save.party[1], battle) + eq(result, "failed", "a POTION on a full-HP mon still costs no turn") +end + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, + soundOpts = function(_, _, opts) return opts end, +} +package.loaded["src.ui.BagMenu"] = nil +local BagMenu = require("src.ui.BagMenu") + +do + local save = newSave(nil) + local battle = newBattle(save, nil) + local turns = 0 + battle.itemUsed = function() turns = turns + 1 end + local game = { data = data, save = save } + game.stack = { states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end } + local list = BagMenu.new(game, { battle = battle }) + game.stack:push(list) + local row + for i, item in ipairs(list.items) do + if item.value == "POKE_FLUTE" then row = i end + end + if check(row ~= nil, "the flute is in the battle bag") then + list.index = row + list.onChoose(list.items[row], list) + local box = game.stack:top() + if check(box and box.textBox, "the no-effect line is up") then + eq(turns, 0, "the turn waits on the message") + game.stack:pop() + box.done() + eq(turns, 1, "dismissing it spends the turn") + eq(game.stack:top(), nil, "and the bag is gone, not back on ITEMS") + end + end +end + +T.finish("poke_flute_battle_turn_bug1882") diff --git a/tests/engine/poke_flute_prompt_bug1880.lua b/tests/engine/poke_flute_prompt_bug1880.lua new file mode 100644 index 00000000..cceb0181 --- /dev/null +++ b/tests/engine/poke_flute_prompt_bug1880.lua @@ -0,0 +1,101 @@ +-- pokered engine/items/item_effects.asm:1794 (#1880) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local Data = T.fixtures.fresh() +require("src.render.Font").load(Data) +local TextBox = require("src.render.TextBox") + +local plays = {} +local sources = {} +local function newSource(file) + local src = sources[file] + if src then return src end + src = { + playing = false, + setVolume = function() end, + setPitch = function() end, + stop = function() end, + isPlaying = function(self) return self.playing end, + play = function(self) + plays[#plays + 1] = file + self.playing = true + end, + } + sources[file] = src + return src +end +love.audio = { newSource = newSource } +Data.audio = { sfx = { Press_AB = "ab.wav", Pokeflute = "flute.wav" }, + fanfares = {} } + +local function tunes() + local n = 0 + for _, file in ipairs(plays) do + if file == "flute.wav" then n = n + 1 end + end + return n +end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() return table.remove(self.states) end +function stack:top() return self.states[#self.states] end + +local pressed = {} +local game = { + data = Data, + save = { player = { name = "RED" }, options = { textSpeed = 1 } }, + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} + +local function step(btn) + pressed = btn and { [btn] = true } or {} + local top = stack:top() + if top then top:update(1 / 60) end + pressed = {} +end + +local woke = 0 +local opts = TextBox.soundOpts(game, "Pokeflute", + { auto = { wait = false, delay = 0, promptFirst = true } }) +local box = TextBox.new(game, "RED PLAYED THE\nPOKE FLUTE", + function() woke = woke + 1 end, opts) +stack:push(box) + +for _ = 1, 2000 do + if box.done then break end + step(box.waiting and "a" or nil) +end +check(box.done, "the played-flute line typed out") +eq(tunes(), 0, "the tune has not started yet") + +step() +eq(tunes(), 0, "still silent while the prompt is up") +eq(stack:top(), box, "and the box is still on screen") +eq(woke, 0, "the woke-up script has not started") + +step("a") +eq(box.autoPrompted, true, "A answers the prompt") +step() +eq(tunes(), 1, "and the tune starts after it") +eq(box.autoSrc, sources["flute.wav"], "the box holds the flute source") +eq(stack:top(), box, "the played-flute line stays up under it") + +for _ = 1, 30 do step("a") end +eq(tunes(), 1, "A neither retriggers nor cuts the tune short") +eq(stack:top(), box, "and cannot dismiss the box") +eq(woke, 0, "so Snorlax does not wake mid-tune") + +sources["flute.wav"].playing = false +step() +eq(stack:top(), nil, "the box pops itself once the tune is over") +eq(woke, 1, "and the woke-up script runs, with no second button") + +T.finish("poke_flute_prompt_bug1880") diff --git a/tests/engine/pokedex_area_bug1878.lua b/tests/engine/pokedex_area_bug1878.lua new file mode 100644 index 00000000..41c9e657 --- /dev/null +++ b/tests/engine/pokedex_area_bug1878.lua @@ -0,0 +1,133 @@ +-- engine/items/town_map.asm:124, 403 (#1878) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Font = require("src.render.Font") +local TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + ROUTE_2 = { x = 2, y = 6, name = "ROUTE 2" }, + VIRIDIAN_FOREST = { x = 2, y = 4, name = "VIRIDIAN FOREST" }, + CERULEAN_CAVE_1F = { x = 9, y = 1, name = "CERULEAN CAVE" }, + PALLET_TOWN = { x = 1, y = 12, name = "PALLET TOWN" }, +} + +local function grass(species) + return { grass = { slots = { { species = species, level = 3 } } } } +end + +local function newGame(encounters) + return { + data = { + field = { + townMap = { + locations = LOCATIONS, + background = { + map = { 1 }, + tiles = { path = "assets/generated/townmap/tiles.png" }, + }, + }, + playerSprites = { walk = "SPRITE_RED" }, + }, + sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } }, + pokemon = { + BULBASAUR = { name = "BULBASAUR" }, + CATERPIE = { name = "CATERPIE" }, + MEWTWO = { name = "MEWTWO" }, + }, + maps = {}, + encounters = encounters, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + } +end + +local function capture(tm) + local texts, draws, boxes = {}, {}, {} + local realDraw = love.graphics.draw + local realText, realBox = Font.draw, Font.drawBox + love.graphics.draw = function(img, quadOrX, x, y) + draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y } + end + Font.draw = function(text, x, y) + texts[#texts + 1] = { text = text, x = x, y = y } + return 0 + end + Font.drawBox = function(tx, ty, tw, th) + boxes[#boxes + 1] = { tx = tx, ty = ty, tw = tw, th = th } + end + tm.blink = 0 + tm:draw() + love.graphics.draw, Font.draw, Font.drawBox = realDraw, realText, realBox + return texts, draws, boxes +end + +local function findText(texts, want) + for _, t in ipairs(texts) do + if t.text == want then return t end + end + return nil +end + +local tm = TownMap.new(newGame({}), { nestSpecies = "BULBASAUR" }) +eq(#tm.nests, 0, "no encounters means no nests") +check(tm.playerLoc ~= nil, "the player's location still resolves in AREA mode") + +local texts, draws, boxes = capture(tm) +check(findText(texts, "BULBASAUR's NEST") ~= nil, + "row 0 always reads \"'s NEST\"") +check(findText(texts, "BULBASAUR AREA UNKNOWN") == nil, + "the cropped concatenated title is gone") +local unknown = findText(texts, " AREA UNKNOWN") +check(unknown ~= nil, "AREA UNKNOWN is printed in the middle of the map") +if unknown then + eq(unknown.x, 16, "AreaUnknownText sits at hlcoord 2, 9") + eq(unknown.y, 72, "AreaUnknownText sits at hlcoord 2, 9") +end +local box +for _, b in ipairs(boxes) do + if b.tx == 1 and b.ty == 7 then box = b end +end +check(box ~= nil, "TextBoxBorder at hlcoord 1, 7") +if box then + eq(box.tw, 17, "the border is 15 interior columns wide") + eq(box.th, 4, "the border is 2 interior rows tall") +end +local drewPlayer = false +for _, d in ipairs(draws) do + if d.img == tm.playerSheet then drewPlayer = true end +end +check(not drewPlayer, + "DrawPlayerOrBirdSprite is skipped when no nest OAM was written") + +local tm2 = TownMap.new( + newGame({ ROUTE_2 = grass("CATERPIE"), VIRIDIAN_FOREST = grass("CATERPIE") }), + { nestSpecies = "CATERPIE" }) +eq(#tm2.nests, 2, "ROUTE 2 and VIRIDIAN FOREST are separate nest squares") + +local texts2, draws2, boxes2 = capture(tm2) +check(findText(texts2, "CATERPIE's NEST") ~= nil, "the title is unchanged") +check(findText(texts2, " AREA UNKNOWN") == nil, + "no AREA UNKNOWN text once a nest was drawn") +eq(#boxes2, 0, "no mid-screen box once a nest was drawn") +local playerDraw +for _, d in ipairs(draws2) do + if d.img == tm2.playerSheet and d.quad == tm2.playerQuad then playerDraw = d end +end +check(playerDraw ~= nil, "the player's walk sprite is drawn on the AREA map") +if playerDraw then + -- town_map.asm:454 + eq(playerDraw.x, tm2.playerLoc.x * 8 + 16 - 4, "player x is markerXY - 4") + eq(playerDraw.y, tm2.playerLoc.y * 8 + 8 - 3, "player y is markerXY - 3") +end + +local tm3 = TownMap.new( + newGame({ CERULEAN_CAVE_1F = grass("MEWTWO") }), { nestSpecies = "MEWTWO" }) +eq(#tm3.nests, 0, "packed coords $19 (Cerulean Cave) are skipped") + +T.finish("pokedex area bug 1878") diff --git a/tests/engine/prize_window_bug1867.lua b/tests/engine/prize_window_bug1867.lua new file mode 100644 index 00000000..6149bcc0 --- /dev/null +++ b/tests/engine/prize_window_bug1867.lua @@ -0,0 +1,214 @@ +-- engine/events/prize_menu.asm (#1867) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local realFont = package.loaded["src.render.Font"] +local realTextBox = package.loaded["src.render.TextBox"] +local calls = {} +local FontStub +FontStub = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, +} +package.loaded["src.render.Font"] = FontStub +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts, isTextBox = true } + end, +} +package.loaded["src.core.Sound"] = { play = function() end } +local version = "red" +package.loaded["src.core.GameVersion"] = { + isBlue = function() return version == "blue" end, + isYellow = function() return version == "yellow" end, +} +package.loaded["src.ui.PrizeCounter"] = nil +package.loaded["src.ui.Theme"] = nil +local PrizeCounter = require("src.ui.PrizeCounter") + +local M = assert(loadfile("data/scripts/story3.lua"))() +local counters = M.GAME_CORNER_PRIZE_ROOM.talk + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed, done +local function mkGame(coins) + done = false + local mons = {} + for _, n in ipairs({ "ABRA", "CLEFAIRY", "NIDORINA", "VULPIX", + "WIGGLYTUFF" }) do + mons[n] = { name = n } + end + return { + data = { + text = {}, + pokemon = mons, + items = { + TM_DRAGON_RAGE = { name = "TM23" }, + TM_HYPER_BEAM = { name = "TM15" }, + TM_SUBSTITUTE = { name = "TM35" }, + }, + }, + save = { coins = coins, inventory = { COIN_CASE = 1 } }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, + } +end + +local function open(n, coins) + local game = mkGame(coins or 5000) + counters["TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_" .. n]( + game, nil, nil, function() done = true end) + local exchange = game.stack:pop() + exchange.onDone() + local which = game.stack:top() + which.opts.stay.onShown() + return game, which, game.stack:top() +end + +do + local game, which, window = open(1) + check(tostring(which.text):find("Which prize", 1, true) ~= nil, + "WhichPrizeText goes up before the window") + check(which.opts.stay ~= nil and which.opts.stay.prompt == nil, + "it ends in `done`: no prompt, and PrintText leaves it up") + check(getmetatable(window) == PrizeCounter, + "the prize window is the CeladonPrizeMenu overlay") + check(window.isOpaque ~= true, + "so the prize room keeps drawing around it") + + local names = {} + for i, row in ipairs(window.prizes) do names[i] = row.name end + eq(#window.prizes, 3, "one window is three prizes (#623)") + eq(table.concat(names, ","), "ABRA,CLEFAIRY,NIDORINA", + "Red's first window, by GetMonName") + for _, row in ipairs(window.prizes) do + check(tostring(row.name):find("L%d") == nil, + row.name .. " carries no level: GetPrizeMonLevel is not menu chrome") + end + eq(window.prizes[1].cost, 180, "with prizes.asm's coin prices") + eq(window.prizes[3].cost, 1200, "including the third row's") + + calls = {} + window:draw() + check(found("box", function(c) + return c[2] == 11 and c[3] == 0 and c[4] == 9 and c[5] == 3 + end) ~= nil, "PrintPrizePrice's coin box is 9x3 at hlcoord 11,0") + check(found("draw", function(c) + return c[2] == "COIN" and c[3] == 96 and c[4] == 0 + end) ~= nil, "with COIN on its top border at hlcoord 12,0") + local coins = found("draw", function(c) return c[2] == "5000" end) + if check(coins ~= nil, "and the balance on the row below") then + eq(coins[3] + FontStub.width("5000"), 136, + "right-aligned through the BCD field (hlcoord 13,1)") + eq(coins[4], 8, "on the box's middle row") + end + check(found("box", function(c) + return c[2] == 0 and c[3] == 2 and c[4] == 18 and c[5] == 10 + end) ~= nil, "the prize box is 18x10 at hlcoord 0,2, clear of columns 18-19") + for i, name in ipairs({ "ABRA", "CLEFAIRY", "NIDORINA" }) do + local y = 32 + (i - 1) * 16 + check(found("draw", function(c) + return c[2] == name and c[3] == 16 and c[4] == y + end) ~= nil, name .. " sits at (16, " .. y .. ")") + local cost = found("draw", function(c) + return c[2] == tostring(window.prizes[i].cost) + end) + if check(cost ~= nil, "its price is drawn") then + eq(cost[4], y + 8, "one tile row below the name (hlcoord 13, 5/7/9)") + eq(cost[3] + FontStub.width(cost[2]), 136, "right-aligned with the coins") + end + end + check(found("draw", function(c) + return c[2] == "NO THANKS" and c[3] == 16 and c[4] == 80 + end) ~= nil, "NoThanksText is the fourth row at hlcoord 2,10") + check(found("draw", function(c) return c[2] == "PRIZES (COINS)" end) == nil, + "and there is no full-screen page title") + + pressed = "up" + window:update(1 / 60) + eq(window.index, 1, "Up on the first prize does nothing") + pressed = "down" + for _ = 1, 6 do window:update(1 / 60) end + eq(window.index, 4, "Down stops on NO THANKS, the fourth row") + pressed = "a" + window:update(1 / 60) + pressed = nil + check(done, "A on NO THANKS ends the conversation (cp 3 -> .noChoice)") + eq(#game.stack.states, 0, + "with the window and WhichPrizeText both taken down") +end + +do + local game, _, window = open(3) + calls = {} + window:draw() + eq(window.prizes[1].name, "TM23", "vendor 3 is GetItemName, not GetMonName") + eq(window.prizes[3].cost, 7700, "PrizeMenuTMsEntries prices") + check(found("draw", function(c) + return c[2] == "3300" and c[4] == 40 + end) ~= nil, "TM prices sit on the row below their name too") +end + +do + version = "yellow" + local game, _, window = open(1) + local names = {} + for i, row in ipairs(window.prizes) do names[i] = row.name end + eq(table.concat(names, ","), "ABRA,VULPIX,WIGGLYTUFF", + "pokeyellow/data/events/prizes.asm restocks window 1") + eq(window.prizes[3].cost, 2680, "with Yellow's prices") + version = "red" +end + +do + local game, _, window = open(1, 5000) + window.index = 1 + pressed = "a" + window:update(1 / 60) + pressed = nil + local ask = game.stack:top() + check(ask.isTextBox and tostring(ask.text):find("ABRA", 1, true) ~= nil, + "SoYouWantPrizeText names the prize out of wNameBuffer") + check(tostring(ask.text):find("wNameBuffer", 1, true) == nil, + "with the token filled in") + check(ask.opts.choice ~= nil, "and a YES/NO over it") + eq(game.stack.states[#game.stack.states - 1], window, + "the window is still up underneath") + eq(game.save.coins, 5000, "no coins move before the answer") +end + +package.loaded["src.render.Font"] = realFont +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Sound"] = nil +package.loaded["src.core.GameVersion"] = nil +package.loaded["src.ui.PrizeCounter"] = nil +package.loaded["src.ui.Theme"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/rom_pick_tv_cancel.lua b/tests/engine/rom_pick_tv_cancel.lua new file mode 100644 index 00000000..ce6aec7e --- /dev/null +++ b/tests/engine/rom_pick_tv_cancel.lua @@ -0,0 +1,83 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local RomImporter = require("src.import.RomImporter") + +love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end +love.system = { + getOS = function() return "Android" end, + pickFile = function() return true end, +} + +local function detail(ri) return tostring(ri.detail or "") end + +local function clearSaveDir() + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + love.filesystem.remove(name) + end +end + +local function freshImporter() + return setmetatable({ + android = true, + launcher = true, + workState = nil, + tab = "red", + ready = { red = false, blue = false, yellow = false }, + saveNotice = {}, + modNotice = nil, + notice = nil, + slotScroll = {}, + activeSlot = {}, + }, RomImporter) +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_rom.gb") + local ri = freshImporter() + ri:focus(true) + eq(ri.workState, "error", "an empty TV pick reports instead of staying silent") + check(detail(ri):find("did not return a file", 1, true), + "the notice blames the file manager, not an unreadable file") + check(not detail(ri):find("Files (Documents) app", 1, true), + "and does not send a TV player to an app the device does not have") + check(detail(ri):find("/sdcard/pokeport/save", 1, true), + "the save dir stays offered as the copy-it-yourself fallback") + eq(love.filesystem.getInfo("pick_error.flag"), nil, "the flag is consumed") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_mod.zip") + local ri = freshImporter() + ri:focus(true) + check(ri.modNotice ~= nil and ri.modNotice.ok == false, + "the prefix still routes a mod pick to the mods panel") + eq(ri.workState, nil, "and leaves the ROM panel alone") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "cancelled:picked_save.sav") + local ri = freshImporter() + ri.androidPendingVersion = "blue" + ri:focus(true) + check(ri.saveNotice.blue ~= nil and ri.saveNotice.blue.ok == false, + "and a save pick to the game it was picked for") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "picked_rom.gb") + local ri = freshImporter() + ri:focus(true) + check(detail(ri):find("Could not read the picked file", 1, true), + "an unprefixed flag still reads as the #442 unreadable pick") +end + +clearSaveDir() +T.finish() diff --git a/tests/engine/save_convert_extra_flags.lua b/tests/engine/save_convert_extra_flags.lua index 24fd846f..1798b631 100644 --- a/tests/engine/save_convert_extra_flags.lua +++ b/tests/engine/save_convert_extra_flags.lua @@ -35,6 +35,7 @@ local data = { maps = loadfile("data/generated/maps.lua")(), eventFlags = events, } +loadfile("tests/fixture_data/map_window.lua")()(data, "REDS_HOUSE_2F") -- ------------------------------------------------------------------ -- offsets, walked forward from wTownVisitedFlag over ram/wram.asm's own diff --git a/tests/engine/save_convert_toggle_objects.lua b/tests/engine/save_convert_toggle_objects.lua index 219dc43b..4ab0ef2f 100644 --- a/tests/engine/save_convert_toggle_objects.lua +++ b/tests/engine/save_convert_toggle_objects.lua @@ -39,6 +39,8 @@ local data = { eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), toggleObjects = toggles, } +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +stampMapWindow(data, "REDS_HOUSE_2F") -- ------------------------------------------------------------------ -- offset pins, independent of the codec's own arithmetic: wram.asm places @@ -175,6 +177,7 @@ local dataYellow = { eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(), gameVersion = "yellow", } +stampMapWindow(dataYellow, "REDS_HOUSE_2F") local ySave = seedSave() ySave.pikachuHappiness = 200 local yBytes = GenSave.encode(ySave, dataYellow, nil) diff --git a/tests/engine/save_export_map_window_bug1691.lua b/tests/engine/save_export_map_window_bug1691.lua new file mode 100644 index 00000000..a009dbbd --- /dev/null +++ b/tests/engine/save_export_map_window_bug1691.lua @@ -0,0 +1,88 @@ +-- home/overworld.asm:2016 (#1691) +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local MapContext = require("src.save_convert.MapContext") +local GenSave = require("src.save_convert.GenSave") + +local O = MapContext.OFFSETS +local SAVE = GenSave.OFFSETS +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local function fixtureData() + local maps = {} + for id, map in pairs(dofile("tests/fixture_data/maps.lua")) do + local copy = {} + for k, v in pairs(map) do copy[k] = v end + maps[id] = copy + end + local data = { + maps = maps, + pokemon = dofile("tests/fixture_data/pokemon.lua"), + moves = dofile("tests/fixture_data/moves.lua"), + items = dofile("tests/fixture_data/items.lua"), + } + return stampMapWindow(data, "FIX_TOWN") +end + +local function newSave() + return { + player = { name = "RED", rival = "BLUE", map = "FIX_TOWN", x = 7, y = 4 }, + money = 3000, inventory = {}, pokedex = { seen = {}, owned = {} }, + flags = {}, party = {}, boxes = {}, + } +end + +do + local bytes = GenSave.encode(newSave(), fixtureData(), nil) + T.eq(#bytes, GenSave.SAVE_SIZE, "a complete cache still exports") + T.check(bytes:byte(SAVE.mainData + O.curMapHeader + 3) ~= 0, + "with a map width the game can walk") + T.check(bytes:byte(SAVE.mainData + O.tilesetHeader + 1) ~= 0, + "and a tileset bank that is not bank 0") +end + +local CASES = { + { + label = "no saved-map bytes", + want = "saved%-map bytes", + strip = function(data) data.maps.FIX_TOWN.sram = nil end, + }, + { + label = "no tileset header", + want = "tileset bytes", + strip = function(data) data.tilesets = {} end, + }, + { + label = "no map music", + want = "map music", + strip = function(data) data.audio = {} end, + }, +} + +for _, case in ipairs(CASES) do + local data = fixtureData() + case.strip(data) + local ok, err = pcall(GenSave.encode, newSave(), data, nil) + T.eq(ok, false, case.label .. ": the export is refused, not written") + T.check(type(err) == "string" and err:find(case.want) ~= nil, + case.label .. ": and says what is missing -- " .. tostring(err)) + T.check(type(err) == "string" and err:find("re%-import the ROM") ~= nil, + case.label .. ": and what to do about it -- " .. tostring(err)) +end + +do + local data = fixtureData() + local template = GenSave.encode(newSave(), data, nil) + local moved = template:sub(1, SAVE.curMap) .. string.char(0xFE) + .. template:sub(SAVE.curMap + 2) + data.maps.FIX_TOWN.sram = nil + local ok, err = pcall(GenSave.encode, newSave(), data, moved) + T.eq(ok, false, "a stale template is refused rather than exported as it is") + T.check(type(err) == "string" and err:find("saved%-map bytes") ~= nil, + "for the same missing bytes -- " .. tostring(err)) +end + +T.finish("save export map window bug1691") diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index d452c1f5..123c6e50 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -67,6 +67,14 @@ local data = { maps = loadfile("data/generated/maps.lua")(), eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), } +-- home/overworld.asm:2016 (#1691) +local cacheHasMapWindow = (data.maps.REDS_HOUSE_2F or {}).sram ~= nil +if not cacheHasMapWindow then + print("save_file_io export cases skipped (this ROM cache predates the saved-map " + .. "bytes; re-import the ROM to run them)") +end +local stampMapWindow = loadfile("tests/fixture_data/map_window.lua")() +for mapId in pairs(data.maps) do stampMapWindow(data, mapId) end -- independent checksum re-derivation (complement of the additive byte sum) so -- the export sanity check does not trust the encoder that wrote it @@ -393,6 +401,7 @@ do local function assertLoadable(label, mapId, x, y) local def = data.maps[mapId] if not def then return end -- this data set lacks the map; skip silently + if not cacheHasMapWindow then return end local out = exportedThrough(mapId, x, y) check(out ~= nil and #out == GenSave.SAVE_SIZE, label .. ": exports a 32768-byte image through the launcher path") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index 3ef6dab9..09fd6524 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -493,9 +493,49 @@ do }, {}, state) eng:unlink() pump(eng, 3) - T.eq(eng.phase, "error", "a failed revocation is surfaced") - T.eq(eng:linked(), true, - "and the device stays linked rather than lying about it") + T.eq(eng:linked(), false, + "a server that refuses the revocation still unlinks this device") + T.neq(eng.phase, "error", "so the player is not left stuck on a red status") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local transport = { + begin = function() return 1 end, + poll = function() + return { status = "error", + err = "fetch failed for http://sync.test/sync/unlink: " .. + "curl: (28) timed out" } + end, + release = function() end, + } + local eng = SyncEngine.new({ + baseUrl = "http://sync.test", transport = transport, state = state, + saves = fakeSaves({}), persist = false, + now = function() return 1700001000 end, + }) + eng:unlink() + pump(eng, 3) + T.eq(eng:linked(), false, + "an unreachable server does not trap the device as linked") + T.eq(eng.status, "Not set up", "and the modal says the device is unlinked") +end + +do + local state = linkedState() + state.deviceId = "0a1b2c3d" + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, body = '{"saves":{}}' }, + ["POST /sync/unlink"] = { code = 200, body = '{"ok":true,"devices":1}' }, + }, {}, state) + eng.pending = { handle = 99 } + T.eq(eng:busy(), true, "a request is in flight") + eng:unlink() + T.eq(transport.sent[1].url, "http://sync.test/sync/unlink", + "unlink cancels it instead of refusing as busy") + pump(eng, 3) + T.eq(eng:linked(), false, "and the device is unlinked") end do @@ -710,4 +750,102 @@ do "an apply that never asked imports nothing: silence is not consent") end +do + T.eq(SyncEngine.unixSeconds(0), nil, "a zero stamp is no stamp") + T.eq(SyncEngine.unixSeconds(-5), nil, "and neither is a negative one") + T.eq(SyncEngine.unixSeconds(1700000000), 1700000000, "a real stamp survives") + T.eq(SyncEngine.displayMeta({ savedAt = 0, sessionStart = 0 }).savedAt, nil, + "the prompt is never handed a stamp it would date 1969") + T.eq(SyncEngine.overlaps({ sessionStart = 0, savedAt = 0 }, + { sessionStart = 0, savedAt = 0 }), false, + "two server-zeroed windows do not overlap in 1970") + T.eq(SyncEngine.samePlaytime({ playTime = 0 }, { playTime = 0 }), false, + "a server-zeroed playtime is unknown, not zero minutes") +end + +do + local SaveData = require("src.core.SaveData") + local raw = "return { player = { name = 'GOLD' }, savedAt = 1700000500 }" + local slots = { { id = "slot1", exists = true } } + local realList, realRead, realDecode, realSummary, realOptions = + SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, + SaveData.slotSummary, SaveData.loadOptions + SaveData.listSlots = function(version) + return version == "gold" and slots or {} + end + SaveData.readSlotSource = function() return raw end + SaveData.decode = function() return { player = { name = "GOLD" }, + savedAt = 1700000500 } end + SaveData.slotSummary = function() + return "GOLD", { badges = 8, timeText = "3:46", dexCount = 9 } + end + SaveData.loadOptions = function() + return { playthroughIds = { gold = { slot1 = "xyz" } } } + end + local list = SyncEngine.defaultSaves().list() + SaveData.listSlots, SaveData.readSlotSource, SaveData.decode, + SaveData.slotSummary, SaveData.loadOptions = + realList, realRead, realDecode, realSummary, realOptions + T.eq(#list, 1, "the Gold slot is listed") + T.eq(list[1].meta.savedAt, 1700000500, + "with the stamp gen2/Save.lua actually writes") +end + +do + local state = linkedState() + local entry = saveEntry("gold", "xyz", 1700000500, nil) + entry.meta.summary = { name = "GOLD", badges = 8, timeText = "3:46", + dexCount = 9 } + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0,' .. + '"summary":{"name":"GOLD","badges":8,"timeText":"3:46",' .. + '"dexCount":9}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":5}' }, + }, { entry }, state) + + eng:syncNow() + pump(eng) + T.eq(#eng.conflicts, 0, + "the same playthrough with an epoch-dated server row is not a fork") + T.neq(eng.phase, "conflict", "so no duplicate-save prompt is raised") + T.eq(SyncState.stamp(eng.state, "gold/xyz"), 1700000500, + "and the upload leaves a stamp behind") + local put + for _, req in ipairs(transport.sent) do + if req.method == "PUT" then put = req end + end + T.check(put ~= nil, "this device's copy is pushed instead") +end + +do + local state = linkedState() + SyncState.setRev(state, "gold/xyz", 4, 1700000500) + local entry = saveEntry("gold", "xyz", 1700000500, nil) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, + }, { entry }, state) + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, + "a Gold save at the rev it was uploaded at syncs no further") + T.eq(eng.phase, "idle", "and the second boot is idle") +end + +do + local state = linkedState() + SyncState.setRev(state, "gold/xyz", 4, nil) + local entry = saveEntry("gold", "xyz", nil, nil) + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"gold/xyz":{"rev":4,"meta":{"savedAt":0}}}}' }, + }, { entry }, state) + eng:syncNow() + pump(eng) + T.eq(#transport.sent, 1, + "an older Gold save with no stamp on either side is not dirty every boot") + T.eq(eng.phase, "idle", "so the launcher settles") +end + T.finish("sync_engine") diff --git a/tests/engine/timing_parity.lua b/tests/engine/timing_parity.lua index cddee324..85bb35fb 100644 --- a/tests/engine/timing_parity.lua +++ b/tests/engine/timing_parity.lua @@ -317,6 +317,48 @@ do "the anim row pays Delay3 before it plays") T.eq(#b.queue, 1, "and is put back on the queue to run after the hold") T.check(b.queue[1].animDelayed, "flagged so the hold is paid only once") + b.waitFrames = nil + b:updateQueue() + T.check(b.waitFrames ~= Timing.MOVE_ANIM_OFF, + "with animations on the disabled arm's 30 is never paid") +end + +-- animations.asm:431-437 +T.eq(Timing.MOVE_ANIM_OFF, 30, "the animations-off arm holds 30 frames") +do + local b = newBattle() + b.game.save.options.animations = false + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b.queue[1] = { anim = "FIX_TACKLE", attackerIsPlayer = true, + hit = { animType = 4, blink = b.enemy } } + b:updateQueue() + T.eq(b.waitFrames, Timing.MOVE_ANIM_PRE, "Delay3 is paid whatever the option") + b.waitFrames = nil + b:updateQueue() + T.eq(b.waitFrames, Timing.MOVE_ANIM_OFF, "then the 30 stands in for the anim") + T.eq(#b.queue, 1, "and the row goes back for the applying-attack layer") + T.check(b.queue[1].animOffDelayed, "flagged so the hold is paid only once") + b.waitFrames = nil + b:updateQueue() + T.eq(b.waitFrames, Timing.BLINK_MON, + "the enemy blink still runs with animations off (#1384)") + T.check(b.fx and b.fx.blink ~= nil, "and it targets the enemy pic") +end + +-- animations.asm:415-419 +do + local b = newBattle() + b.game.save.options.animations = false + b.queue, b.nextInsert, b.current = {}, 0, nil + b.waitFrames, b.waitingSound = nil, nil + b.draining, b.animPlaying, b.waitingUI = nil, nil, nil + b.queue[1] = { anim = "POOF_ANIM", attackerIsPlayer = true } + b:updateQueue() + b.waitFrames = nil + b:updateQueue() + T.check(b.waitFrames ~= Timing.MOVE_ANIM_OFF, "a ball anim skips the 30") end -- StartBattle's 40-frame hold is unconditional -- the `call nz` gates only diff --git a/tests/engine/touch_skin_test.lua b/tests/engine/touch_skin_test.lua index 54c698c8..f7a5f8cd 100644 --- a/tests/engine/touch_skin_test.lua +++ b/tests/engine/touch_skin_test.lua @@ -412,6 +412,26 @@ local _, _, sHalfW, sHalfH = TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], 1920, 1080) check(math.abs(sHalfW - sHalfH) < 2, "A stays round on a 16:9 window") +TouchSkin.setSurface(0, 0, LW, LH) +local aCx, aCy, aHalfW, aHalfH = + TouchSkin.controlGeometry(TouchSkin.page(), TouchSkin.page().controls[1], LW, LH) +local aW, aH = aHalfW * 2, aHalfH * 2 +local sx, sy = TouchSkin.imageFit(210, 210, aW, aH) +eq(sx, aW / 210, "desc art scales to the dest width") +eq(sy, aH / 210, "desc art scales to the dest height") +check(210 * sx <= aW + 1e-6 and 210 * sy <= aH + 1e-6, + "the whole bitmap lands inside its dest box") +check(sx >= math.min(aW / 210, aH / 210) - 1e-6, + "and it is not shrunk below a contain fit") +local lx, ly = TouchSkin.imageFit(327, 193, 218, 129) +check(327 * lx <= 218 + 1e-6 and 193 * ly <= 129 + 1e-6, + "a wide shoulder PNG is not cropped by its dest box") +check(TouchSkin.imageFit(0, 0, aW, aH) == nil, "a zero-sized image has no fit") +local wx, wy = TouchSkin.imageFit(100, 50, 200, 200) +eq(wx, 2, "independent X scale, not one uniform cover scale") +eq(wy, 4, "independent Y scale") +check(aCx > 0 and aCy > 0, "landscape A sits inside the page box") + local nativeOrient = TouchSkin.parseNative(TouchSkin.serialize(orient)) check(nativeOrient and nativeOrient.pages[2].aspectFromCfg, "native export keeps the aspect lock") diff --git a/tests/engine/town_map_cursor_bug1868.lua b/tests/engine/town_map_cursor_bug1868.lua new file mode 100644 index 00000000..32842a39 --- /dev/null +++ b/tests/engine/town_map_cursor_bug1868.lua @@ -0,0 +1,90 @@ +-- engine/items/town_map.asm:74 (#1868) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local TownMap = require("src.ui.TownMap") + +local LOCATIONS = { + PALLET_TOWN = { x = 4, y = 12, name = "PALLET TOWN" }, + ROUTE_1 = { x = 4, y = 9, name = "ROUTE 1" }, + VIRIDIAN_CITY = { x = 4, y = 11, name = "VIRIDIAN CITY" }, + SEA_COTTAGE = { x = 12, y = 0, name = "SEA COTTAGE" }, +} +local ORDER = { "PALLET_TOWN", "ROUTE_1", "VIRIDIAN_CITY" } + +local function newGame() + local pressed = {} + local game = { + data = { + field = { + townMap = { locations = LOCATIONS, cursorOrder = ORDER }, + playerSprites = { walk = "SPRITE_RED" }, + }, + sprites = {}, + maps = {}, + }, + save = {}, + overworld = { map = { id = "PALLET_TOWN" } }, + input = { wasPressed = function(_, name) + local p = pressed[name] + pressed[name] = nil + return p + end }, + stack = { pop = function() end }, + } + return game, function(name) pressed[name] = true end +end + +local game, tap = newGame() +local tm = TownMap.new(game, {}) + +eq(tm.mode, "grid", "located entries put the screen in grid mode") +eq(tm.locs[tm.sel].name, "PALLET TOWN", + "the viewer opens on the player's town") + +local reachable = {} +for _, loc in ipairs(tm.locs) do reachable[loc.name] = true end +check(not reachable["SEA COTTAGE"], + "a location outside cursorOrder is not a cursor stop") + +tap("left") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "LEFT does not move the cursor") +tap("right") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "RIGHT does not move the cursor") + +tap("up") tm:update(0) +eq(tm.locs[tm.sel].name, "ROUTE 1", + "UP steps forward through TownMapOrder, not to the nearest square") +tap("up") tm:update(0) +eq(tm.locs[tm.sel].name, "VIRIDIAN CITY", "UP again reaches VIRIDIAN CITY") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "ROUTE 1", "DOWN steps back") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "PALLET TOWN", "DOWN again returns to PALLET TOWN") +tap("down") tm:update(0) +eq(tm.locs[tm.sel].name, "VIRIDIAN CITY", + "DOWN off the top wraps to the last TownMapOrder entry") + +local game2, tap2 = newGame() +game2.data.field.townMap.cursorOrder = nil +local tm2 = TownMap.new(game2, {}) +eq(tm2.mode, "grid", "the screen still works without cursorOrder") +local before = tm2.locs[tm2.sel] +tap2("left") tm2:update(0) +tap2("right") tm2:update(0) +check(tm2.locs[tm2.sel] == before, + "LEFT/RIGHT stay inert on the cursorOrder-less fallback") +tap2("up") tm2:update(0) +check(tm2.locs[tm2.sel] ~= before, "UP still moves on the fallback") + +local game3 = newGame() +game3.overworld.map.id = "SEA_COTTAGE" +local tm3 = TownMap.new(game3, {}) +eq(tm3.locs[tm3.sel].name, "SEA COTTAGE", + "the viewer still opens on a map that is not a TownMapOrder stop") + +T.finish("town map cursor bug 1868") diff --git a/tests/engine/vending_machine_bug1876.lua b/tests/engine/vending_machine_bug1876.lua new file mode 100644 index 00000000..2536b152 --- /dev/null +++ b/tests/engine/vending_machine_bug1876.lua @@ -0,0 +1,214 @@ +-- engine/events/vending_machine.asm (#1876) + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local realFont = package.loaded["src.render.Font"] +local realTextBox = package.loaded["src.render.TextBox"] +local calls = {} +package.loaded["src.render.Font"] = { + BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 }, + draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end, + drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end, + drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end, + width = function(text) return #tostring(text) * 8 end, + split = function(text) + local out = {} + for i = 1, #tostring(text) do out[i] = i end + return out + end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts, isTextBox = true } + end, +} +local sounds = {} +package.loaded["src.core.Sound"] = { + play = function(_, name) sounds[#sounds + 1] = name end, +} +local bagFull = false +local bagged = {} +package.loaded["src.inventory.Bag"] = { + add = function(_, id) + if bagFull then return false end + bagged[#bagged + 1] = id + return true + end, +} +package.loaded["src.ui.Menu"] = nil +package.loaded["src.ui.Theme"] = nil + +local M = assert(loadfile("data/scripts/story4.lua"))() +local machine = M.CELADON_MART_ROOF.talk.TEXT_CELADONMARTROOF_VENDING_MACHINE1 + +local function found(kind, pred) + for _, c in ipairs(calls) do + if c[1] == kind and pred(c) then return c end + end + return nil +end + +local pressed, done +local function mkGame(cash) + sounds, bagged, done = {}, {}, false + return { + data = { + text = { + _VendingMachineText1 = "A vending machine!\nHere's the menu!", + _VendingMachineText4 = "Oops, not enough\nmoney!", + _VendingMachineText5 = "{RAM:wStringBuffer}\npopped out!", + _VendingMachineText6 = "There's no more\nroom for stuff!", + _VendingMachineText7 = "Not thirsty!", + }, + items = { + FRESH_WATER = { name = "FRESH WATER" }, + SODA_POP = { name = "SODA POP" }, + LEMONADE = { name = "LEMONADE" }, + }, + }, + save = { money = cash, inventory = {} }, + input = { + wasPressed = function(_, b) return pressed == b end, + isDown = function() return false end, + }, + stack = { + states = {}, + push = function(self, s) self.states[#self.states + 1] = s end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + }, + } +end + +local function open(cash) + local game = mkGame(cash) + machine(game, nil, nil, function() done = true end) + local intro = game.stack:top() + return game, intro +end + +do + local game, intro = open(3000) + check(intro.isTextBox, "talking to the sign prints VendingMachineText1") + check(tostring(intro.text):find("vending machine", 1, true) ~= nil, + "with the machine's own greeting") + check(intro.opts.money ~= nil, "DisplayTextBoxID MONEY_BOX rides along") + check(intro.opts.stay ~= nil and intro.opts.stay.prompt, + "the greeting prompts, then stays up under the list") + + intro.opts.stay.onShown() + local menu = game.stack:top() + check(menu ~= intro and menu.items ~= nil, "the drink list opens over it") + eq(menu.tx, 0, "TextBoxBorder at hlcoord 0,3") + eq(menu.ty, 3, "row 3") + eq(menu.tw, 14, "14 tiles wide (b=8, c=12)") + eq(menu.th, 10, "10 tiles tall") + eq(#menu.items, 4, "wMaxMenuItem 3: three drinks and CANCEL") + eq(menu.items[4].label, "CANCEL", "CANCEL is the last row") + eq(menu.noWrap, true, + "wMenuWrappingEnabled is never set (home/window.asm:56-83)") + + calls = {} + menu:draw() + for i, name in ipairs({ "FRESH WATER", "SODA POP", "LEMONADE", "CANCEL" }) do + local y = (5 + (i - 1) * 2) * 8 + check(found("draw", function(c) + return c[2] == name and c[3] == 16 and c[4] == y + end) ~= nil, name .. " sits at (16, " .. y .. ")") + end + for i, price in ipairs({ "¥200", "¥300", "¥350" }) do + local y = (6 + (i - 1) * 2) * 8 + check(found("draw", function(c) + return c[2] == price and c[3] == 72 and c[4] == y + end) ~= nil, price .. " sits a row under its drink at (72, " .. y .. ")") + end +end + +do + local game, intro = open(3000) + intro.opts.stay.onShown() + local menu = game.stack:top() + menu.items[1].onSelect() + local waiter = game.stack:top() + check(waiter ~= menu and waiter.update ~= nil, + "the delivery rumble runs before the text") + eq(game.save.money, 3000, "money is still untouched during the rumble") + for _ = 1, 120 do + if game.stack:top() ~= waiter then break end + waiter:update(1 / 60) + end + eq(#sounds, 60, "SFX_PUSH_BOULDER restarts 60 times") + eq(sounds[1], "Push_Boulder", "and it is the boulder push, not Cut") + check(bagged[1] == "FRESH_WATER", "the drink lands in the bag") + eq(game.save.money, 2800, "SubBCDPredef takes the drink's price") + local result = game.stack:top() + check(result.isTextBox and tostring(result.text):find("popped out") ~= nil, + "then VendingMachineText5 names the drink") + check(tostring(result.text):find("FRESH WATER", 1, true) ~= nil, + "out of wStringBuffer") + check(result.opts ~= nil and result.opts.money ~= nil, + "the money box is redrawn with the new balance") + eq(#game.stack.states, 1, + "the drink list and the greeting are gone by then") + result.onDone() + check(done, "dismissing it hands control back to the script") +end + +do + local game, intro = open(3000) + intro.opts.stay.onShown() + local menu = game.stack:top() + menu.index = 4 + pressed = "a" + menu:update(1 / 60) + pressed = nil + local result = game.stack:top() + check(result.isTextBox and tostring(result.text):find("thirsty") ~= nil, + "CANCEL prints VendingMachineText7") + eq(#game.stack.states, 1, "with the list and the greeting closed") + eq(#bagged, 0, "and nothing bought") + + game, intro = open(3000) + intro.opts.stay.onShown() + menu = game.stack:top() + pressed = "b" + menu:update(1 / 60) + pressed = nil + check(tostring(game.stack:top().text):find("thirsty") ~= nil, + "B does the same (.notThirsty)") +end + +do + local game, intro = open(100) + intro.opts.stay.onShown() + game.stack:top().items[1].onSelect() + local result = game.stack:top() + check(tostring(result.text):find("enough", 1, true) ~= nil, + "no money prints VendingMachineText4") + eq(game.save.money, 100, "and spends nothing") + eq(#bagged, 0, "and hands over no drink") + + bagFull = true + game, intro = open(3000) + intro.opts.stay.onShown() + game.stack:top().items[1].onSelect() + result = game.stack:top() + check(tostring(result.text):find("room for stuff", 1, true) ~= nil, + "a full bag prints VendingMachineText6") + eq(game.save.money, 3000, "with the money untouched (.BagFull)") + bagFull = false +end + +package.loaded["src.render.Font"] = realFont +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Sound"] = nil +package.loaded["src.inventory.Bag"] = nil +package.loaded["src.ui.Menu"] = nil +package.loaded["src.ui.Theme"] = nil +require("src.ui.Screens").invalidate() + +T.finish() diff --git a/tests/engine/version_dev_identity_test.lua b/tests/engine/version_dev_identity_test.lua new file mode 100644 index 00000000..735fb491 --- /dev/null +++ b/tests/engine/version_dev_identity_test.lua @@ -0,0 +1,109 @@ +-- Unstamped builds derive "0.0.0-dev+" so two checkouts never pair as +-- `full` on an exact engineVersion match (online plan 0.9). +-- luajit tests/engine/version_dev_identity_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check = T.check +local eq = T.eq + +local Version = require("src.core.Version") +local Handshake = require("src.link.Handshake") + +local PLACEHOLDER = "0.0.0-dev" +local HASH = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" + +local root = os.getenv("TMPDIR") or "/tmp" +root = root:gsub("/$", "") .. "/pokeport-version-dev-" .. tostring(os.time()) + +local function write(path, text) + os.execute(("mkdir -p '%s'"):format(path:match("^(.*)/[^/]+$"))) + local handle = assert(io.open(path, "wb")) + handle:write(text) + handle:close() +end + +-- --- ref: HEAD resolved through refs/heads +local repoDir = root .. "/checkout" +write(repoDir .. "/.git/HEAD", "ref: refs/heads/dev\n") +write(repoDir .. "/.git/refs/heads/dev", HASH .. "\n") + +local derived = Version.devEngine(PLACEHOLDER, repoDir) +check(derived:match("^0%.0%.0%-dev%+%x+$") ~= nil, + "derived dev identity is 0.0.0-dev+, got " .. tostring(derived)) +eq(derived, PLACEHOLDER .. "+" .. HASH:sub(1, 12), + "the suffix is the first 12 hex of the resolved commit") +eq(tonumber(derived:match("^(%d+)")), 0, + "semver major still parses as 0 (Handshake.checkCompat major())") + +-- --- a second checkout at another commit derives a different string +local otherDir = root .. "/checkout2" +local OTHER = "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" +write(otherDir .. "/.git/HEAD", "ref: refs/heads/dev\n") +write(otherDir .. "/.git/refs/heads/dev", OTHER .. "\n") +local otherDerived = Version.devEngine(PLACEHOLDER, otherDir) +check(otherDerived ~= derived, "two checkouts derive different engine strings") + +-- --- packed-refs fallback (a fresh clone has no loose ref file) +local packedDir = root .. "/packed" +write(packedDir .. "/.git/HEAD", "ref: refs/heads/dev\n") +write(packedDir .. "/.git/packed-refs", + "# pack-refs with: peeled fully-peeled sorted\n" + .. HASH .. " refs/heads/dev\n") +eq(Version.devEngine(PLACEHOLDER, packedDir), PLACEHOLDER .. "+" .. HASH:sub(1, 12), + "packed-refs resolves the branch when no loose ref exists") + +-- --- detached HEAD holds the hash directly +local detachedDir = root .. "/detached" +write(detachedDir .. "/.git/HEAD", HASH .. "\n") +eq(Version.devEngine(PLACEHOLDER, detachedDir), PLACEHOLDER .. "+" .. HASH:sub(1, 12), + "detached HEAD is read as the commit") + +-- --- no repo, and a stamped release, are both left alone +eq(Version.devEngine(PLACEHOLDER, root .. "/nope"), PLACEHOLDER, + "no .git leaves the placeholder untouched") +eq(Version.devEngine("0.1.73", repoDir), "0.1.73", + "a stamped release version is returned untouched") +eq(Version.devEngine("1.0.0-rc1", repoDir), "1.0.0-rc1", + "only the exact placeholder is derived from") + + +-- --- a packaged build reads its commit out of build-info.json +local savedRead = love.filesystem.read +love.filesystem.read = function(name) + if name == "build-info.json" then + return '{"gitCommit": "deadbee", "gitCommitFull": "' .. HASH .. '"}' + end + return savedRead(name) +end +eq(Version.devEngine(PLACEHOLDER, root .. "/nope"), PLACEHOLDER .. "+" .. HASH:sub(1, 12), + "build-info.json wins over the git walk in a packaged tree") +love.filesystem.read = savedRead + +os.execute(("rm -rf '%s'"):format(root)) + +-- --- the verdict two dev checkouts now get +local function hello(engine) + return { + type = "hello", + protocol = Handshake.PROTOCOL, + engineVersion = engine, + apiVersion = Version.modApi, + generation = 1, + fingerprint = "same-surface", + } +end + +local verdict, reason = Handshake.checkCompat(hello(derived), hello(otherDerived)) +eq(verdict, "engine_skew", "different +hash suffixes are an engine skew") +eq(reason, "engine_release_mismatch", "and the skew names the release mismatch") +check(not Handshake.battleAllowed(verdict), "so no lockstep battle is started") + +verdict = Handshake.checkCompat(hello(derived), hello(derived)) +eq(verdict, "full", "identical dev identities still pair as full") + +verdict = Handshake.checkCompat(hello(PLACEHOLDER), hello(derived)) +eq(verdict, "engine_skew", "a bare placeholder and a derived build are skewed") + +T.finish("version dev identity") diff --git a/tests/fixture_data/map_window.lua b/tests/fixture_data/map_window.lua new file mode 100644 index 00000000..59d21281 --- /dev/null +++ b/tests/fixture_data/map_window.lua @@ -0,0 +1,30 @@ +-- home/overworld.asm:2016 +return function(data, mapId) + local map = data and data.maps and data.maps[mapId] + if not map then return data end + if not map.sram then + map.sram = { + header = { 0, map.height or 4, map.width or 5, + 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00 }, + connections = {}, + objects = { 0x0E, 0, 0, 0 }, + } + end + data.tilesets = data.tilesets or {} + if map.tileset then + local ts = data.tilesets[map.tileset] + if not ts then + ts = {} + data.tilesets[map.tileset] = ts + end + ts.header = ts.header or { 0x0C, 0x11, 0x40, 0x22, 0x40, + 0x33, 0x40, 0x44, 0x55, 0x66, 0x77, 0x02 } + end + data.audio = data.audio or {} + data.audio.mapSongs = data.audio.mapSongs or {} + data.audio.songs = data.audio.songs or {} + data.audio.mapSongs[mapId] = data.audio.mapSongs[mapId] or "Music_Fixture" + data.audio.songs.Music_Fixture = data.audio.songs.Music_Fixture + or { address = 0x4000 + 3 * 0xBD, bank = 2 } + return data +end diff --git a/tests/gen2_battle_options_test.lua b/tests/gen2_battle_options_test.lua index 51c59947..e66ce6c5 100644 --- a/tests/gen2_battle_options_test.lua +++ b/tests/gen2_battle_options_test.lua @@ -65,6 +65,11 @@ check("the default surround is the cart's paper white", check("and a fresh options table carries it", Save.defaultOptions().battleBg, "white") +check("the default battle size is the whole-pixel blit", + Save.DEFAULT_OPTIONS.battleFit, "fixed") +check("and a fresh options table carries that too", + Save.defaultOptions().battleFit, "fixed") + -- --------------------------------------------------------------- the row local function rowNamed(label) @@ -82,11 +87,26 @@ if bgRow then check("stored lowercase, shown WHITE", bgRow.display.white, "WHITE") check("and BLACK", bgRow.display.black, "BLACK") -- Appended at the tail, so nothing the other suites index by position moves. - check("it follows MAX FPS", OptionsMenu.ROWS[bgIndex - 1].label, "MAX FPS") + check("it follows BATTLE SIZE", + OptionsMenu.ROWS[bgIndex - 1].label, "BATTLE SIZE") check("and CANCEL still ends the list", OptionsMenu.ROWS[bgIndex + 1].cancel, true) check("CANCEL is last", bgIndex + 1, #OptionsMenu.ROWS) end + +local sizeIndex, sizeRow = rowNamed("BATTLE SIZE") +check("OPTION has a BATTLE SIZE row", sizeRow ~= nil, true) +if sizeRow then + check("it edits battleFit", sizeRow.key, "battleFit") + check("it is a port row, not one of the cart's seven", sizeRow.port, true) + check("FIXED and FILL are the whole ladder", #sizeRow.values, 2) + check("stored lowercase, shown FIXED", sizeRow.display.fixed, "FIXED") + check("and FILL, padded to the value column", + sizeRow.display.fill, "FILL ") + check("it follows MAX FPS", + OptionsMenu.ROWS[sizeIndex - 1].label, "MAX FPS") + check("BATTLE BG comes straight after it", sizeIndex + 1, bgIndex) +end check("the cart's rows are unmoved", OptionsMenu.ROWS[7].key, "frame") check("the rebind screen is unmoved", OptionsMenu.ROWS[8].id, "controls") check("the port's audio group is unmoved", OptionsMenu.ROWS[9].key, "musicVol") @@ -113,6 +133,25 @@ if built then check("and back", menu.options.battleBg, "white") end +local builtFit +for _, row in ipairs(menu.rows) do + if row.key == "battleFit" then builtFit = row end +end +check("buildRows keeps BATTLE SIZE", builtFit ~= nil, true) +if builtFit then + check("and gives it the mod-facing id battleFit", builtFit.id, "battleFit") + check("the menu starts on FIXED", menu.options.battleFit, "fixed") + menu:cycle(builtFit, 1) + check("right stores fill, not the display string", + menu.options.battleFit, "fill") + menu:cycle(builtFit, 1) + check("right again wraps to fixed", menu.options.battleFit, "fixed") + menu:cycle(builtFit, -1) + check("left walks the ladder the other way", menu.options.battleFit, "fill") + menu:cycle(builtFit, -1) + check("and back", menu.options.battleFit, "fixed") +end + -- ------------------------------------------------------ the launcher gear local model = LauncherSettings.open(nil, "gold") @@ -134,6 +173,25 @@ if gearRow then check("stepping again returns to white", model.opts.gold.battleBg, "white") end +local gearSize +for _, section in ipairs(model.sections) do + for _, row in ipairs(section.rows) do + if row.label == "BATTLE SIZE" then gearSize = row end + end +end +check("the gold gear offers BATTLE SIZE", gearSize ~= nil, true) +if gearSize then + model.opts.gold.battleFit = nil + check("an options.lua with no battleFit reads FIXED", gearSize.value(), + "FIXED") + gearSize.step(1) + check("stepping writes the gold block, not the flat Gen 1 one", + model.opts.gold.battleFit, "fill") + check("and the row reads FILL", gearSize.value(), "FILL") + gearSize.step(1) + check("stepping again returns to fixed", model.opts.gold.battleFit, "fixed") +end + -- ---------------------------------------------------------- the battle end local bgMode = BattleState.bgMode @@ -199,8 +257,8 @@ check("the overworld alone paints nothing", check("a party menu over the battle still finds the battle's mode", #paint({ overworld, blackBattle, plainMenu }, 1024, 768), 4) -local function panelSafe(states, w, h, label) - local scale = Chrome.fitScale(w, h) +local function panelSafe(states, w, h, label, scale) + scale = scale or Chrome.fitScale(w, h) local ox, oy = Chrome.fitOrigin(w, h, scale) local pw, ph = 160 * scale, 144 * scale local rects = paint(states, w, h) @@ -218,7 +276,8 @@ local function panelSafe(states, w, h, label) -- a black frame eating the HUD or the message box, and any shortfall is a -- strip of white left behind on one edge. check(label .. ": no band touches the battle panel", overlap, 0) - check(label .. ": the void is covered edge to edge", covered, w * h - pw * ph) + check(label .. ": the void is covered edge to edge", + math.abs(covered - (w * h - pw * ph)) < 1, true) check(label .. ": every band is black", offColour, 0) end @@ -240,6 +299,44 @@ ScreenPosition.setMode("center") -- not wrap around to the far edge on the negative origin. check("nothing to paint under 160x144", #paint({ blackBattle }, 100, 90), 0) +local wantsFill = BattleState.wantsFillScale +check("the Gold battle screen answers wantsFillScale", type(wantsFill), + "function") +if type(wantsFill) == "function" then + check("FILL asks for the fill scale", + wantsFill({ game = { options = { battleFit = "fill" } } }), true) + check("FIXED does not", + wantsFill({ game = { options = { battleFit = "fixed" } } }), false) + check("nor does an options table that predates the key", + wantsFill({ game = { options = {} } }), false) + check("nor a screen with no game at all", wantsFill({}), false) +end + +check("FIXED is the integer fit", BattleState.panelScale(1280, 840, false), + Chrome.fitScale(1280, 840)) +check("FILL is min(w/160, h/144)", BattleState.fillScale(1280, 840), 840 / 144) +check("and FILL is not the integer fit here", + BattleState.fillScale(1280, 840) ~= Chrome.fitScale(1280, 840), true) +check("FILL never goes below one whole pixel", + BattleState.fillScale(100, 90), 1) + +local fillBattle = { + bgMode = function() return "black" end, + battlePanelScale = function(_, w, h) return BattleState.fillScale(w, h) end, +} +for _, size in ipairs({ { 1280, 840 }, { 1366, 768 }, { 1024, 768 } }) do + panelSafe({ fillBattle }, size[1], size[2], + ("fill %dx%d"):format(size[1], size[2]), + BattleState.fillScale(size[1], size[2])) + panelSafe({ fillBattle, plainMenu }, size[1], size[2], + ("fill under a menu %dx%d"):format(size[1], size[2]), + BattleState.fillScale(size[1], size[2])) +end + +local widescreenMenu = { drawsWidescreen = function() return true end } +panelSafe({ fillBattle, widescreenMenu }, 1280, 840, + "a widescreen menu over a fill battle", Chrome.fitScale(1280, 840)) + -- ----------------------------------------------------- the call site -- -- Constructing a Game2 needs love, so where the paint is called from is read @@ -254,6 +351,9 @@ if source then check("drawScene repaints straight after the widescreen layer", source:find("wide:drawWidescreen%(w, h%)%s*self:paintBattleSurround%(w, h%)") ~= nil, true) + check("the overlay blit shares the battle's panel scale", + source:find("local scale, ox, oy = panelBlit%(self%.stack, w, h%)") + ~= nil, true) end print(("gen2 battle options: %d checks, %d failures"):format(checks, failures)) diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index f82cf496..11d4fa6f 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -358,9 +358,8 @@ local options = OptionsMenu.new(optionsGame, { }) -- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside --- the dual-shader feature), video mode, screen position, the mobile-gated --- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. -check("twenty-eight rows", #OptionsMenu.ROWS, 28) +-- the dual-shader feature), video mode and screen position. +check("twenty-nine rows", #OptionsMenu.ROWS, 29) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") @@ -1499,10 +1498,10 @@ local expnGear = newGear({ landmark = "LANDMARK_PALLET_TOWN", clock = { hour = 14, minute = 0, weekday = 1 } }) check("and airs with it", expnGear:stations()[7].station, "POKE_FLUTE_RADIO") --- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F and one of three --- landmarks around the Lake of Rage. +-- .EvolutionRadio wants STATUSFLAGS_ROCKET_SIGNAL_F. +-- pokegold constants/engine_flags.asm local rageGear = newGear({ landmark = "LANDMARK_LAKE_OF_RAGE", - save = { flags = { ROCKET_SIGNAL = true } }, + save = { engineFlags = { [14] = true } }, clock = { hour = 14, minute = 0, weekday = 1 } }) check("20.5 airs by the Lake of Rage", rageGear:stations()[8].station, "EVOLUTION_RADIO") diff --git a/tests/gen2_ow_bounce_test.lua b/tests/gen2_ow_bounce_test.lua index 211e56fc..edf8d25a 100644 --- a/tests/gen2_ow_bounce_test.lua +++ b/tests/gen2_ow_bounce_test.lua @@ -3,14 +3,12 @@ -- luajit tests/gen2_ow_bounce_test.lua (ROM-free; the cache section SKIPs -- without a gold cache) -- --- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE and --- OBJECT_ACTION_BOUNCE. SetFacingBounce increments OBJECT_STEP_FRAME once a --- frame, masks it to four bits and reads bit 3: set means FACING_STEP_UP_0, --- clear falls into SetFacingFreezeBounce and FACING_STEP_DOWN_0 +-- data/sprites/map_objects.asm:181-187 gives the row SPRITEMOVEFN_BOUNCE -- (engine/overworld/map_object_action.asm:184-202). Those are tiles $00..$03 -- and $04..$07 of the mon's menu icon (data/sprites/facings.asm:43-72), i.e. -- the two 16x16 halves of the 16x32 sheet extractIcons already writes. -- +-- events.asm:175-189 -- Both halves of #1748 are pinned here: the animation itself, and the sheet -- being two frames deep so the animation has a second pose to reach. package.path = "./?.lua;./?/init.lua;" .. package.path @@ -69,32 +67,27 @@ do "MovementFunction_Bouncing parks it on STEP_TYPE_STANDING: no walking") end --- ---- eight frames down, eight frames up ------------------------------------ --- `inc a / and %00001111 / ld [hl], a / and %00001000` is a sixteen-frame --- cycle with an eight-frame dwell on each pose, and the counter is stepped --- BEFORE the bit is read -- so the spawn frame is pose 0 and so are the seven --- after it. do local mon = build(SPRITEMOVEDATA_POKEMON) eq(mon.bounceStep, 0, "OBJECT_STEP_FRAME starts at zero") local seen, wrong = { mon:bounceFrame() }, nil - for i = 1, 31 do + for i = 1, 63 do mon:update() seen[i + 1] = mon:bounceFrame() end - for i = 0, 31 do - local want = math.floor(i / 8) % 2 + for i = 0, 63 do + local want = math.floor(i / 16) % 2 if seen[i + 1] ~= want and not wrong then wrong = ("frame %d drew pose %s, wanted %d") :format(i, tostring(seen[i + 1]), want) end end - check(wrong == nil, "two full cycles run 0,0..0,1,1..1 eight frames apiece" + check(wrong == nil, "two full cycles run sixteen fixed steps a pose" .. (wrong and (" -- " .. wrong) or "")) eq(seen[1], 0, "the spawn frame is FacingStepDown0, the icon's first half") - eq(seen[9], 1, "the ninth is FacingStepUp0, its second half") - eq(seen[17], 0, "and the seventeenth is back to the first") - eq(mon.bounceStep, 31 % 16, "the counter wrapped at sixteen, not at eight") + eq(seen[17], 1, "the seventeenth is FacingStepUp0, its second half") + eq(seen[33], 0, "and the thirty-third is back to the first") + eq(mon.bounceStep, 63 % 32, "the counter wrapped at thirty-two") end -- ---- the frozen column ----------------------------------------------------- @@ -103,9 +96,9 @@ end -- conversation pins its FIRST pose and resumes on the phase it left. do local mon = build(SPRITEMOVEDATA_POKEMON) - for _ = 1, 10 do mon:update() end - eq(mon:bounceFrame(), 1, "ten frames in, the mon is on the up pose") - eq(mon.bounceStep, 10, "with OBJECT_STEP_FRAME at 10") + for _ = 1, 20 do mon:update() end + eq(mon:bounceFrame(), 1, "twenty frames in, the mon is on the up pose") + eq(mon.bounceStep, 20, "with the step counter at 20") mon.frozen = true local pinned = true @@ -114,12 +107,12 @@ do if mon:bounceFrame() ~= 0 then pinned = false end end check(pinned, "frozen, it holds FacingStepDown0 for the whole conversation") - eq(mon.bounceStep, 10, + eq(mon.bounceStep, 20, "and SetFacingFreezeBounce leaves the step counter where it was") mon.frozen = false mon:update() - eq(mon.bounceStep, 11, "released, the counter carries on from 10") + eq(mon.bounceStep, 21, "released, the counter carries on from 20") eq(mon:bounceFrame(), 1, "so the bounce resumes on the phase it froze at") end @@ -166,9 +159,9 @@ do eq(seen.mon.override, 0, "the mon's first draw overrides to frame 0") check(seen.doll.override == nil, "and the doll's overrides to nothing") - for _ = 1, 8 do mon:update() end + for _ = 1, 16 do mon:update() end mon:draw(0, 0, 1) - eq(seen.mon.override, 1, "eight frames later it overrides to frame 1") + eq(seen.mon.override, 1, "sixteen frames later it overrides to frame 1") end -- ---- a one-frame sheet has nowhere to bounce to ---------------------------- diff --git a/tests/gen2_screen_layout_test.lua b/tests/gen2_screen_layout_test.lua index c9f78ebc..75a559ee 100644 --- a/tests/gen2_screen_layout_test.lua +++ b/tests/gen2_screen_layout_test.lua @@ -275,7 +275,8 @@ for _, path in ipairs(screens) do src:find("math%.min%(win[WH] */ *[%dSCREN_WH]+", 1, false) == nil, true) if src:find("function [%w_]+:drawWidescreen") then check(path .. " blits through Chrome.fitScale", - src:find("fitScale", 1, true) ~= nil, true) + src:find("fitScale", 1, true) ~= nil + or src:find("Chrome.withPanel", 1, true) ~= nil, true) end -- An opaque screen is one nothing under it is drawn for: its cart routine -- ran ClearBGPalettes / ClearTilemap, so the map is gone and the screen owns diff --git a/tests/goldens/fixture_fingerprint.txt b/tests/goldens/fixture_fingerprint.txt index 3ab0752d..a8d8ae3b 100644 --- a/tests/goldens/fixture_fingerprint.txt +++ b/tests/goldens/fixture_fingerprint.txt @@ -1 +1 @@ -7de3051a7b3c108e +6e6c62281edfe2ac diff --git a/tests/goldens/vanilla_fingerprint.txt b/tests/goldens/vanilla_fingerprint.txt index ee73d375..c1e3a511 100644 --- a/tests/goldens/vanilla_fingerprint.txt +++ b/tests/goldens/vanilla_fingerprint.txt @@ -1 +1 @@ -227fd737aba15763 +789ccca105bbebed diff --git a/tests/link2_desync_fuzz.lua b/tests/link2_desync_fuzz.lua new file mode 100644 index 00000000..73932e39 --- /dev/null +++ b/tests/link2_desync_fuzz.lua @@ -0,0 +1,442 @@ +-- Gen 2 lockstep desync fuzz, modelled on tests/link_desync_fuzz.lua. +-- +-- luajit tests/link2_desync_fuzz.lua [runs] [firstSeed] +-- +-- tests/link2_lockstep.lua battles a handful of fixed pairings with the A +-- button held, which reaches one code path: move slot 1, no status, no lock, +-- no weather. Every desync a lockstep engine actually suffers lives outside +-- it, so this walks the rest -- random multi-mon parties with random +-- movesets and held items, random move choices, voluntary switches, faints +-- and replacements -- with the two sides deliberately NOT identical clients: +-- +-- speed one side takes several fixed steps per frame while the other +-- takes one, so each machine is at a different point in its own +-- message queue when the peer's action lands +-- lag the relay is not instantaneous, so an action arrives while this +-- side is mid-turn +-- +-- None of that may change the outcome: a lockstep battle is decided by the +-- two actions and the shared RNG stream and nothing else. ROM-free, like +-- tests/link2_lockstep.lua: the dataset below is a Gen 2 shaped fixture. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local Json = require("src.link.Json") +local LinkBattle2 = require("src.link.LinkBattle2") +local Mon = require("src.battle.gen2.Mon") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") + +-- one PRNG per run so a failure replays from its seed alone +local function makeRandom(seed) + local s = seed % 2147483647 + if s <= 0 then s = s + 2147483646 end + return function(a, b) + s = (s * 16807) % 2147483647 + if b == nil then a, b = 1, a end + return a + (s % (b - a + 1)) + end +end + +-- ---------------------------------------------------------------- fixture + +local TYPE_IDS = { "NORMAL", "FIRE", "WATER", "GRASS", "GHOST" } +local TYPES = {} +for index, id in ipairs(TYPE_IDS) do + TYPES[id] = { id = id, index = index - 1, + category = (id == "FIRE" or id == "WATER" or id == "GRASS") + and "special" or "physical" } +end + +-- A chart with real supereffective / resisted / immune cells, so damage is +-- not one flat number and the Ghost immunity exercises the miss path. +local MATCHUPS = { + { attacker = "FIRE", defender = "GRASS", multiplier = 20 }, + { attacker = "GRASS", defender = "WATER", multiplier = 20 }, + { attacker = "WATER", defender = "FIRE", multiplier = 20 }, + { attacker = "FIRE", defender = "WATER", multiplier = 5 }, + { attacker = "GRASS", defender = "FIRE", multiplier = 5 }, + { attacker = "WATER", defender = "GRASS", multiplier = 5 }, + { attacker = "NORMAL", defender = "GHOST", multiplier = 0 }, +} + +local function move(id, kind, power, effect, chance) + return { id = id, name = id, power = power, type = kind, + accuracy = 95, pp = 15, effect = effect, effectChance = chance } +end + +local MOVES = { + STRUGGLE = move("STRUGGLE", "NORMAL", 50, "EFFECT_NORMAL_HIT"), + HIT_A = move("HIT_A", "NORMAL", 40, "EFFECT_NORMAL_HIT"), + HIT_B = move("HIT_B", "FIRE", 45, "EFFECT_NORMAL_HIT"), + HIT_C = move("HIT_C", "WATER", 45, "EFFECT_NORMAL_HIT"), + HIT_D = move("HIT_D", "GRASS", 45, "EFFECT_NORMAL_HIT"), + QUICK = move("QUICK", "NORMAL", 25, "EFFECT_PRIORITY_HIT"), + MULTI = move("MULTI", "NORMAL", 15, "EFFECT_MULTI_HIT"), + DRAIN = move("DRAIN", "GRASS", 30, "EFFECT_LEECH_HIT"), + RECOIL = move("RECOIL", "NORMAL", 60, "EFFECT_RECOIL_HIT"), + FLINCHER = move("FLINCHER", "NORMAL", 25, "EFFECT_FLINCH_HIT", 30), + POISON_JAB = move("POISON_JAB", "NORMAL", 25, "EFFECT_POISON_HIT", 40), + BURNER = move("BURNER", "FIRE", 25, "EFFECT_BURN_HIT", 40), + ZAPPER = move("ZAPPER", "NORMAL", 25, "EFFECT_PARALYZE_HIT", 40), + MUDDLER = move("MUDDLER", "WATER", 25, "EFFECT_CONFUSE_HIT", 40), + SLEEPER = move("SLEEPER", "NORMAL", 0, "EFFECT_SLEEP"), + VENOM = move("VENOM", "NORMAL", 0, "EFFECT_TOXIC"), + SCARE = move("SCARE", "GHOST", 0, "EFFECT_CONFUSE"), + GROWLER = move("GROWLER", "NORMAL", 0, "EFFECT_ATTACK_DOWN"), + HARDEN = move("HARDEN", "NORMAL", 0, "EFFECT_DEFENSE_UP"), + AGILE = move("AGILE", "NORMAL", 0, "EFFECT_SPEED_UP_2"), + WRAPPER = move("WRAPPER", "NORMAL", 20, "EFFECT_TRAP_TARGET"), + THRASHER = move("THRASHER", "NORMAL", 45, "EFFECT_RAMPAGE"), + BOULDER = move("BOULDER", "NORMAL", 25, "EFFECT_ROLLOUT"), + CURL = move("CURL", "NORMAL", 0, "EFFECT_DEFENSE_CURL"), + BEAM = move("BEAM", "NORMAL", 80, "EFFECT_HYPER_BEAM"), + DIVE = move("DIVE", "WATER", 60, "EFFECT_FLY"), + SEEDER = move("SEEDER", "GRASS", 0, "EFFECT_LEECH_SEED"), + DUMMY = move("DUMMY", "NORMAL", 0, "EFFECT_SUBSTITUTE"), + SHIELD = move("SHIELD", "NORMAL", 0, "EFFECT_PROTECT"), + GLASS = move("GLASS", "NORMAL", 0, "EFFECT_LIGHT_SCREEN"), + MIRROR = move("MIRROR", "NORMAL", 0, "EFFECT_REFLECT"), + CALTROPS = move("CALTROPS", "NORMAL", 0, "EFFECT_SPIKES"), + DIRGE = move("DIRGE", "NORMAL", 0, "EFFECT_PERISH_SONG"), + RERUN = move("RERUN", "NORMAL", 0, "EFFECT_ENCORE"), + STORM = move("STORM", "NORMAL", 0, "EFFECT_SANDSTORM"), + DELUGE = move("DELUGE", "WATER", 0, "EFFECT_RAIN_DANCE"), + HEX = move("HEX", "GHOST", 0, "EFFECT_CURSE"), + SIGHT = move("SIGHT", "NORMAL", 0, "EFFECT_LOCK_ON"), + RAGER = move("RAGER", "NORMAL", 20, "EFFECT_RAGE"), + MENDER = move("MENDER", "NORMAL", 0, "EFFECT_HEAL"), +} + +local MOVE_POOL = {} +for id in pairs(MOVES) do + if id ~= "STRUGGLE" then MOVE_POOL[#MOVE_POOL + 1] = id end +end +table.sort(MOVE_POOL) -- pairs order is not stable; the seed is the only input + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local POKEMON = { growthRates = GROWTH } +local SPECIES = {} +do + local rows = { + { "ALFAMON", "NORMAL", "NORMAL", 45, 60, 45, 85, 50, 45 }, + { "BETAMON", "FIRE", "NORMAL", 60, 55, 60, 45, 65, 55 }, + { "GAMMAMON", "WATER", "WATER", 70, 45, 70, 30, 55, 70 }, + { "DELTAMON", "GRASS", "GHOST", 55, 65, 40, 60, 70, 45 }, + { "EPSILMON", "GHOST", "GHOST", 50, 50, 55, 55, 60, 60 }, + } + for index, row in ipairs(rows) do + POKEMON[row[1]] = { + id = row[1], index = index, name = row[1], + baseStats = { hp = row[4], attack = row[5], defense = row[6], + speed = row[7], specialAttack = row[8], specialDefense = row[9] }, + types = { row[2], row[3] }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127, + levelMoves = { { level = 1, move = "HIT_A" } }, evolutions = {}, + } + SPECIES[#SPECIES + 1] = row[1] + end +end +table.sort(SPECIES) + +local ITEMS = { + LEFTOVERS = { id = "LEFTOVERS", index = 1, name = "LEFTOVERS", + pocket = "ITEM", heldEffect = "HELD_LEFTOVERS" }, + KINGS_ROCK = { id = "KINGS_ROCK", index = 2, name = "KING'S ROCK", + pocket = "ITEM", heldEffect = "HELD_FLINCH", heldParameter = 30 }, + QUICK_CLAW = { id = "QUICK_CLAW", index = 3, name = "QUICK CLAW", + pocket = "ITEM", heldEffect = "HELD_QUICK_CLAW", heldParameter = 60 }, + FOCUS_BAND = { id = "FOCUS_BAND", index = 4, name = "FOCUS BAND", + pocket = "ITEM", heldEffect = "HELD_FOCUS_BAND", heldParameter = 30 }, + BERRY = { id = "BERRY", index = 5, name = "BERRY", pocket = "ITEM", + heldEffect = "HELD_BERRY", heldParameter = 10 }, + PSNCUREBERRY = { id = "PSNCUREBERRY", index = 6, name = "PSNCUREBERRY", + pocket = "ITEM", heldEffect = "HELD_HEAL_POISON" }, + SCOPE_LENS = { id = "SCOPE_LENS", index = 7, name = "SCOPE LENS", + pocket = "ITEM", heldEffect = "HELD_CRITICAL_UP" }, + BRIGHTPOWDER = { id = "BRIGHTPOWDER", index = 8, name = "BRIGHTPOWDER", + pocket = "ITEM", heldEffect = "HELD_BRIGHTPOWDER", heldParameter = 20 }, +} +local ITEM_POOL = {} +for id in pairs(ITEMS) do ITEM_POOL[#ITEM_POOL + 1] = id end +table.sort(ITEM_POOL) + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, + items = ITEMS, +} + +-- ---------------------------------------------------------------- harness + +local function fakeInput() + local input = { button = nil } + function input:wasPressed(button) return self.button == button end + function input:isDown() return false end + function input:step() end + return input +end + +local function makeGame(name, party) + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:clear() self.list = {} end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + return { + data = DATA, + input = fakeInput(), + stack = stack, + options = {}, + save = { party = party, player = { name = name, id = 4242 }, + inventory = {}, pokedex = { seen = {}, caught = {} } }, + } +end + +-- a loopback pair that holds each message for `delay` pumps before it lands +-- in the peer's inbox: Net.loopbackPair on its own delivers instantly, which +-- is the one thing a real relay never does +local function laggyPair(delayA, delayB) + local a, b = Net.loopbackPair() + a.wire, b.wire = {}, {} + a.delay, b.delay = delayA or 0, delayB or 0 + local function send(self, msg) + if self.closed then return end + local decoded = Json.decode(Json.encode(msg)) + if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end + end + local function update(self) + for i = #self.wire, 1, -1 do + local row = self.wire[i] + row.at = row.at - 1 + if row.at <= 0 then + table.remove(self.wire, i) + if not self.peerEnd.closed then + table.insert(self.peerEnd.inbox, row.msg) + end + end + end + end + a.send, b.send = send, send + a.update, b.update = update, update + return a, b +end + +local function randomParty(rnd, size) + local party = {} + for _ = 1, size do + local mon = Mon.new(DATA, SPECIES[rnd(1, #SPECIES)], rnd(20, 40), { + dvs = { attack = rnd(0, 15), defense = rnd(0, 15), + speed = rnd(0, 15), special = rnd(0, 15) }, + }) + local moves, seen = {}, {} + for _ = 1, 4 do + local id = MOVE_POOL[rnd(1, #MOVE_POOL)] + if not seen[id] then + seen[id] = true + moves[#moves + 1] = { id = id, pp = MOVES[id].pp, + maxPp = MOVES[id].pp } + end + end + if #moves == 0 then + moves[1] = { id = "HIT_A", pp = 15, maxPp = 15 } + end + mon.moves = moves + if rnd(1, 100) <= 60 then mon.item = ITEM_POOL[rnd(1, #ITEM_POOL)] end + party[#party + 1] = mon + end + return party +end + +-- The screen drives itself from the pad, so the fuzz steers it the way the +-- lockstep suite does -- press A, and walk the cursor off a fainted slot -- +-- plus a random move pick and the occasional voluntary switch. +local function drive(side, rnd) + local screen = side.screen + local game = side.game + local top = game.stack:top() + if top and top ~= screen then + local mon = top.party and top.index and top.party[top.index] + if not mon or (mon.hp or 0) <= 0 or mon.isEgg then + -- walk to the first healthy slot rather than one step at a time, so a + -- laggy run cannot spend its whole guard in the list + for i, candidate in ipairs(top.party or {}) do + if (candidate.hp or 0) > 0 and not candidate.isEgg then + top.index = i + break + end + end + end + game.input.button = "a" + return + end + if screen.phase == "menu" then + -- a voluntary switch now and then, which is the other action the wire + -- carries and the one that re-enters the turn loop without a move + if rnd(1, 100) <= 8 then + for _ = 1, 4 do + local index = rnd(1, #screen.battle.party) + local mon = screen.battle.party[index] + if (mon.hp or 0) > 0 and mon ~= screen.battle.player + and not screen.battle:switchLocked() then + screen:submit({ kind = "switch", index = index }) + game.input.button = nil + return + end + end + end + screen.menuIndex = 1 -- FIGHT + elseif screen.phase == "moves" then + local usable = {} + for i, mv in ipairs(screen.battle.player.moves or {}) do + if (mv.pp or 0) > 0 + and not screen.battle:moveDisabled(screen.battle.player, mv.id) then + usable[#usable + 1] = i + end + end + if #usable > 0 then screen.moveIndex = usable[rnd(1, #usable)] end + end + game.input.button = "a" +end + +LinkBattle2.keepSignatures = true + +local function firstSplit(a, b) + local found + for turn, value in pairs(a.localHashes or {}) do + local other = (b.localHashes or {})[turn] + if other and other ~= value and (not found or turn < found) then + found = turn + end + end + return found +end + +-- The strings behind the digests, so a failure names the component and shows +-- both sides of it rather than two hex blobs. +local function splitDetail(a, b, turn) + local mine = (a.linkSignatures or {})[turn] + local theirs = (b.linkSignatures or {})[turn] + if not (mine and theirs) then return "" end + for _, part in ipairs({ "actives", "volatile", "bench" }) do + if mine[part] ~= theirs[part] then + return ("\n %s:\n host %s\n guest %s"):format(part, mine[part], + theirs[part]) + end + end + return "" +end + +local function runOne(seed) + local rnd = makeRandom(seed) + local lagA, lagB = rnd(0, 3), rnd(0, 3) + local stepsA, stepsB = rnd(1, 3), rnd(1, 3) + local netA, netB = laggyPair(lagA, lagB) + + local partyA = randomParty(rnd, rnd(1, 4)) + local partyB = randomParty(rnd, rnd(1, 4)) + local gameA = makeGame("GOLD", partyA) + local gameB = makeGame("SILVER", partyB) + local packedA = Protocol.packParty2(partyA) + local packedB = Protocol.packParty2(partyB) + local battleSeed = rnd(1, 2147483000) + + local host, hostErr = LinkBattle2.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, theirName = "SILVER", + seed = battleSeed, verdict = "full", strict = true, keepNetOpen = true, + }) + local guest, guestErr = LinkBattle2.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, theirName = "GOLD", + seed = battleSeed, verdict = "full", strict = true, keepNetOpen = true, + }) + if not host then return ("seed %d: host refused (%s)"):format(seed, tostring(hostErr)) end + if not guest then return ("seed %d: guest refused (%s)"):format(seed, tostring(guestErr)) end + + local sides = { + { game = gameA, screen = host, steps = stepsA }, + { game = gameB, screen = guest, steps = stepsB }, + } + for _, side in ipairs(sides) do + side.screen.onFinish = function(result) side.result = result end + side.game.stack:push(side.screen) + end + + local guard = 0 + while (sides[1].result == nil or sides[2].result == nil) and guard < 40000 do + guard = guard + 1 + for _, side in ipairs(sides) do + for _ = 1, side.steps do + drive(side, rnd) + side.game.stack:update(1 / 60) + side.game.input.button = nil + side.game.stack:update(1 / 60) + end + end + local turn = firstSplit(host, guest) + if turn then + return ("seed %d: turn %d hash split (lag %d/%d, steps %d/%d)%s"):format( + seed, turn, lagA, lagB, stepsA, stepsB, splitDetail(host, guest, turn)), + host.battle.turn or 0 + end + end + + -- a battle still running at the guard is a stalemate (two mons that cannot + -- KO each other), not a split; only a finished one can be checked mirrored + if sides[1].result and sides[2].result then + if host.battle.player.hp ~= guest.battle.enemy.hp + or host.battle.enemy.hp ~= guest.battle.player.hp then + return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format( + seed, host.battle.player.hp, host.battle.enemy.hp, + guest.battle.enemy.hp, guest.battle.player.hp), host.battle.turn or 0 + end + if host.battle.rngDraws ~= guest.battle.rngDraws then + return ("seed %d: rng draw counts differ (%d vs %d)"):format( + seed, host.battle.rngDraws or -1, guest.battle.rngDraws or -1), + host.battle.turn or 0 + end + local a, b = sides[1].result, sides[2].result + local agrees = (a == "win" and b == "lose") or (a == "lose" and b == "win") + or (a == "draw" and b == "draw") + if not agrees then + return ("seed %d: results disagree (%s vs %s)"):format(seed, tostring(a), + tostring(b)), host.battle.turn or 0 + end + end + return nil, host.battle.turn or 0 +end + +local RUNS = tonumber(arg and arg[1]) or 40 +local FIRST = tonumber(arg and arg[2]) or 1 + +local failures, turns = 0, 0 +for seed = FIRST, FIRST + RUNS - 1 do + local ok, why, t = pcall(runOne, seed) + turns = turns + (t or 0) + if not ok then + failures = failures + 1 + print("FAIL gen2 desync fuzz seed " .. seed .. ": " .. tostring(why)) + elseif why then + failures = failures + 1 + print("FAIL gen2 desync fuzz " .. why) + end +end +print(("gen2 link desync fuzz: %d runs, %d turns, %d failures"):format( + RUNS, turns, failures)) + +assert(failures == 0, failures .. " Gen 2 lockstep run(s) diverged") +return true diff --git a/tests/link2_lockstep.lua b/tests/link2_lockstep.lua new file mode 100644 index 00000000..2805aa5c --- /dev/null +++ b/tests/link2_lockstep.lua @@ -0,0 +1,511 @@ +-- Gen 2 lockstep link battles (src/link/LinkBattle2.lua), the Gold peer of +-- the Gen 1 section in tests/run_link_tests.lua. +-- +-- luajit tests/link2_lockstep.lua +-- +-- ROM-free: the dataset below is a hand-written Gen 2 shaped fixture, the +-- same trick tests/gen2_battle_pack_test.lua uses, so this needs no +-- data/generated/gold. Both peers run the REAL src/ui/gen2/BattleState over +-- the REAL src/battle/gen2/Battle, exchanging actions over +-- Net.loopbackPair(), and every assertion is about the two simulations +-- agreeing rather than about either one on its own. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = love or require("tests.love_stub") + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end +local function eq(got, want, msg) + check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), + tostring(want))) +end + +require("src.core.Logger").warn = function() end + +local Battle = require("src.battle.gen2.Battle") +local LinkBattle2 = require("src.link.LinkBattle2") +local Mon = require("src.battle.gen2.Mon") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") + +-- ---------------------------------------------------------------- fixture + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + SCRATCH = { id = "SCRATCH", name = "SCRATCH", power = 40, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + STRUGGLE = { id = "STRUGGLE", name = "STRUGGLE", power = 50, type = "NORMAL", + accuracy = 100, pp = 1, effect = "EFFECT_NORMAL_HIT" }, +} + +local GROWTH = { + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0, + linear = 0, constant = 0 }, +} + +local function species(id, index, stats) + return { + id = id, index = index, name = id, + baseStats = stats, + types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, + } +end + +local POKEMON = { + growthRates = GROWTH, + QUICKMON = species("QUICKMON", 1, { hp = 45, attack = 50, defense = 40, + speed = 90, specialAttack = 40, specialDefense = 40 }), + SLOWMON = species("SLOWMON", 2, { hp = 60, attack = 45, defense = 50, + speed = 20, specialAttack = 40, specialDefense = 45 }), + TANKMON = species("TANKMON", 3, { hp = 70, attack = 40, defense = 60, + speed = 35, specialAttack = 35, specialDefense = 50 }), +} + +local ITEMS = { + -- Battle:tickHeldItem's residual arm and BattleCommand_EffectChance's + -- flinch arm, the two held effects a link battle is most likely to split on. + LEFTOVERS = { id = "LEFTOVERS", index = 1, name = "LEFTOVERS", + pocket = "ITEM", heldEffect = "HELD_LEFTOVERS" }, + KINGS_ROCK = { id = "KINGS_ROCK", index = 2, name = "KING'S ROCK", + pocket = "ITEM", heldEffect = "HELD_FLINCH", heldParameter = 30 }, +} + +local DATA = { + pokemon = POKEMON, + moves = MOVES, + type_chart = { types = TYPES, matchups = {} }, + items = ITEMS, +} + +local PERFECT = { attack = 15, defense = 15, speed = 15, special = 15 } +PERFECT.hp = Mon.hpDV(PERFECT) + +local function fighter(id, level, item) + local mon = Mon.new(DATA, id, level or 20, { dvs = PERFECT }) + mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "SCRATCH", pp = 35, maxPp = 35 } } + mon.item = item + return mon +end + +-- ---------------------------------------------------------------- harness + +local function fakeInput() + local input = { button = nil } + function input:wasPressed(button) return self.button == button end + function input:isDown() return false end + function input:step() end + return input +end + +local function makeGame(name, party) + local stack = { list = {} } + function stack:push(state, ...) + table.insert(self.list, state) + if state.enter then state:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:clear() self.list = {} end + function stack:update(dt) + local top = self:top() + if top and top.update then top:update(dt) end + end + return { + data = DATA, + input = fakeInput(), + stack = stack, + options = {}, + save = { party = party, player = { name = name, id = 1234 }, + inventory = { POTION = 5 }, pokedex = { seen = {}, caught = {} } }, + } +end + +-- The screen is on top of the stack until it opens the party list, and the +-- forced switch after a faint opens on the mon that just fainted -- so while a +-- list is up the press walks the cursor off a fainted slot before choosing, +-- exactly the way a player does. +local function buttonFor(game, screen) + local top = game.stack:top() + if top and top ~= screen then + local mon = top.party and top.index and top.party[top.index] + if not mon or (mon.hp or 0) <= 0 or mon.isEgg then return "down" end + end + return "a" +end + +local function step(sides) + for _, side in ipairs(sides) do + side.game.input.button = buttonFor(side.game, side.screen) + side.game.stack:update(1 / 60) + side.game.input.button = nil + side.game.stack:update(1 / 60) + end +end + +local function packedParty(party) + return Protocol.packParty2(party) +end + +-- Build one loopback match. Returns the two sides, each carrying its own +-- game, screen and result. +local function newMatch(hostParty, guestParty, seed, opts) + opts = opts or {} + local netA, netB = Net.loopbackPair() + local gameA = makeGame("GOLD", hostParty) + local gameB = makeGame("SILVER", guestParty) + local packedA, packedB = packedParty(hostParty), packedParty(guestParty) + + local host, hostErr = LinkBattle2.newHost(gameA, netA, { + myParty = packedA, theirParty = packedB, theirName = "SILVER", + seed = seed, verdict = "full", strict = true, keepNetOpen = true, + turnLimit = opts.turnLimit, + }) + local guest, guestErr = LinkBattle2.newGuest(gameB, netB, { + myParty = packedB, theirParty = packedA, theirName = "GOLD", + seed = seed, verdict = "full", strict = true, keepNetOpen = true, + turnLimit = opts.turnLimit, + }) + if not host then error("host: " .. tostring(hostErr)) end + if not guest then error("guest: " .. tostring(guestErr)) end + + local sides = { + { game = gameA, screen = host, net = netA, role = "host" }, + { game = gameB, screen = guest, net = netB, role = "guest" }, + } + for _, side in ipairs(sides) do + side.screen.onFinish = function(result) side.result = result end + side.game.stack:push(side.screen) + end + return sides +end + +local function drive(sides, cap) + local frame = 0 + while frame < (cap or 20000) do + frame = frame + 1 + step(sides) + local done = true + for _, side in ipairs(sides) do + if side.result == nil then done = false end + end + if done then return frame end + end + return nil +end + +local function hashSplit(a, b) + for turn, value in pairs(a.localHashes or {}) do + local other = (b.localHashes or {})[turn] + if other and other ~= value then return turn end + end + return nil +end + +-- ---------------------------------------------------------------- one duel + +do + local hostParty = { fighter("QUICKMON", 20) } + local guestParty = { fighter("SLOWMON", 20) } + local sides = newMatch(hostParty, guestParty, 987654321) + local host, guest = sides[1].screen, sides[2].screen + + eq(host.battle.player.species, "QUICKMON", "the host leads with its own mon") + eq(host.battle.enemy.species, "SLOWMON", + "the guest's party became the host's enemy side") + eq(guest.battle.enemy.species, "QUICKMON", + "the host's party became the guest's enemy side") + eq(host.battle.save, nil, + "the lockstep battle carries no save, so no badge boost applies") + eq(host.battle:hasBadge("badges", "ZEPHYR"), false, + "...and every badge reads false on both sides") + eq(host.battle:badgeTypeBoost(host.battle.player, "NORMAL"), false, + "...including the type boost DoBadgeTypeBoosts would apply") + + local frames = drive(sides) + check(frames ~= nil, + ("a Gen 2 lockstep battle finishes on both sides (%s / %s)") + :format(tostring(sides[1].result), tostring(sides[2].result))) + local a, b = sides[1].result, sides[2].result + check((a == "win" and b == "lose") or (a == "lose" and b == "win") + or (a == "draw" and b == "draw"), + ("the two simulations agree on the outcome (%s / %s)") + :format(tostring(a), tostring(b))) + eq(host.battle.player.hp, guest.battle.enemy.hp, + "the host's mon has identical HP on both machines") + eq(host.battle.enemy.hp, guest.battle.player.hp, + "the guest's mon has identical HP on both machines") + eq(hashSplit(host, guest), nil, "no hash split across the whole battle") + check(next(host.localHashes) ~= nil, "hashes were actually exchanged") + eq(host.battle.rngDraws, guest.battle.rngDraws, + "both machines drew the same number of random numbers") + eq(hostParty[1].hp, hostParty[1].stats.hp, + "the real party is untouched (the battle used clamped copies)") + eq(hostParty[1].experience, guest.battle.enemy.experience, + "no experience is paid on the cable") +end + +-- ---------------------------------------------------------------- faints +-- Three mons a side, so both sides face a forced replacement and the wire +-- carries `replace` in both directions. + +do + local hostParty = { fighter("QUICKMON", 15), fighter("SLOWMON", 18), + fighter("TANKMON", 20) } + local guestParty = { fighter("SLOWMON", 15), fighter("QUICKMON", 18), + fighter("TANKMON", 20) } + local sides = newMatch(hostParty, guestParty, 24680) + local host, guest = sides[1].screen, sides[2].screen + + local frames = drive(sides, 60000) + check(frames ~= nil, + ("a three-a-side battle finishes on both sides (%s / %s)") + :format(tostring(sides[1].result), tostring(sides[2].result))) + local a, b = sides[1].result, sides[2].result + check((a == "win" and b == "lose") or (a == "lose" and b == "win") + or (a == "draw" and b == "draw"), + "the two simulations agree after forced replacements") + eq(hashSplit(host, guest), nil, "no hash split across the replacements") + eq(host.battle.rngDraws, guest.battle.rngDraws, + "the RNG streams stayed in step through the faints") + for i = 1, 3 do + eq(host.battle.party[i].hp, guest.battle.enemyParty[i].hp, + "host bench slot " .. i .. " has identical HP on both machines") + eq(host.battle.enemyParty[i].hp, guest.battle.party[i].hp, + "guest bench slot " .. i .. " has identical HP on both machines") + end + local faints = 0 + for _, mon in ipairs(host.battle.party) do + if (mon.hp or 0) <= 0 then faints = faints + 1 end + end + for _, mon in ipairs(host.battle.enemyParty) do + if (mon.hp or 0) <= 0 then faints = faints + 1 end + end + check(faints >= 3, "the match actually ran through faints (" .. faints .. ")") +end + +-- ---------------------------------------------------------------- held items + +do + local hostParty = { fighter("TANKMON", 25, "LEFTOVERS"), + fighter("SLOWMON", 25, "LEFTOVERS") } + local guestParty = { fighter("QUICKMON", 25, "KINGS_ROCK"), + fighter("TANKMON", 25, "KINGS_ROCK") } + local sides = newMatch(hostParty, guestParty, 1357911) + local host, guest = sides[1].screen, sides[2].screen + + eq(host.battle.player.item, "LEFTOVERS", "the held item survives packMon2") + eq(guest.battle.enemy.item, "LEFTOVERS", "...and reaches the peer's copy") + eq(host.battle.enemy.item, "KINGS_ROCK", "the foe's item comes over too") + + local frames = drive(sides, 60000) + check(frames ~= nil, + ("a held-item battle finishes on both sides (%s / %s)") + :format(tostring(sides[1].result), tostring(sides[2].result))) + eq(hashSplit(host, guest), nil, + "Leftovers and King's Rock stay in sync across the wire") + eq(host.battle.rngDraws, guest.battle.rngDraws, + "...including the flinch rolls King's Rock adds") + eq(host.battle.player.hp, guest.battle.enemy.hp, + "the Leftovers holder's HP matches on both machines") +end + +-- ---------------------------------------------------------------- cable rules + +do + local sides = newMatch({ fighter("QUICKMON", 20) }, + { fighter("SLOWMON", 20) }, 4242) + local host = sides[1].screen + for _ = 1, 400 do + step(sides) + if host.phase == "menu" then break end + end + eq(host.phase, "menu", "the host reaches its own battle menu") + + host:chooseMenu("item") + eq(host.phase, "refuse-menu", "the PACK is refused in a link battle") + check((host.message or ""):find("Items") ~= nil, + "...with the cable's own line: " .. tostring(host.message)) + + host.phase = "menu" + host:chooseMenu("run") + eq(host.phase, "refuse-menu", "RUN is refused in a link battle") + + host.phase = "menu" + eq(host:chooseMenu("fight"), true, "FIGHT still opens the move list") + eq(host.phase, "moves", "...and reaches the move menu") +end + +-- ---------------------------------------------------------------- no love.math +-- Rule 7 of .bazinga/online-handoff-rules.md: nothing on the link path may +-- roll love.math.random or read game.options. Both peers run a whole battle +-- with love.math.random replaced by a throw. + +do + local saved = love.math.random + love.math.random = function() + error("love.math.random reached the link path", 2) + end + local ok, err = pcall(function() + local sides = newMatch({ fighter("QUICKMON", 20), fighter("TANKMON", 22) }, + { fighter("SLOWMON", 20), fighter("QUICKMON", 22) }, + 555777) + local frames = drive(sides, 60000) + if not frames then error("the battle did not finish") end + if hashSplit(sides[1].screen, sides[2].screen) then + error("hash split under the throwing rng") + end + end) + love.math.random = saved + check(ok, "a whole Gen 2 link battle rolls no love.math.random: " + .. tostring(err)) +end + +-- ---------------------------------------------------------------- signature + +do + local hostParty = { fighter("QUICKMON", 20, "LEFTOVERS") } + local guestParty = { fighter("SLOWMON", 20) } + local sides = newMatch(hostParty, guestParty, 31337) + local host, guest = sides[1].screen, sides[2].screen + + local hSig = host.battle:linkSignature("host") + local gSig = guest.battle:linkSignature("guest") + eq(hSig.actives, gSig.actives, + "the two machines sign the same actives string at turn 0") + eq(hSig.bench, gSig.bench, "...and the same bench string") + eq(hSig.volatile, gSig.volatile, "...and the same volatile string") + check(hSig.actives:find("LEFTOVERS", 1, true) ~= nil, + "the held item is inside the actives component") + check(hSig.actives:find("|r0", 1, true) ~= nil, + "the rng draw counter rides the actives component") + + -- the components are what a mismatch NAMES: move one and only that one moves + host.battle.weather = "rain" + host.battle.weatherTurns = 5 + local moved = host.battle:linkSignature("host") + eq(moved.actives, hSig.actives, "weather does not disturb the actives") + check(moved.volatile ~= hSig.volatile, "weather lands in the volatile part") + host.battle.weather, host.battle.weatherTurns = nil, 0 + + host.battle.party[1].hp = host.battle.party[1].hp - 1 + local hurt = host.battle:linkSignature("host") + check(hurt.actives ~= hSig.actives, "a HP change lands in the actives") + check(hurt.bench ~= hSig.bench, "...and in the bench, which is fatal too") +end + +-- ---------------------------------------------------------------- spectator + +do + local hostParty = { fighter("QUICKMON", 18), fighter("TANKMON", 20) } + local guestParty = { fighter("SLOWMON", 18), fighter("QUICKMON", 20) } + local seed = 8080808 + local sides = newMatch(hostParty, guestParty, seed) + + -- The relay's spectate envelope: every action and replace the two real + -- players send, tagged with the side that sent it. + local specNet = Net.loopbackPair() + local feed = {} + for _, side in ipairs(sides) do + local realSend = side.net.send + local tag = side.role + side.net.send = function(self, msg) + if msg.type == "action" or msg.type == "replace" or msg.type == "bye" then + feed[#feed + 1] = { type = "spectate", side = tag, msg = msg } + end + return realSend(self, msg) + end + end + + local specGame = makeGame("WATCHER", {}) + local inbox = {} + local specNetStub = { + closed = false, + update = function() end, + poll = function() + local out = inbox + inbox = {} + return out + end, + send = function() end, + close = function(self) self.closed = true end, + } + local spectator, specErr = LinkBattle2.newSpectator(specGame, specNetStub, { + hostParty = packedParty(hostParty), guestParty = packedParty(guestParty), + hostName = "GOLD", guestName = "SILVER", seed = seed, + verdict = "full", strict = true, keepNetOpen = true, + }) + check(spectator ~= nil, "the spectator builds: " .. tostring(specErr)) + local specResult + spectator.onFinish = function(r) specResult = r end + specGame.stack:push(spectator) + + local frame = 0 + while frame < 60000 do + frame = frame + 1 + step(sides) + for _, msg in ipairs(feed) do table.insert(inbox, msg) end + feed = {} + specGame.input.button = "a" + specGame.stack:update(1 / 60) + specGame.input.button = nil + specGame.stack:update(1 / 60) + if sides[1].result and sides[2].result and specResult then break end + end + + check(sides[1].result ~= nil and sides[2].result ~= nil, + "the spectated match finished for both players") + check(specResult ~= nil, "the spectator finished too: " .. tostring(specResult)) + local host = sides[1].screen + eq(spectator.battle.player.species, host.battle.player.species, + "the spectator's host side is the host's own mon") + eq(spectator.battle.player.hp, host.battle.player.hp, + "the spectator converged on the host's HP") + eq(spectator.battle.enemy.hp, host.battle.enemy.hp, + "...and on the guest's") + for i = 1, 2 do + eq(spectator.battle.party[i].hp, host.battle.party[i].hp, + "spectator host bench slot " .. i .. " converged") + eq(spectator.battle.enemyParty[i].hp, host.battle.enemyParty[i].hp, + "spectator guest bench slot " .. i .. " converged") + end +end + +-- ---------------------------------------------------------------- forfeit + +do + local sides = newMatch({ fighter("QUICKMON", 20) }, + { fighter("SLOWMON", 20) }, 606060) + local host, guest = sides[1].screen, sides[2].screen + for _ = 1, 2000 do + step(sides) + if host.phase == "menu" or host.phase == "moves" then break end + end + guest.update(guest, 1 / 60) + -- The peer's own shot clock ran out: unlike a mutual draw this has a winner. + sides[1].net:send({ type = "forfeit" }) + for _ = 1, 400 do + step(sides) + if guest.result then break end + end + eq(guest.result, "win", "a forfeit from the peer is a win for the other side") +end + +print(("\n%s"):format(failures == 0 and "ALL GEN 2 LOCKSTEP TESTS PASSED" + or failures .. " FAILURES")) +if failures > 0 then error("link2 lockstep failures: " .. failures, 0) end diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua index fae7dbc2..b41d5f6e 100644 --- a/tests/link_desync_fuzz.lua +++ b/tests/link_desync_fuzz.lua @@ -38,6 +38,7 @@ local Net = require("src.link.Net") local Json = require("src.link.Json") local Input = require("src.core.Input") local LinkBattle = require("src.link.LinkBattle") +local Handshake = require("src.link.Handshake") Input:init() require("src.render.Font").load(Data) @@ -169,11 +170,15 @@ local function firstMismatch(a, b) end -- Returns nil when the run agreed, or a description of how it split. -local function runOne(seed, mutateRate) +local function runOne(seed, mutateRate, rulesetSplit) local rnd = makeRandom(seed) -- the two sides are deliberately different clients local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" } local optsB = { animations = true, textSpeed = 3, battleStyle = "SHIFT" } + if rulesetSplit then + optsA.ruleset = seed % 2 == 0 and "modern_clean" or "gen1_faithful" + optsB.ruleset = seed % 2 == 0 and "gen1_faithful" or "modern_clean" + end local stepsA, stepsB = rnd(1, 4), 1 -- A fast-forwards, B does not local lagA, lagB = rnd(0, 8), rnd(0, 8) @@ -183,15 +188,21 @@ local function runOne(seed, mutateRate) local netA, netB = laggyPair(lagA, lagB, rnd, mutateRate) local battleSeed = rnd(1, 2 ^ 30) + local dealtRuleset = rulesetSplit and Handshake.ruleset(gameA) or nil local battleA = LinkBattle.newHost(gameA, netA, { myParty = Protocol.packParty(gameA.save.party), theirParty = Protocol.packParty(gameB.save.party), - theirName = "BLUE", seed = battleSeed }) + theirName = "BLUE", seed = battleSeed, ruleset = dealtRuleset }) local battleB = LinkBattle.newGuest(gameB, netB, { myParty = Protocol.packParty(gameB.save.party), theirParty = Protocol.packParty(gameA.save.party), - theirName = "RED", seed = battleSeed }) + theirName = "RED", seed = battleSeed, ruleset = dealtRuleset }) if not battleA or not battleB then return nil, 0 end + if rulesetSplit and battleA.ruleset ~= battleB.ruleset then + return ("seed %d: the two sides hold different rulesets (%s vs %s)"):format( + seed, tostring(battleA.ruleset and battleA.ruleset.name), + tostring(battleB.ruleset and battleB.ruleset.name)), 0 + end local resA, resB battleA.onFinish = function(r) resA = r end @@ -312,6 +323,23 @@ end print(("link mutation fuzz: %d runs, %d failures"):format( MUTATION_RUNS, mutationFailures)) +local rulesetFailures, rulesetTurns = 0, 0 +for seed = FIRST, FIRST + RUNS - 1 do + local ok, why, t = pcall(runOne, seed, nil, true) + rulesetTurns = rulesetTurns + (t or 0) + if not ok then + rulesetFailures = rulesetFailures + 1 + print("FAIL link ruleset-split fuzz seed " .. seed .. ": " .. tostring(why)) + elseif why then + rulesetFailures = rulesetFailures + 1 + print("FAIL link ruleset-split fuzz " .. why) + end +end +print(("link ruleset-split fuzz: %d runs, %d turns, %d failures"):format( + RUNS, rulesetTurns, rulesetFailures)) + assert(failures == 0, failures .. " lockstep run(s) diverged") assert(mutationFailures == 0, mutationFailures .. " mutated run(s) threw") +assert(rulesetFailures == 0, + rulesetFailures .. " ruleset-split run(s) diverged") return true diff --git a/tests/link_hostile.lua b/tests/link_hostile.lua index 8fd63bcb..12fab939 100644 --- a/tests/link_hostile.lua +++ b/tests/link_hostile.lua @@ -1,7 +1,7 @@ -- Hostile link traffic: every message type this build reads, with every -- field replaced by every wrong Lua type, driven through the real Session -- choke point and then into the real consumers (trade session, link battle, --- spectator battle, tournament screen -- including its draw). +-- spectator battle -- including their draws). -- -- The three payloads from the "How to Troll Pokemon Players" writeup are -- rows in the table below: action.slot as a table, hash.parts as a number, @@ -27,7 +27,6 @@ local Net = require("src.link.Net") local Pokemon = require("src.pokemon.Pokemon") local Protocol = require("src.link.Protocol") local Session = require("src.link.Session") -local Tournament = require("src.link.Tournament") local Wire = require("src.link.Wire") local failures = 0 @@ -69,34 +68,13 @@ local TEMPLATES = { parts = { actives = "a", volatile = "b", bench = "c" } }, { type = "replace", index = 1 }, { type = "bye" }, - { type = "forfeit" }, + { type = "forfeit", match = "ABCDEF-r1-m0" }, { type = "spectate", side = "host", msg = { type = "action", kind = "move", slot = 1 } }, { type = "hosted", code = "ABCDEF" }, { type = "paired" }, { type = "peer_gone" }, { type = "join_error", reason = "not_found" }, - { type = "tournament_hosted", code = "ABCDEF", turnLimit = 6, - requiredPartySize = 3, minLevel = 5, maxLevel = 50, forceLevel = 50, - participating = true }, - { type = "tournament_host_error", reason = "party_ineligible", - requiredPartySize = 3, minLevel = 5, maxLevel = 50 }, - { type = "tournament_join_error", reason = "party_ineligible", - requiredPartySize = 3, minLevel = 5, maxLevel = 50 }, - { type = "tournament_roster", players = { "RED", "BLUE" }, - spectators = { "GREEN" }, turnLimit = 6, requiredPartySize = 3, - minLevel = 5, maxLevel = 50, forceLevel = 50 }, - { type = "bracket_update", tournament = { code = "ABCDEF", turnLimit = 6, - requiredPartySize = 3, minLevel = 5, maxLevel = 50, status = "active", - round = 1, champion = "RED", - rounds = { { round = 1, matches = { { a = "RED", b = "BLUE", - winner = "RED", bye = false, state = "live" } } } } } }, - { type = "match_start", opponent = "BLUE", round = 1, turnLimit = 6, - role = "host" }, - { type = "match_start_spectate", round = 1, playerHost = "RED", - playerGuest = "BLUE" }, - { type = "tournament_bye", round = 1 }, - { type = "tournament_over", champion = "RED" }, { type = "a_type_this_build_has_never_heard_of", payload = { n = 1 } }, } @@ -112,10 +90,6 @@ local NESTED = { { "hello", { "mods", 1 } }, { "spectate", { "msg" } }, { "spectate", { "msg", "slot" } }, - { "bracket_update", { "tournament", "rounds" } }, - { "bracket_update", { "tournament", "rounds", 1, "matches" } }, - { "bracket_update", { "tournament", "rounds", 1, "matches", 1, "a" } }, - { "tournament_roster", { "players", 1 } }, } local function templateFor(kind) @@ -333,44 +307,6 @@ do .. (crashed and (": " .. crashed) or "")) end -do - local game = makeFakeGame("PIKACHU", "RED") - local exits = 0 - local t = setmetatable({ - game = game, - stage = "bracket", - index = 1, - settingsIndex = 1, - settings = { turnLimit = 6, requiredPartySize = 3, minLevel = "ANY", - maxLevel = "ANY", forceLevel = "ANY", participating = true }, - roster = {}, - spectatorRoster = {}, - isCreator = false, - net = { send = function() end, close = function() end, - take = function() return nil end, - poll = function() return {} end, - hasPending = function() return false end }, - }, Tournament) - t.exitWith = function(self) exits = exits + 1 end - local crashed - for _, msg in ipairs(survivors) do - local ok, err = pcall(function() - t:handleMessage(msg) - t:draw() - end) - if not ok then - crashed = ("%s: %s"):format(tostring(msg.type), tostring(err)) - break - end - if type(t.roster) ~= "table" or type(t.spectatorRoster) ~= "table" then - crashed = ("%s left a non-table roster"):format(tostring(msg.type)) - break - end - end - check(not crashed, "the tournament screen survives every hostile message" - .. (crashed and (": " .. crashed) or "")) -end - -- ---------------------------------------------------------------- json do local deep = ("["):rep(4096) .. ("]"):rep(4096) diff --git a/tests/link_tournament16.lua b/tests/link_tournament16.lua index c1985434..61649635 100644 --- a/tests/link_tournament16.lua +++ b/tests/link_tournament16.lua @@ -1,10 +1,12 @@ --- A full 16-player tournament, played out. +-- A full 16-player bracket, played out through LinkBattle alone. -- -- Sixteen distinct identities, six random Pokemon each, everyone mashing A, -- through every match of every round until one champion is left: 8 + 4 + 2 + --- 1 = 15 real lockstep battles, each with the tournament match shape +-- 1 = 15 real lockstep battles, each with the bracket match shape -- (turnLimit shot clock, keepNetOpen, forceLevel) plus a live spectator --- rebuilding the same match from the relay's fan-out. +-- rebuilding the same match from the relay's fan-out. The bracket itself is +-- fake transports here: this drives the arena battle path the launcher's +-- rooms boot into, not any screen. -- -- What this is actually checking, beyond "it finishes": -- * 15 consecutive lockstep matches agree turn by turn, so a desync is diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 44e62756..7c3dd558 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -154,7 +154,7 @@ local function startGame() } end local VANILLA_START = { "POKéDEX", "POKéMON", "ITEM", "RED", "SAVE", - "OPTION", "LINK", "QUIT" } + "OPTION", "QUIT" } local menu = StartMenu.new(startGame()) check(#menu.items == #VANILLA_START, "vanilla start menu row count") for i, label in ipairs(VANILLA_START) do diff --git a/tests/online_arena_data.lua b/tests/online_arena_data.lua new file mode 100644 index 00000000..7f0d11e4 --- /dev/null +++ b/tests/online_arena_data.lua @@ -0,0 +1,523 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local Loader = require("src.mods.Loader") +local CartManifest = require("src.carts.CartManifest") +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local ArenaData = require("src.online.ArenaData") +local TeamPick = require("src.online.TeamPick") + +local function memfs(files) + return { + files = files, + read = function(path) return files[path] end, + write = function(path, content) files[path] = content return true end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end, + load = function(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end, + createDirectory = function() return true end, + getDirectoryItems = function(path) + local seen, items = {}, {} + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end + table.sort(items) + return items + end, + } +end + +local MODS = { + plain = { + manifest = '{"id":"plain","name":"plain","version":"1.0.0",' .. + '"entry":"main.lua","profile":"content"}', + entry = [[ +return function(mod) + mod.content.pokemon:register("PLAIN", { name = "PLAIN" }) +end +]], + }, + espanol = { + manifest = '{"id":"espanol","name":"espanol","version":"1.0.0",' .. + '"entry":"main.lua","language":true,"category":"LANGUAGE"}', + entry = [[ +return function(mod) + mod.content.strings:register("arena.hello", "HOLA") +end +]], + }, + faux = { + manifest = '{"id":"faux","name":"faux","version":"1.0.0",' .. + '"entry":"main.lua","language":true}', + entry = [[ +return function(mod) + mod.content.strings:register("arena.hi", "HELLO") + mod.content.pokemon:register("FAUX", { name = "FAUX" }) +end +]], + }, +} + +local function install(options) + local files = { + ["options.lua"] = SaveSerializer.encode(options or {}), + } + for id, mod in pairs(MODS) do + files["mods/" .. id .. "/manifest.json"] = mod.manifest + files["mods/" .. id .. "/main.lua"] = mod.entry + end + SaveData.resetSlotState() + SaveData.setCart(nil) + GameVersion.set("red") + return files +end + +local function boot(files, opts) + local data = { pokemon = {}, strings = {} } + local loader = Loader.new({ fs = memfs(files) }) + local ok, reason = loader:load(data, opts) + return loader, data, ok, reason +end + +local function writeCart(files, tbl) + local cart, err = CartManifest.parse(tbl) + assert(cart, err) + files["carts/" .. tbl.id .. CartManifest.EXT] = CartManifest.encode(cart) + return cart +end + +local function cartTable(id, seal, mods, order) + return { id = id, title = id, version = "1.0.0", author = "tester", + shell = "#102030", base = "red", seal = seal, + mods = mods, load_order = order } +end + +local function pin(id, version) + return { id = id, source = "local", version = version or "1.0.0" } +end + +-- ------- normal mode is unchanged + +do + local files = install({ modsByVersion = { red = { plain = false } } }) + local loader, data, ok = boot(files) + T.check(ok, "a normal load with no arena mode still loads clean") + T.eq(loader:status().arenaMode, "normal", "and reports the normal mode") + T.eq(loader.mods.plain.enabled, false, "the player's disable flag decides") + T.eq(loader.mods.espanol.enabled, true, "an enabled translation runs") + T.eq(data.pokemon.PLAIN, nil, "a disabled mod merges nothing") + T.eq(data.pokemon.FAUX.name, "FAUX", "an enabled content mod merges") +end + +-- ------- disableAll keeps verified translations and nothing else + +do + local files = install({ safeMode = true, + modsByVersion = { red = { espanol = false } } }) + local before = files["options.lua"] + local loader, data, ok = boot(files, { mode = "disableAll" }) + T.check(ok, "a disableAll load succeeds") + T.eq(loader:status().arenaMode, "disableAll", "and reports its mode") + T.eq(loader.safeMode, false, "safe mode is never read on an arena boot") + T.eq(loader.mods.espanol.enabled, true, + "a verified translation loads even though options disabled it") + T.eq(loader.mods.plain.enabled, false, "a content mod is off") + T.eq(data.pokemon.PLAIN, nil, "and merges nothing") + T.eq(loader.mods.faux.enabled, false, + "a mod that claims language but writes a species is unloaded") + T.eq(data.pokemon.FAUX, nil, "and its ops never reach the merge") + T.eq(data.strings["arena.hello"], "HOLA", + "the verified translation's string is the one that merged") + local order = table.concat(loader.order, ",") + T.eq(order, "espanol", "only the translation is in the load order") + T.eq(files["options.lua"], before, "options.lua is untouched byte for byte") +end + +-- ------- disableAll with no translations installed + +do + local files = install({}) + files["mods/espanol/manifest.json"] = nil + files["mods/espanol/main.lua"] = nil + files["mods/faux/manifest.json"] = nil + files["mods/faux/main.lua"] = nil + local before = files["options.lua"] + local loader, data, ok = boot(files, { mode = "disableAll" }) + T.check(ok, "a disableAll load with only content mods succeeds") + T.eq(#loader.order, 0, "nothing loads") + T.eq(data.pokemon.PLAIN, nil, "and nothing merges") + T.eq(files["options.lua"], before, "options.lua is still untouched") +end + +-- ------- an unknown mode is refused + +do + local files = install({}) + local _, _, ok, reason = boot(files, { mode = "nonsense" }) + T.eq(ok, false, "an unknown mode is refused") + T.check(reason and reason:find("nonsense", 1, true) ~= nil, + "and the reason names it") +end + +-- ------- cartOnly runs exactly the cart's pins + +do + local files = install({ modsByVersion = { red = { plain = false } } }) + writeCart(files, cartTable("arena", "sealed", { pin("plain") }, { "plain" })) + local before = files["options.lua"] + local loader, data, ok = boot(files, { mode = "cartOnly", cartId = "arena" }) + T.check(ok, "a sealed cart arena loads") + T.eq(loader:status().arenaMode, "cartOnly", "and reports its mode") + T.eq(data.pokemon.PLAIN.name, "PLAIN", + "the pinned mod runs even though the player switched it off") + T.eq(loader.mods.espanol.enabled, false, + "a mod the cart does not pin never runs, translation or not") + T.eq(loader:cartStatus().enforced, true, "the cart is enforced") + T.eq(files["options.lua"], before, "options.lua is untouched") +end + +-- ------- cartOnly refuses a broken seal + +do + local files = install({}) + writeCart(files, cartTable("arena", "sealed", { pin("plain") }, { "plain" })) + local _, _, ok, reason = boot(files, + { mode = "cartOnly", cartId = "arena", sealBroken = true }) + T.eq(ok, false, "a broken seal cannot enter an arena") + T.eq(reason, "this save's seal is broken", "and says so") +end + +-- ------- cartOnly refuses a plan that cannot be enforced + +do + local files = install({}) + writeCart(files, cartTable("gap", "sealed", { pin("missing") }, { "missing" })) + local _, _, ok, reason = boot(files, { mode = "cartOnly", cartId = "gap" }) + T.eq(ok, false, "a cart whose pins are not installed is refused") + T.check(reason and reason:find("missing", 1, true) ~= nil, + "and names the pin it cannot satisfy") + + local open = install({}) + writeCart(open, cartTable("loose", "open", { pin("plain") }, { "plain" })) + local _, _, openOk, openReason = boot(open, + { mode = "cartOnly", cartId = "loose" }) + T.eq(openOk, false, "an open cart is not a fixed identity") + T.check(openReason and openReason:find("sealed", 1, true) ~= nil, + "and the reason says so") + + local gone = install({}) + local _, _, goneOk, goneReason = boot(gone, + { mode = "cartOnly", cartId = "nothere" }) + T.eq(goneOk, false, "an uninstalled cart is refused") + T.check(goneReason ~= nil, "with a reason") +end + +-- ------- ArenaData profile comparison + +local function profile(overrides) + local p = { + engine = 1, version = "red", engineVersion = "1.2.3", apiVersion = 2, + fingerprint = "abcd1234", rulesetId = "gen1_faithful", kind = "vanilla", + rule = { partySize = 3 }, + } + for key, value in pairs(overrides or {}) do p[key] = value end + return p +end + +do + local a, b = profile(), profile() + T.eq(ArenaData.equal(a, b), true, "two identical profiles are equal") + T.eq(ArenaData.describeMismatch(a, b), nil, "and name no mismatch") + + b.rule = { partySize = 6, forceLevel = 50 } + T.eq(ArenaData.equal(a, b), true, "the rule is not part of the identity") + + T.eq(ArenaData.equal(a, profile({ fingerprint = "ffff" })), false, + "a different dataset is not equal") + T.eq(ArenaData.describeMismatch(a, profile({ fingerprint = "ffff" })), + "data differs", "and reports the dataset") + T.eq(ArenaData.describeMismatch(a, profile({ engineVersion = "9.9.9" })), + "engine version differs", "an engine release is named first") + T.eq(ArenaData.describeMismatch(a, profile({ version = "blue" })), + "game differs", "so is the game") + T.eq(ArenaData.describeMismatch(a, profile({ rulesetId = "gen1_fixed" })), + "ruleset differs", "and the ruleset") + T.eq(ArenaData.describeMismatch(a, profile({ engine = 2 })), + "engine differs", "and the generation") + + local cartA = profile({ kind = "cart", + cart = { id = "arena", version = "1.0.0", hash = "h1" } }) + local cartB = profile({ kind = "cart", + cart = { id = "arena", version = "1.0.0", hash = "h2" } }) + T.eq(ArenaData.equal(cartA, cartB), false, "two cart hashes must agree") + T.eq(ArenaData.describeMismatch(cartA, cartB), "cart hash differs", + "and the mismatch names the hash") + T.eq(ArenaData.describeMismatch(cartA, a), "arena kind differs", + "a cart arena is not a vanilla one") + T.eq(ArenaData.equal(a, nil), false, "a missing profile is never equal") +end + +-- ------- the cache key + +do + local Version = require("src.core.Version") + T.eq(ArenaData.cacheKey("red", "vanilla", nil), + "red|vanilla|-|" .. Version.engine, + "a vanilla key uses - for the absent cart hash") + T.eq(ArenaData.cacheKey("gold", "cart", "deadbeef"), + "gold|cart|deadbeef|" .. Version.engine, + "a cart key carries the cart hash") +end + +-- ------- TeamPick.validate + +local function mon(level, extra) + local m = { species = "PIKACHU", level = level, hp = 20, moves = {}, + dvs = {}, statExp = {} } + for key, value in pairs(extra or {}) do m[key] = value end + return m +end + +do + local party = { mon(50), mon(50), mon(10), mon(60, { isEgg = true }) } + local rule = { partySize = 3, minLevel = 20, maxLevel = 55 } + + T.eq(TeamPick.validate(party, { 1, 2 }, rule), false, + "a short team is refused") + T.eq(select(2, TeamPick.validate(party, { 1, 2 }, rule)), + "this arena needs 3 Pokemon.", "with the count in the reason") + T.eq(TeamPick.validate(party, { 1, 2, 1 }, rule), false, + "the same slot twice is refused") + T.eq(select(2, TeamPick.validate(party, { 1, 2, 1 }, rule)), + "no doubles allowed.", "and says so") + T.eq(TeamPick.validate(party, { 1, 2, 9 }, rule), false, + "an index past the party is refused") + T.eq(TeamPick.validate(party, { 1, 2, 3 }, rule), false, + "an under-level mon is refused") + T.eq(select(2, TeamPick.validate(party, { 1, 2, 3 }, rule)), + "every Pokemon must be\nLv20 or higher.", "naming the floor") + T.eq(TeamPick.validate(party, { 1, 2, 4 }, rule), false, + "an EGG cannot be picked") + T.eq(select(2, TeamPick.validate(party, { 1, 2, 4 }, rule)), + "an EGG can't battle.", "and says so") + + local tall = { mon(50), mon(50), mon(90) } + T.eq(TeamPick.validate(tall, { 1, 2, 3 }, rule), false, + "an over-level mon is refused") + T.eq(select(2, TeamPick.validate(tall, { 1, 2, 3 }, rule)), + "every Pokemon must be\nLv55 or lower.", "naming the ceiling") + T.eq(TeamPick.validate(tall, { 1, 2, 3 }, + { partySize = 3, minLevel = 20, maxLevel = 55, forceLevel = 50 }), true, + "forceLevel rewrites every level, so the band no longer applies") + + T.eq(TeamPick.validate(party, { 3, 2, 1 }, { partySize = 3 }), true, + "a rule with no level band takes any order") + T.eq(TeamPick.validate(party, { 1 }, { partySize = 1 }), true, + "a one-mon arena is legal") + T.eq(TeamPick.validate({ mon(5, { hp = 0 }) }, { 1 }, { partySize = 1 }), true, + "a fainted mon is legal: the lockstep copy is rebuilt from the wire") + T.eq(TeamPick.validate(party, nil, { partySize = 3 }), false, + "no team at all is refused") +end + +-- ------- TeamPick.pack + +do + local party = { mon(50, { nickname = "ONE" }), mon(51, { nickname = "TWO" }), + mon(52, { nickname = "THREE" }) } + local packed = TeamPick.pack(party, { 3, 1 }, 1) + T.eq(#packed, 2, "only the chosen slots are packed") + T.eq(packed[1].nickname, "THREE", "in the order the player chose") + T.eq(packed[2].nickname, "ONE", "not in party order") + T.eq(packed[1].level, 52, "carrying the mon's own fields") + T.eq(packed[1].isEgg, nil, "the Gen 1 codec has no egg field") + + local gen2Party = { + mon(50, { nickname = "GOLD", item = "LEFTOVERS", happiness = 70 }), + mon(51, { nickname = "SILVER" }), + } + local packed2 = TeamPick.pack(gen2Party, { 2, 1 }, 2) + T.eq(#packed2, 2, "the Gen 2 branch packs per mon") + T.eq(packed2[1].nickname, "SILVER", "in the chosen order") + T.eq(packed2[2].item, "LEFTOVERS", "carrying the held item") + T.eq(packed2[2].happiness, 70, "and happiness, which Gen 1 has no room for") + T.eq(packed2[1].exp, nil, "the Gen 2 codec spells experience differently") + + T.eq(#TeamPick.pack(party, {}, 1), 0, "an empty pick packs nothing") +end + +-- ------- TeamPick.convert / packConverted (Time Capsule teams) + +do + local Stats = require("src.pokemon.Stats") + local Mon = require("src.battle.gen2.Mon") + + local GEN1 = { + pokemon = { + PIKACHU = { name = "PIKACHU", growthRate = "MEDIUM_FAST", + baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, + special = 50 }, + types = { "ELECTRIC" }, evolutions = {} }, + }, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + items = {}, + } + local GEN2 = { + pokemon = { + PIKACHU = { name = "PIKACHU", growthRate = "MEDIUM_FAST", + baseStats = { hp = 35, attack = 55, defense = 30, speed = 90, + specialAttack = 50, specialDefense = 40 }, + types = { "ELECTRIC" }, evolutions = {} }, + HOOTHOOT = { name = "HOOTHOOT", growthRate = "MEDIUM_FAST", + baseStats = { hp = 60, attack = 30, defense = 30, speed = 50, + specialAttack = 36, specialDefense = 56 }, + types = { "NORMAL", "FLYING" }, evolutions = {} }, + }, + moves = { TACKLE = { name = "TACKLE", pp = 35 } }, + items = { LEFTOVERS = { name = "LEFTOVERS" } }, + } + + local function dvs() + local d = { attack = 15, defense = 15, speed = 15, special = 15 } + d.hp = Mon.hpDV(d) + return d + end + local function statExp() + return { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + end + + local function g1(species, level) + local d, e = dvs(), statExp() + local stats = Stats.calc(GEN1.pokemon[species], level, d, e) + return { species = species, level = level, exp = level ^ 3, dvs = d, + statExp = e, stats = stats, hp = stats.hp, maxHp = stats.hp, + moves = { { id = "TACKLE", pp = 35 } }, ot = "RED", otId = 1 } + end + local function g2(species, level) + local d, e = dvs(), statExp() + local stats = Mon.stats(GEN2.pokemon[species].baseStats, d, level, e) + return { species = species, level = level, experience = level ^ 3, + dvs = d, statExp = e, stats = stats, hp = stats.hp, + maxHp = stats.hp, types = GEN2.pokemon[species].types, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }, + happiness = 70, caughtLevel = level, ot = "GOLD", otId = 2 } + end + + local byKey, rows = TeamPick.convert({ g1("PIKACHU", 20) }, 2, GEN1, GEN2) + T.check(byKey["party|1"] ~= nil, "a Kanto mon crosses into Gen 2") + T.eq(rows["party|1"].ok, true, "and the result row says so") + T.check(#rows["party|1"].preview > 0, "with preview lines for the picker") + T.eq(byKey["party|1"].happiness, 70, "friendship is set on arrival") + T.eq(rows["party|1"].key, "party|1", "each row carries its source key") + + local intoOne, gen1Rows, refusals = + TeamPick.convert({ g2("PIKACHU", 20), g2("HOOTHOOT", 20) }, 1, GEN2, GEN1) + T.check(intoOne["party|1"] ~= nil, "a Gen 1 species crosses back") + T.eq(intoOne["party|2"], nil, "a Johto species does not") + T.eq(gen1Rows["party|2"].ok, false, "its row is a refusal") + T.eq(refusals["party|2"], "species_too_new", "naming why") + T.eq(gen1Rows["party|2"].preview[1], "SPECIES NOT IN GEN 1: HOOTHOOT", + "in words the picker can show") + + local packed = TeamPick.packConverted(intoOne, { 1 }, 1) + T.eq(#packed, 1, "packConverted packs the converted record") + T.eq(packed[1].species, "PIKACHU", "keeping the species") + T.eq(packed[1].happiness, nil, "through the Gen 1 codec") + + local none, why = TeamPick.packConverted(intoOne, { 1, 2 }, 1) + T.eq(none, nil, "a refused mon cannot be packed into a team") + T.eq(why, "that Pokemon cannot cross generations.", "and says why") + + local two = TeamPick.packConverted(byKey, { 1 }, 2) + T.eq(#two, 1, "the Gen 2 branch packs per mon") + T.eq(two[1].happiness, 70, "carrying what only Gen 2 records hold") + + -- a boxed mon converts through the same table, keyed by its own record + local boxed = { party = { g2("PIKACHU", 20) }, + generation = 2, + save = { boxes = { {}, { g2("HOOTHOOT", 20), g2("PIKACHU", 25) } } } } + local boxKeys, boxRows = TeamPick.convert(boxed, 1, GEN2, GEN1) + T.check(boxKeys["box|2|2"] ~= nil, "a boxed Kanto mon crosses too") + T.eq(boxRows["box|2|1"].ok, false, "and a boxed Johto one is refused") + local boxPacked = TeamPick.packConverted(boxKeys, + { { where = "box", box = 2, index = 2 } }, 1) + T.eq(#boxPacked, 1, "which packs from the box record") + + T.eq(#TeamPick.packConverted({}, {}, 1), 0, "an empty pick packs nothing") +end + +-- ------- TeamPick.candidates and source-aware packing + +do + local SAVE = { + party = { mon(50, { nickname = "ONE" }), mon(51, { nickname = "TWO" }) }, + boxes = { { mon(10, { nickname = "BOXA" }) }, {}, + { mon(12, { nickname = "BOXB" }), + mon(13, { nickname = "BOXC" }) } }, + } + local slot = { party = SAVE.party, save = SAVE, generation = 1 } + local rows = TeamPick.candidates(slot) + T.eq(#rows, 5, "candidates are the party plus every boxed POKeMON") + T.eq(rows[1].where, "party", "the party comes first") + T.eq(rows[1].source, "Party", "labelled as the party") + T.eq(rows[3].where, "box", "then the boxes") + T.eq(rows[3].box, 1, "in box order") + T.eq(rows[3].source, "BOX 1", "each row named by its box") + T.eq(rows[4].source, "BOX 3", "empty boxes contribute nothing") + T.eq(rows[5].index, 2, "and the slot inside the box is kept") + + T.eq(TeamPick.refKey(rows[5]), "box|3|2", "a pick has a stable key") + T.eq(TeamPick.refKey(1), "party|1", + "a bare party index is the same key as its record") + T.check(TeamPick.sameRef(1, { where = "party", index = 1 }), + "so the two forms compare equal") + + T.eq(TeamPick.monAt(slot, { where = "box", box = 3, index = 2 }).nickname, + "BOXC", "monAt reads the box the record names") + T.eq(TeamPick.monAt(slot, 2).nickname, "TWO", "and the party by index") + T.eq(TeamPick.monAt(slot, { where = "box", box = 2, index = 1 }), nil, + "an empty box slot has no mon") + + local team = { { where = "box", box = 3, index = 2 }, 1 } + local packed = TeamPick.pack(slot, team, 1) + T.eq(#packed, 2, "a mixed team packs both sources") + T.eq(packed[1].nickname, "BOXC", "in the order it was picked") + T.eq(packed[2].nickname, "ONE", "party mon second") + T.eq(TeamPick.validate(slot, team, { partySize = 2 }), true, + "and validates as a two-mon team") + T.eq(TeamPick.validate(slot, + { { where = "box", box = 3, index = 2 }, + { where = "box", box = 3, index = 2 } }, { partySize = 2 }), false, + "the same box slot twice is still a double") + + local gen2 = { party = {}, generation = 2, + save = { boxes = { { mon(9, { nickname = "JOHTO" }) } }, + boxNames = { "TEAM" } } } + local g2rows = TeamPick.candidates(gen2) + T.eq(#g2rows, 1, "a Gen 2 save lists its boxes the same way") + T.eq(g2rows[1].source, "TEAM", "under the name the player gave the box") + T.eq(TeamPick.boxName({}, 2, 3), "BOX3", "an unnamed Gen 2 box is BOXn") +end + +T.finish("online arena data") diff --git a/tests/online_client.lua b/tests/online_client.lua new file mode 100644 index 00000000..ffe817c1 --- /dev/null +++ b/tests/online_client.lua @@ -0,0 +1,2081 @@ +-- luajit tests/online_client.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local Protocol2 = require("src.online.Protocol2") +local Wire = require("src.link.Wire") + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok " .. msg) + else + failures = failures + 1 + print("FAIL " .. msg) + end +end +local function eq(got, want, msg) + check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), + tostring(want))) +end + +local function copy(v) + if type(v) ~= "table" then return v end + local out = {} + for k, val in pairs(v) do out[k] = copy(val) end + return out +end + +local savedGetTime = love.timer.getTime +local CLOCK = 0 +love.timer.getTime = function() return CLOCK end + +-- ---------------------------------------------------------------- harness + +local function newTransport() + local t = { paired = true, closed = false, error = nil, + inbox = {}, outbox = {} } + function t:update() end + function t:poll() + local messages = self.inbox + self.inbox = {} + return messages + end + function t:send(msg) table.insert(self.outbox, copy(msg)) end + function t:close() self.closed = true end + return t +end + +local function newClientModule() + package.loaded["src.online.Client"] = nil + local Client = require("src.online.Client") + Client.reset() + return Client +end + +local PROFILE = { + engine = 1, version = "red", engineVersion = "0.0.0-dev", + apiVersion = "2", fingerprint = "abc123", rulesetId = "gen1_faithful", + kind = "vanilla", rule = { partySize = 1 }, +} + +local Relay = {} +Relay.__index = Relay + +local function newRelay() + return setmetatable({ seats = {}, room = nil }, Relay) +end + +function Relay:seat(id, name) + local seat = { id = id, name = name, transport = newTransport(), ack = 0 } + self.seats[id] = seat + return seat +end + +function Relay:to(seat, msg) + if not seat then return end + table.insert(seat.transport.inbox, copy(msg)) +end + +function Relay:welcome(seat, resumed) + self:to(seat, { type = "lobby_welcome", session = "S-" .. seat.id, + you = { id = seat.id, name = seat.name, verified = true }, + serverTime = 1000, heartbeatMs = 10000, + resumed = resumed == true }) +end + +function Relay:sideOf(id) + local room = self.room + if not room then return nil end + if room.players[1] and room.players[1].id == id then return "host" end + if room.players[2] and room.players[2].id == id then return "guest" end + return nil +end + +function Relay:roomStateMsg() + local room = self.room + local players = {} + for _, p in ipairs(room.players) do + table.insert(players, { id = p.id, name = p.name, verified = true, + ready = p.ready or false, online = true }) + end + local spectators = {} + for _, sp in ipairs(room.spectators) do + table.insert(spectators, { id = sp.id, name = sp.name, verified = false }) + end + return { type = "room_state", code = room.code, players = players, + spectators = spectators, stage = room.stage, + profile = room.profile, host = room.host, seed = room.seed, + rule = room.profile and room.profile.rule or nil, + intent = "battle", maxSpectators = 8, match = room.match, + deadlines = {} } +end + +function Relay:broadcast(msg) + local room = self.room + for _, p in ipairs(room.players) do self:to(self.seats[p.id], msg) end + for _, sp in ipairs(room.spectators) do self:to(self.seats[sp.id], msg) end +end + +function Relay:roomState(stage) + local room = self.room + if not room then return end + room.stage = stage or room.stage + self:broadcast(self:roomStateMsg()) +end + +function Relay:startMatch(token) + local room = self.room + room.matchNo = (room.matchNo or 0) + 1 + room.match = token or (room.code .. "-m" .. room.matchNo) + room.seed = 4242 + room.log = {} + room.seq = 0 + room.reports = {} + self:roomState("battling") + local host, guest = room.players[1], room.players[2] + local base = { seed = room.seed, ruleset = room.profile.rulesetId, + rule = room.profile.rule, hostName = host.name, + guestName = guest.name, hostParty = host.party, + guestParty = guest.party, match = room.match, + code = room.code } + local function start(seat, extra) + local msg = {} + for k, v in pairs(base) do msg[k] = v end + for k, v in pairs(extra) do msg[k] = v end + self:to(seat, msg) + end + start(self.seats[host.id], { type = "match_start", role = "host", + peerName = guest.name, theirParty = guest.party }) + start(self.seats[guest.id], { type = "match_start", role = "guest", + peerName = host.name, theirParty = host.party }) + for _, sp in ipairs(room.spectators) do + start(self.seats[sp.id], { type = "match_start_spectate", + role = "spectator" }) + end +end + +function Relay:resolve(how, winnerSide) + local room = self.room + local winner = winnerSide and room.players[winnerSide == "host" and 1 or 2] + self:broadcast({ type = "room_result", match = room.match, + winner = winner and winner.name or nil, + winnerId = winner and winner.id or nil, how = how }) + for _, p in ipairs(room.players) do p.ready = false end + room.seed = nil + room.reports = {} + self:roomState("waiting") + local entry = self.tour and self.tour.liveEntry + if entry and entry.match == room.match then + entry.state = "done" + entry.how = how + entry.winner = winner and winner.id or entry.a + local loser = entry.winner == entry.a and entry.b or entry.a + for _, p in ipairs(self.tour.players) do + if p.id == loser then p.eliminated = true end + end + self.tour.liveEntry = nil + self.tour.live = nil + self:tourState() + self:tourAdvance() + end +end + +function Relay:fanout(fromId, clientSeq, inner) + local room = self.room + if not room then return end + local side = self:sideOf(fromId) + if not side then return end + room.clientSeq = room.clientSeq or {} + if clientSeq and room.clientSeq[fromId] and clientSeq <= room.clientSeq[fromId] then + room.duplicates = (room.duplicates or 0) + 1 + return + end + if clientSeq then room.clientSeq[fromId] = clientSeq end + room.seq = room.seq + 1 + local entry = { seq = room.seq, clientSeq = clientSeq, side = side, + msg = copy(inner) } + table.insert(room.log, entry) + for _, p in ipairs(room.players) do + if p.id ~= fromId then + self:to(self.seats[p.id], { type = "room_msg", seq = entry.seq, + clientSeq = clientSeq, msg = copy(inner) }) + end + end + for _, sp in ipairs(room.spectators) do + self:to(self.seats[sp.id], { type = "room_msg", seq = entry.seq, + clientSeq = clientSeq, side = side, + msg = copy(inner) }) + end + if inner.type == "forfeit" then + room.reports[side] = "lose" + self:resolve("forfeit", side == "host" and "guest" or "host") + end +end + +function Relay:replay(seat, from) + local room = self.room + local side = self:sideOf(seat.id) + local msgs = {} + for _, e in ipairs(room.log or {}) do + if e.seq > from and not (side and e.side == side) then + table.insert(msgs, side + and { seq = e.seq, clientSeq = e.clientSeq, msg = copy(e.msg) } + or { seq = e.seq, clientSeq = e.clientSeq, side = e.side, + msg = copy(e.msg) }) + end + end + self:to(seat, { type = "room_replay", from = from, msgs = msgs, + yourSeq = (room.clientSeq or {})[seat.id] or 0 }) +end + + +-- ------------------------------------------------------------- tournaments + +local CHILD_CODES = { "CHA234", "CHB234", "CHC234", "CHD234", "CHE234", + "CHF234", "CHG234", "CHJ234" } + +function Relay:tourSeats() + local out = {} + for _, p in ipairs(self.tour.players) do out[#out + 1] = self.seats[p.id] end + for _, sp in ipairs(self.tour.spectators) do + out[#out + 1] = self.seats[sp.id] + end + return out +end + +function Relay:tourBroadcast(msg) + for _, seat in ipairs(self:tourSeats()) do self:to(seat, msg) end +end + +function Relay:tourNameOf(id) + for _, p in ipairs(self.tour.players) do + if p.id == id then return p.name end + end + return nil +end + +function Relay:tourStateMsg() + local t = self.tour + local players, spectators, bracket = {}, {}, {} + for _, p in ipairs(t.players) do + players[#players + 1] = { id = p.id, name = p.name, verified = true, + online = true, eliminated = p.eliminated == true } + end + for _, sp in ipairs(t.spectators) do + spectators[#spectators + 1] = { id = sp.id, name = sp.name, + verified = false } + end + for _, round in ipairs(t.bracket) do + local matches = {} + for _, e in ipairs(round.matches) do + matches[#matches + 1] = { match = e.match, a = e.a, b = e.b, + winner = e.winner, how = e.how, + state = e.state } + end + bracket[#bracket + 1] = { round = round.round, matches = matches } + end + return { type = "tour_state", code = t.code, creator = t.creator, + stage = t.stage, players = players, spectators = spectators, + profile = t.profile, rule = t.profile and t.profile.rule or nil, + shotClock = t.shotClock, round = t.round, bracket = bracket, + live = t.live, champion = t.champion, + maxSpectators = 16 } +end + +function Relay:tourState() + if not self.tour then return end + self:tourBroadcast(self:tourStateMsg()) +end + +function Relay:tourStartMatch(entry, roundNo) + local t = self.tour + entry.state = "live" + t.live = entry.match + t.liveEntry = entry + t.childNo = (t.childNo or 0) + 1 + local code = CHILD_CODES[t.childNo] + local function playerRec(id) + for _, p in ipairs(t.players) do + if p.id == id then + return { id = p.id, name = p.name, party = p.party } + end + end + end + local a, b = playerRec(entry.a), playerRec(entry.b) + local spectators = {} + for _, p in ipairs(t.players) do + if p.id ~= entry.a and p.id ~= entry.b then + spectators[#spectators + 1] = { id = p.id, name = p.name } + end + end + for _, sp in ipairs(t.spectators) do + spectators[#spectators + 1] = { id = sp.id, name = sp.name } + end + self.room = { code = code, players = { a, b }, spectators = spectators, + stage = "waiting", profile = t.profile, host = t.creator, + seed = nil, log = {}, seq = 0, reports = {}, matchNo = 0, + match = nil } + self:to(self.seats[entry.a], { type = "tour_match", match = entry.match, + round = roundNo, code = code }) + self:to(self.seats[entry.b], { type = "tour_match", match = entry.match, + round = roundNo, code = code }) + for _, sp in ipairs(spectators) do + self:to(self.seats[sp.id], { type = "tour_match_spectate", + match = entry.match, round = roundNo, + code = code }) + end + self:tourState() + self:to(self.seats[entry.a], { type = "tour_deadline", kind = "shot", + at = 1000 + (t.shotClock or 6) * 1000, + match = entry.match }) + self:startMatch(entry.match) +end + +function Relay:tourAdvance() + local t = self.tour + if not t or t.stage ~= "running" then return end + local round = t.bracket[#t.bracket] + for _, e in ipairs(round.matches) do + if e.state == "bye" and not e.winner then + e.winner = e.a + self:to(self.seats[e.a], { type = "tour_bye", match = e.match, + round = round.round }) + end + end + for _, e in ipairs(round.matches) do + if e.state == "pending" then + self:tourStartMatch(e, round.round) + return + end + end + local winners = {} + for _, e in ipairs(round.matches) do + if e.winner then winners[#winners + 1] = e.winner end + end + if #winners <= 1 then + t.stage = "finished" + t.champion = winners[1] + self:tourState() + self:tourBroadcast({ type = "tour_over", code = t.code, + championId = winners[1], + champion = self:tourNameOf(winners[1]) }) + return + end + t.round = t.round + 1 + local matches = {} + for i = 1, #winners, 2 do + local a, b = winners[i], winners[i + 1] + matches[#matches + 1] = { + match = ("%s-r%d-m%d"):format(t.code, t.round, math.ceil(i / 2)), + a = a, b = b, state = b and "pending" or "bye" } + end + t.bracket[#t.bracket + 1] = { round = t.round, matches = matches } + self:tourState() + self:tourAdvance() +end + +function Relay:tourHandle(seat, msg) + local kind = msg.type + local t = self.tour + if kind == "tour_create" then + self.tour = { code = "TRN234", creator = seat.id, stage = "registering", + players = {}, spectators = {}, profile = msg.profile, + shotClock = msg.shotClock, round = 0, bracket = {}, + live = nil, champion = nil, childNo = 0 } + if msg.playing == false then + table.insert(self.tour.spectators, { id = seat.id, name = seat.name }) + elseif not msg.party or #msg.party == 0 then + self.tour = nil + self:to(seat, { type = "join_error", reason = "party_ineligible" }) + return true + else + table.insert(self.tour.players, { id = seat.id, name = seat.name, + party = msg.party, + digest = msg.partyDigest }) + end + self:tourState() + return true + end + if kind ~= "tour_join" and kind ~= "tour_leave" and kind ~= "tour_start" + and kind ~= "tour_kick" and kind ~= "tour_close" then + return false + end + if not t then + self:to(seat, { type = "join_error", reason = "tour_not_found" }) + return true + end + if kind == "tour_join" then + if msg.code ~= t.code then + self:to(seat, { type = "join_error", reason = "tour_not_found" }) + return true + end + if msg.as == "spectator" then + table.insert(t.spectators, { id = seat.id, name = seat.name }) + else + if t.stage ~= "registering" then + self:to(seat, { type = "join_error", reason = "tour_started" }) + return true + end + if not msg.party or #msg.party == 0 then + self:to(seat, { type = "join_error", reason = "party_ineligible" }) + return true + end + table.insert(t.players, { id = seat.id, name = seat.name, + party = msg.party, digest = msg.partyDigest }) + end + self:tourState() + elseif kind == "tour_start" then + if seat.id ~= t.creator then + self:to(seat, { type = "join_error", reason = "not_creator" }) + return true + end + if #t.players < 2 then return true end + t.stage = "running" + t.round = 1 + local matches = {} + for i = 1, #t.players, 2 do + local a, b = t.players[i], t.players[i + 1] + matches[#matches + 1] = { + match = ("%s-r1-m%d"):format(t.code, math.ceil(i / 2)), + a = a.id, b = b and b.id or nil, + state = b and "pending" or "bye" } + end + t.bracket[1] = { round = 1, matches = matches } + self:tourState() + self:tourAdvance() + elseif kind == "tour_kick" then + if seat.id ~= t.creator then + self:to(seat, { type = "join_error", reason = "not_creator" }) + return true + end + self.tourKicked = msg.id + self:to(self.seats[msg.id], { type = "tour_closed", code = t.code, + reason = "kicked" }) + for i = #t.players, 1, -1 do + if t.players[i].id == msg.id then table.remove(t.players, i) end + end + self:tourState() + elseif kind == "tour_close" then + if seat.id ~= t.creator then + self:to(seat, { type = "join_error", reason = "not_creator" }) + return true + end + self.tourClosedBy = seat.id + self:tourBroadcast({ type = "tour_closed", code = t.code, + reason = "closed" }) + self.tour = nil + elseif kind == "tour_leave" then + self.tourLeft = seat.id + for i = #t.players, 1, -1 do + if t.players[i].id == seat.id then table.remove(t.players, i) end + end + for i = #t.spectators, 1, -1 do + if t.spectators[i].id == seat.id then table.remove(t.spectators, i) end + end + self:tourState() + end + return true +end + +function Relay:handle(seat, msg) + local kind = msg.type + local room = self.room + if kind:sub(1, 5) == "tour_" then + if self:tourHandle(seat, msg) then return end + end + if kind == "lobby_hello" then + seat.name = msg.name or seat.name + self:welcome(seat, false) + elseif kind == "resume" then + seat.resumedWith = msg.ack + self:welcome(seat, true) + if room then + self:to(seat, self:roomStateMsg()) + self:replay(seat, math.max(0, msg.ack or 0)) + end + elseif kind == "room_kick" then + self.kicked = msg.id + elseif kind == "room_close" then + self.closedBy = seat.id + if room then self:broadcast({ type = "room_closed", reason = "closed", + code = room.code }) end + self.room = nil + elseif kind == "room_create" then + self.room = { code = "ABC234", players = {}, spectators = {}, + stage = "waiting", profile = msg.profile, + host = seat.id, seed = nil, log = {}, seq = 0, + reports = {}, matchNo = 0, match = nil } + table.insert(self.room.players, { id = seat.id, name = seat.name }) + self:roomState("waiting") + elseif kind == "room_join" then + if not room or msg.code ~= room.code then + self:to(seat, { type = "join_error", reason = "not_found", + detail = tostring(msg.code) }) + return + end + if not msg.profile then + self:to(seat, { type = "join_error", reason = "bad_profile" }) + return + end + if msg.as == "spectator" then + table.insert(room.spectators, { id = seat.id, name = seat.name }) + else + table.insert(room.players, { id = seat.id, name = seat.name }) + end + self:roomState(nil) + if msg.as == "spectator" and room.stage == "battling" then + self:replay(seat, 0) + end + elseif kind == "room_ready" then + local ready = 0 + for _, p in ipairs(room.players) do + if p.id == seat.id then + p.ready = true + p.party = msg.party + p.digest = msg.partyDigest + end + if p.ready then ready = ready + 1 end + end + if ready >= 2 then self:startMatch() else self:roomState("ready") end + elseif kind == "room_msg" then + self:fanout(seat.id, msg.seq, msg.msg) + elseif kind == "room_ack" then + seat.ack = math.max(seat.ack, msg.seq or 0) + elseif kind == "room_report" or kind == "forfeit" then + if not room or not room.match then return end + if msg.match and msg.match ~= room.match then + self.staleReports = (self.staleReports or 0) + 1 + return + end + local side = self:sideOf(seat.id) + if not side then return end + room.reports[side] = kind == "forfeit" and "lose" or msg.result + local other = side == "host" and "guest" or "host" + if kind == "forfeit" then + self:resolve("forfeit", other) + elseif room.reports[other] then + local winner = room.reports.host == "win" and "host" or "guest" + self:resolve("agreed", winner) + end + elseif kind == "room_leave" then + if room then + for i = #room.players, 1, -1 do + if room.players[i].id == seat.id then table.remove(room.players, i) end + end + for i = #room.spectators, 1, -1 do + if room.spectators[i].id == seat.id then + table.remove(room.spectators, i) + end + end + end + end +end + +function Relay:pump() + local ids = {} + for id in pairs(self.seats) do ids[#ids + 1] = id end + table.sort(ids) + for _, id in ipairs(ids) do + local seat = self.seats[id] + local outbox = seat.transport.outbox + seat.transport.outbox = {} + for _, msg in ipairs(outbox) do + if type(msg) == "table" and type(msg.type) == "string" then + self:handle(seat, msg) + end + end + end +end + +local function connectTo(Client, seat, name) + Client.configure({ relayAddress = "fake:1", connect = function() + return seat.transport + end }) + return Client.connect({ name = name, profiles = { PROFILE } }) +end + +-- ---------------------------------------------------------------- protocol + +do + local hello = Protocol2.lobbyHello({ name = "REDDISHLONGNAMEHERE", + profiles = { PROFILE }, + engineVersion = "0.0.0-dev" }) + eq(hello.type, "lobby_hello", "lobbyHello builds its type") + eq(#hello.name, 16, "a display name is bounded to 16 characters") + eq(hello.profiles[1].rulesetId, "gen1_faithful", "the profile rides along") + eq(hello.profiles[1].rule.partySize, 1, "the party rule rides along") + + local join = Protocol2.roomJoin("abc234", "spectator") + eq(join.code, "ABC234", "a room code is upper-cased") + eq(join.as, "spectator", "join carries the seat kind") + eq(Protocol2.roomJoin("ABC01I", "player").code, nil, + "a code outside CodeEntry.CHARSET is refused") + eq(Protocol2.roomJoin("ABC23", "player").code, nil, + "a short code is refused") + + local wrapped = Protocol2.roomMsg(3, { type = "action", kind = "move", + slot = 2 }) + eq(wrapped.seq, 3, "room_msg keeps its seq") + eq(wrapped.msg.kind, "move", "room_msg sanitizes the inner message") + eq(Protocol2.roomMsg(1, nil), nil, "room_msg without an inner message drops") + + local list = Wire.sanitize({ type = "lobby_list", entries = (function() + local out = {} + for i = 1, 400 do out[i] = { id = "p" .. i, name = "N", since = i } end + return out + end)() }) + eq(#list.entries, 200, "a lobby list is bounded to 200 entries") + + local replay = Wire.sanitize({ type = "room_replay", from = 0, + msgs = (function() + local out = {} + for i = 1, 900 do out[i] = { seq = i, msg = { type = "bye" } } end + return out + end)() }) + eq(#replay.msgs, 512, "a replay is bounded to 512 messages") + + local state = Wire.sanitize({ type = "room_state", code = "ABC234", + players = {}, spectators = (function() + local out = {} + for i = 1, 200 do out[i] = { id = "s" .. i, name = "S" } end + return out + end)() }) + eq(#state.spectators, 64, "spectators are bounded to 64") + + eq(select(2, Protocol2.validate({ type = "room_state", players = {} })), + "room_state without a code", "a codeless room_state fails validation") + eq(select(2, Protocol2.validate({ type = "join_error" })), + "join_error without a reason", "a reasonless join_error fails validation") + eq(Protocol2.validate({ type = "action", kind = "move", slot = 1 }).kind, + "move", "an unvalidated type still passes through sanitize") + + local created = Protocol2.roomCreate({ intent = "battle", profile = PROFILE, + playing = true, maxSpectators = 4, + public = false, + note = string.rep("z", 90) }) + eq(created.public, false, "roomCreate carries a private flag") + eq(#created.note, 40, "a room note is bounded to 40 characters") + eq(Protocol2.roomCreate({ profile = PROFILE }).public, true, + "a room is public unless the caller says otherwise") + eq(Protocol2.roomCreate({ profile = PROFILE }).note, nil, + "a room without a note sends none") + + local shaped = Wire.sanitize({ type = "lobby_delta", added = { + { id = "p9", name = "N", since = 1, code = "abc234", open = "yes", + stage = "battling", players = 900, spectators = 200, + maxSpectators = 200 } } }).added[1] + eq(shaped.open, nil, "a non-boolean open is dropped from a lobby entry") + eq(shaped.stage, "battling", "a lobby entry carries the room stage") + eq(shaped.players, 64, "the entry player count is bounded") + eq(shaped.spectators, 64, "the entry spectator count is bounded") + eq(shaped.maxSpectators, 64, "the entry spectator ceiling is bounded") + local openEntry = Wire.sanitize({ type = "lobby_delta", added = { + { id = "p8", name = "N", since = 1, code = "abc234", open = true, + stage = "waiting", players = 1, spectators = 0, + maxSpectators = 4 } } }).added[1] + eq(openEntry.open, true, "an open flag survives the schema") + eq(Wire.sanitize({ type = "lobby_list", entries = {}, online = 12 }).online, + 12, "lobby_list carries the relay's online count") + + local create = Protocol2.tourCreate({ profile = PROFILE, playing = false, + shotClock = 7, maxSpectators = 999 }) + eq(create.type, "tour_create", "tourCreate builds its type") + eq(create.playing, false, "a spectating creator says so") + eq(create.shotClock, 6, "an off-list shot clock snaps to 3/6/9") + eq(Protocol2.tourCreate({ shotClock = 9 }).shotClock, 9, + "an allowed shot clock is kept") + eq(create.maxSpectators, 64, "maxSpectators is bounded to 64") + eq(create.public, true, "a tournament is public unless it says otherwise") + eq(Protocol2.tourCreate({ public = false }).public, false, + "tourCreate carries a private flag") + eq(Protocol2.tourCreate({ note = ("z"):rep(80) }).note:len(), 40, + "the tournament note is clipped to 40 characters") + + local tj = Protocol2.tourJoin("trn234", "player", PROFILE, + { { species = "PIKACHU", level = 5 } }, "dg") + eq(tj.code, "TRN234", "tour_join upper-cases its code") + eq(tj.party[1].species, "PIKACHU", "tour_join carries the team") + eq(tj.partyDigest, "dg", "tour_join carries the digest") + eq(Protocol2.tourKick("p9").id, "p9", "tour_kick names its target") + eq(Protocol2.tourStart().type, "tour_start", "tour_start builds its type") + eq(Protocol2.tourClose().type, "tour_close", "tour_close builds its type") + eq(Protocol2.tourLeave().type, "tour_leave", "tour_leave builds its type") + + local function fill(n, fn) + local out = {} + for i = 1, n do out[i] = fn(i) end + return out + end + local big = Wire.sanitize({ + type = "tour_state", code = "TRN234", creator = "p1", stage = "running", + players = fill(200, function(i) + return { id = "p" .. i, name = "N" .. i, eliminated = false } + end), + spectators = fill(200, function(i) return { id = "s" .. i, name = "S" } end), + bracket = fill(20, function(r) + return { round = r, matches = fill(80, function(k) + return { match = "m" .. r .. "-" .. k, a = "p1", b = "p2", + state = "pending" } + end) } + end), + }) + eq(#big.players, 64, "a tournament is bounded to 64 players") + eq(#big.spectators, 64, "and to 64 spectators") + eq(#big.bracket, 7, "a bracket is bounded to 7 rounds") + eq(#big.bracket[1].matches, 32, "a round is bounded to 32 matches") + eq(big.players[1].eliminated, false, "an elimination mark rides along") + eq(Wire.sanitize({ type = "tour_state", code = "TRN234", stage = "nonsense", + players = {} }).stage, "registering", + "an unknown stage falls back to registering") + eq(Wire.sanitize({ type = "tour_state", code = "TRN234", players = {}, + bracket = { { round = 1, matches = { + { match = "m1", a = "p1", state = "sideways" } } } } + }).bracket[1].matches[1].state, nil, + "an unknown match state is dropped") + + eq(select(2, Protocol2.validate({ type = "tour_match", round = 1 })), + "tour_match without a match token", + "a tokenless tour_match fails validation") + eq(select(2, Protocol2.validate({ type = "tour_match", match = "m1" })), + "tour_match without a child room code", + "a codeless tour_match fails validation") + eq(select(2, Protocol2.validate({ type = "tour_deadline", kind = "shot" })), + "tour_deadline without a time", "a timeless tour_deadline fails") + eq(select(2, Protocol2.validate({ type = "tour_over" })), + "tour_over without a code", "a codeless tour_over fails") + eq(Protocol2.validate({ type = "tour_match", match = "m1", + code = "cha234" }).code, "CHA234", + "a child room code is upper-cased on the way in") + eq(select(2, Protocol2.validate({ type = "tour_closed", code = "TRN234" })), + "tour_closed without a reason", "a reasonless tour_closed fails") + eq(Protocol2.validate({ type = "tour_closed", code = "trn234", + reason = "idle" }).code, "TRN234", + "tour_closed carries its tournament code") + eq(Protocol2.tourCreate({ profile = PROFILE, party = { + { species = "PIKACHU", level = 5 } }, partyDigest = "dd" }).party[1] + .species, "PIKACHU", "tour_create carries the creator's team") +end + +-- ---------------------------------------------------------------- handshake + +local relay = newRelay() +local seatA = relay:seat("a1", "RED") +local ClientA = newClientModule() + +local states = {} +ClientA.on("state", function(s) table.insert(states, s) end) + +eq(ClientA.state(), "offline", "a fresh client is offline") +check(connectTo(ClientA, seatA, "RED"), "connect opens the transport") +eq(ClientA.state(), "connecting", "connect moves to connecting") +eq(seatA.transport.outbox[1].type, "lobby_hello", "connect sends lobby_hello") +relay:pump() +ClientA.update(1 / 60) +eq(ClientA.state(), "online", "lobby_welcome moves to online") +eq(ClientA.you().name, "RED", "the welcome names us") +eq(ClientA.you().verified, true, "the welcome carries verification") +check(ClientA.serverTime() ~= nil and ClientA.serverTime() >= 1000, + "the client estimates the relay's clock") +eq(ClientA.sessionId(), "S-a1", "the session id is held for resume") +eq(table.concat(states, ","), "connecting,online", + "state transitions are offline -> connecting -> online") + +-- ---------------------------------------------------------------- lobby + +local lobbyEvents = 0 +ClientA.on("lobby", function() lobbyEvents = lobbyEvents + 1 end) + +relay:to(seatA, { type = "lobby_list", entries = { + { id = "p3", name = "GREEN", since = 30, intent = "battle", profile = PROFILE }, + { id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE }, + { id = "p2", name = "LEAF", since = 20, intent = "trade", profile = PROFILE }, +} }) +ClientA.update(0) +eq(#ClientA.lobby(), 3, "the lobby list lands") +eq(ClientA.lobby()[1].name, "BLUE", "entries order by since (first)") +eq(ClientA.lobby()[3].name, "GREEN", "entries order by since (last)") +eq(lobbyEvents, 1, "a lobby list emits one lobby event") + +relay:to(seatA, { type = "lobby_delta", + added = { { id = "p0", name = "GOLD", since = 5, + code = "abc234" } }, + removed = { "p2" }, changed = {} }) +ClientA.update(0) +eq(#ClientA.lobby(), 3, "a delta adds and removes") +eq(ClientA.lobby()[1].name, "GOLD", "a delta keeps the ordering stable") +eq(ClientA.lobby()[1].code, "ABC234", + "a lobby entry carries the advertiser's room code") +local names = {} +for _, e in ipairs(ClientA.lobby()) do table.insert(names, e.name) end +eq(table.concat(names, ","), "GOLD,BLUE,GREEN", "the whole order is stable") + +relay:to(seatA, { type = "lobby_delta", added = {}, removed = {}, + changed = { { id = "p1", name = "BLUE", since = 10, + note = "wants a battle" } } }) +ClientA.update(0) +eq(ClientA.lobby()[2].note, "wants a battle", "a changed entry updates in place") +eq(#ClientA.lobby(), 3, "a change does not duplicate an entry") + +relay:to(seatA, { type = "lobby_delta", op = "update", + entry = { id = "p1", name = "BLUE", since = 10, + note = "still here" } }) +ClientA.update(0) +eq(ClientA.lobby()[2].note, "still here", + "the older single-entry delta shape still applies") + +-- ------------------------------------------------------- open rooms, watch + +relay:to(seatA, { type = "lobby_list", online = 9, entries = { + { id = "a1", name = "RED", since = 5, intent = "battle", profile = PROFILE, + code = "aaa234", open = true, stage = "waiting", players = 1, + spectators = 0, maxSpectators = 4 }, + { id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE, + code = "bbb234", open = true, stage = "waiting", players = 1, + spectators = 0, maxSpectators = 4 }, + { id = "p2", name = "LEAF", since = 20, intent = "battle", profile = PROFILE, + code = "ccc234", open = false, stage = "battling", players = 2, + spectators = 1, maxSpectators = 4 }, + { id = "p3", name = "GREEN", since = 30, intent = "battle", profile = PROFILE, + code = "ddd234", open = false, stage = "battling", players = 2, + spectators = 4, maxSpectators = 4 }, + { id = "p4", name = "GOLD", since = 40, intent = "battle", profile = PROFILE, + note = "looking for a battle" }, + { id = "p5", name = "SILVER", since = 50, intent = "tournament", + profile = PROFILE, code = "eee234", open = false, stage = "registering", + players = 3, spectators = 0, maxSpectators = 8 }, + { id = "p6", name = "CRYS", since = 60, intent = "battle", profile = PROFILE, + code = "fff234", open = true, stage = "waiting", players = 1, + spectators = 0, maxSpectators = 0 }, +} }) +ClientA.update(0) + +local openRooms = ClientA.openRooms() +eq(#openRooms, 2, "openRooms keeps only the open, coded entries") +eq(openRooms[1].code, "BBB234", "openRooms sorts by since") +eq(openRooms[2].code, "FFF234", "...and the later room comes second") +local sawSelf = false +for _, e in ipairs(openRooms) do + if e.id == ClientA.you().id then sawSelf = true end +end +check(not sawSelf, "openRooms never lists your own room") +eq(openRooms[1].players, 1, "an open room entry carries its player count") +eq(openRooms[1].maxSpectators, 4, "...and its spectator ceiling") + +local watchable = ClientA.watchable() +eq(#watchable, 2, "watchable keeps live rooms with a free spectator seat") +eq(watchable[1].code, "CCC234", "a battling room with room to spectate is watchable") +eq(watchable[2].code, "EEE234", "a tournament is watchable too") +local sawFull = false +for _, e in ipairs(watchable) do + if e.code == "DDD234" then sawFull = true end +end +check(not sawFull, "a room with every spectator seat taken is not watchable") + +local counts = ClientA.counts() +eq(counts.players, 9, "counts uses the relay's online count when it is sent") +eq(counts.openRooms, 2, "counts reports the open room total") + +relay:to(seatA, { type = "lobby_list", entries = { + { id = "p1", name = "BLUE", since = 10, intent = "battle", profile = PROFILE, + code = "bbb234", open = true, stage = "waiting", players = 1, + spectators = 0, maxSpectators = 4 }, + { id = "p4", name = "GOLD", since = 40, intent = "battle", profile = PROFILE }, +} }) +ClientA.update(0) +local fallback = ClientA.counts() +eq(fallback.players, 2, "without an online count, counts falls back to entries") +eq(fallback.openRooms, 1, "and still counts the open rooms") +eq(#ClientA.watchable(), 0, "nothing is watchable in a quiet lobby") + +relay:to(seatA, { type = "lobby_list", entries = { + { id = "a1", name = "RED", since = 5, intent = "tournament", + profile = PROFILE, code = "ggg234", open = true, stage = "registering", + players = 8, spectators = 0, maxSpectators = 8 }, + { id = "p7", name = "IVY", since = 6, intent = "tournament", + profile = PROFILE, code = "hhh234", open = true, stage = "registering", + players = 5, spectators = 0, maxSpectators = 8 }, +} }) +ClientA.update(0) +local watchMine = ClientA.watchable() +eq(#watchMine, 1, "watchable drops the caller's own entry") +eq(watchMine[1].code, "HHH234", "only another trainer's tournament is watchable") +eq(watchMine[1].players, 5, + "a tournament entry keeps a player count above the room ceiling") + +-- ---------------------------------------------------------------- advertise + +ClientA.advertise("battle", PROFILE, "come at me") +relay:pump() +eq(seatA.transport.outbox[1], nil, "advertise leaves nothing queued after a pump") +ClientA.unadvertise() +local sawUnadvertise = false +for _, m in ipairs(seatA.transport.outbox) do + if m.type == "unadvertise" then sawUnadvertise = true end +end +check(sawUnadvertise, "unadvertise is sent") +relay:pump() + +-- ---------------------------------------------------------------- rooms + +local roomEvents = 0 +ClientA.on("room", function() roomEvents = roomEvents + 1 end) +local pending = ClientA.createRoom({ intent = "battle", profile = PROFILE, + playing = true, maxSpectators = 8, + public = true, note = "come and get it" }) +local createSent +for _, m in ipairs(seatA.transport.outbox) do + if m.type == "room_create" then createSent = m end +end +check(createSent ~= nil, "createRoom sends a room_create") +eq(createSent and createSent.public, true, "createRoom sends the public flag") +eq(createSent and createSent.note, "come and get it", + "createRoom sends the note") +relay:pump() +ClientA.update(0) +check(pending.done, "createRoom's promise completes") +eq(pending.code, "ABC234", "createRoom yields a room code") +eq(ClientA.room().code, "ABC234", "the room model holds the code") +eq(ClientA.room().stage, "waiting", "a fresh room is waiting") +eq(#ClientA.room().players, 1, "the creator is the only player") +eq(roomEvents, 1, "room_state emits a room event") + +local seatB = relay:seat("b1", "BLUE") +local ClientB = newClientModule() +connectTo(ClientB, seatB, "BLUE") +relay:pump() +ClientB.update(0) +eq(ClientB.state(), "online", "the second client comes online") +local joinPending = ClientB.joinRoom("ABC234", "player") +local joinMsg +for _, m in ipairs(seatB.transport.outbox) do + if m.type == "room_join" then joinMsg = m end +end +eq(joinMsg and joinMsg.profile and joinMsg.profile.fingerprint, "abc123", + "room_join carries the joiner's profile") +relay:pump() +ClientA.update(0) +ClientB.update(0) +check(joinPending.done, "joinRoom's promise completes") +eq(ClientB.room().code, "ABC234", "the guest sees the room") +eq(#ClientA.room().players, 2, "the host sees both players") + +local seatC = relay:seat("c1", "GREEN") +local ClientC = newClientModule() +connectTo(ClientC, seatC, "GREEN") +relay:pump() +ClientC.update(0) +ClientC.joinRoom("ABC234", "spectator") +local specJoin +for _, m in ipairs(seatC.transport.outbox) do + if m.type == "room_join" then specJoin = m end +end +eq(specJoin and specJoin.as, "spectator", "a spectator join names its seat") +eq(specJoin and specJoin.profile and specJoin.profile.rulesetId, "gen1_faithful", + "a spectator sends its profile too") +relay:pump() +ClientA.update(0); ClientB.update(0); ClientC.update(0) +eq(#ClientC.room().spectators, 1, "a spectator lands in the room") +eq(#ClientC.room().players, 2, "a spectator sees both players") + +local badJoin = ClientC.joinRoom("ZZZZZZ", "player") +relay:pump() +ClientC.update(0) +check(badJoin.done and badJoin.error ~= nil, "a bad code answers join_error") +eq(badJoin.reason, "not_found", "the join error keeps its reason") + +-- ---------------------------------------------------------------- match_start + +local startA, startB, startC +ClientA.on("match_start", function(p) startA = p end) +ClientB.on("match_start", function(p) startB = p end) +ClientC.on("match_start", function(p) startC = p end) + +local partyRed = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } +local partyBlue = { { species = "BLASTOISE", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } +ClientA.ready(partyRed, "digestA") +ClientB.ready(partyBlue, "digestB") +relay:pump() +ClientA.update(0); ClientB.update(0); ClientC.update(0) + +check(startA ~= nil and startB ~= nil, "both players get match_start") +eq(ClientA.room().stage, "battling", "a started room reads as battling") +eq(startA.match, "ABC234-m1", "match_start carries the match token") +eq(ClientA.match(), "ABC234-m1", "the client holds the match token") +eq(ClientA.role(), "host", "the client knows its role") +eq(ClientC.role(), "spectator", "a spectator knows its role") +eq(startA.role, "host", "the creator is the host") +eq(startB.role, "guest", "the joiner is the guest") +eq(startC and startC.role, "spectator", "the spectator gets a spectator role") +eq(startA.seed, 4242, "match_start carries the room seed") +eq(startA.ruleset, "gen1_faithful", "match_start carries the ruleset") +eq(startA.rule.partySize, 1, "match_start carries the party rule") +eq(startA.peerName, "BLUE", "the host's peer is the guest") +eq(startB.peerName, "RED", "the guest's peer is the host") +eq(startA.hostName, "RED", "match_start names the host") +eq(startA.guestName, "BLUE", "match_start names the guest") +eq(startA.myParty, nil, "match_start leaves myParty for ArenaBoot") +eq(startA.theirParty[1].species, "BLASTOISE", "the host's theirParty is the guest's") +eq(startB.theirParty[1].species, "CHARIZARD", "the guest's theirParty is the host's") +eq(startC.hostParty[1].species, "CHARIZARD", "a spectator gets the host party") +eq(startC.guestParty[1].species, "BLASTOISE", "a spectator gets the guest party") +eq(startC.theirParty, nil, "a spectator has no theirParty") + +-- ---------------------------------------------------------------- room session + +local sessA = ClientA.roomSession() +local sessB = ClientB.roomSession() +check(sessA ~= nil and sessB ~= nil, "roomSession exists once a room does") +eq(sessA.paired, true, "a two-player room reads as paired") +eq(sessA.closed, false, "a live room session is not closed") + +sessA:send({ type = "action", kind = "move", slot = 1 }) +sessA:send({ type = "hash", turn = 1, value = "x" }) +local sentSeqs = {} +for _, m in ipairs(seatA.transport.outbox) do + if m.type == "room_msg" then table.insert(sentSeqs, m.seq) end +end +eq(table.concat(sentSeqs, ","), "1,2", "send wraps into room_msg with a rising seq") + +relay:pump() +ClientB.update(0) +local got = sessB:poll() +eq(#got, 2, "poll unwraps both inner messages") +eq(got[1].type, "action", "poll keeps seq order (first)") +eq(got[2].type, "hash", "poll keeps seq order (second)") +local ackSeq +for _, m in ipairs(seatB.transport.outbox) do + if m.type == "room_ack" then ackSeq = m.seq end +end +eq(ackSeq, 2, "poll acks the highest delivered seq") + +relay:pump() +sessA:send({ type = "replace", index = 2 }) +sessA:send({ type = "bye" }) +relay:pump() +ClientB.update(0) +local taken = sessB:take("bye") +check(taken ~= nil, "take pulls a message by type") +eq(sessB:pollOne().type, "replace", "the untaken message stays in order") +sessB:unread({ { type = "forfeit" } }) +eq(sessB:take("forfeit") ~= nil, true, "unread puts a message back") + +-- ---------------------------------------------------------------- reconnect + +relay:pump() +sessA:send({ type = "hash", turn = 2, value = "y" }) +sessA:send({ type = "hash", turn = 3, value = "z" }) +relay:pump() +ClientB.update(0) +eq(#sessB:poll(), 2, "two more messages reach the guest before the drop") + +sessA:send({ type = "hash", turn = 4, value = "w" }) +relay:pump() + +local oldTransport = seatB.transport +oldTransport.closed = true +ClientB.update(0) +eq(ClientB.state(), "reconnecting", "a dropped transport goes to reconnecting") + +seatB.transport = newTransport() +ClientB.configure({ connect = function() return seatB.transport end }) +CLOCK = CLOCK + 2 +ClientB.update(0) +local resumeMsg +for _, m in ipairs(seatB.transport.outbox) do + if m.type == "resume" then resumeMsg = m end +end +check(resumeMsg ~= nil, "the reconnect sends resume") +eq(resumeMsg.session, "S-b1", "resume carries the session id") +eq(resumeMsg.ack, 6, "resume carries the last acked seq") + +relay:pump() +ClientB.update(0) +eq(ClientB.state(), "online", "the resumed client is online again") +local replayed = ClientB.roomSession():poll() +eq(#replayed, 1, "the replay delivers only the unacked message") +eq(replayed[1].value, "w", "the replayed message is the missing one") + +-- ------------------------------------------------------------- spectating + +local sessC = ClientC.roomSession() +check(sessC ~= nil, "a spectator holds a room session") +relay:pump() +ClientC.update(0) +sessC:poll() +sessA:send({ type = "action", kind = "move", slot = 3 }) +relay:pump() +ClientC.update(0) +local watched = sessC:poll() +eq(#watched, 1, "a spectator's poll delivers the fanned-out message") +eq(watched[1].type, "spectate", + "a side-tagged room_msg reaches a spectator as a spectate envelope") +eq(watched[1].side, "host", "the envelope keeps the side that sent it") +eq(watched[1].msg.kind, "move", "and carries the inner message") +sessA:send({ type = "hash", turn = 9, value = "s" }) +relay:pump() +ClientC.update(0) +local takenSpec = sessC:take("spectate", function(m) return m.side == "host" end) +check(takenSpec ~= nil and takenSpec.msg.type == "hash", + "take matches a spectate envelope by type and side") +ClientB.update(0) +local peerGot = sessB:poll() +check(#peerGot > 0 and peerGot[#peerGot].type == "hash", + "the seated peer still gets bare inner messages") + +-- ------------------------------------------------- resume replays its tail + +relay:pump() +sessB:send({ type = "hash", turn = 5, value = "b5" }) +relay:pump() +ClientA.update(0) +sessA:poll() +sessB:send({ type = "hash", turn = 6, value = "b6" }) +seatB.transport.closed = true +ClientB.update(0) +eq(ClientB.state(), "reconnecting", "the second drop goes to reconnecting") +seatB.transport = newTransport() +ClientB.configure({ connect = function() return seatB.transport end }) +CLOCK = CLOCK + 2 +ClientB.update(0) +relay:pump() +local yourSeqSeen +for _, m in ipairs(seatB.transport.inbox) do + if m.type == "room_replay" then yourSeqSeen = m.yourSeq end +end +ClientB.update(0) +eq(ClientB.state(), "online", "the second resume lands") +eq(yourSeqSeen, 1, "room_replay tells the resumed client its last logged seq") +local extraResume = false +for _, m in ipairs(seatB.transport.outbox) do + if m.type == "resume" then extraResume = true end +end +check(not extraResume, "a landed resume clears the retry timer instead of re-resuming") +check(ClientB.roomSession() == sessB, + "the room session object survives a resume, so a live battle keeps its net") +relay:pump() +ClientA.update(0) +local resent = sessA:poll() +eq(#resent, 1, "the resumed client replays only the message the relay missed") +eq(resent[1].value, "b6", "and it is the one that never arrived") +eq((relay.room.duplicates or 0), 0, + "nothing the relay already had was sent a second time") +ClientB.sendRaw(Protocol2.roomMsg(2, { type = "hash", turn = 6, value = "b6" })) +relay:pump() +ClientA.update(0) +eq(#sessA:poll(), 0, "a re-sent clientSeq is dropped by the relay") +eq(relay.room.duplicates, 1, "and counted as a duplicate") + +-- ---------------------------------------------------------------- results + +local endA, endB, endC +ClientA.on("match_end", function(p) endA = p end) +ClientB.on("match_end", function(p) endB = p end) +ClientC.on("match_end", function(p) endC = p end) + +ClientA.report("win") +local reportMsg +for _, m in ipairs(seatA.transport.outbox) do + if m.type == "room_report" then reportMsg = m end +end +check(reportMsg ~= nil, "report sends room_report") +eq(reportMsg.match, "ABC234-m1", "room_report carries the live match token") +eq(reportMsg.result, "win", "room_report carries the result") + +ClientB.report("lose") +relay:pump() +ClientA.update(0); ClientB.update(0); ClientC.update(0) + +check(endA ~= nil and endB ~= nil, "both players get match_end") +eq(endA.how, "agreed", "match_end carries how the relay resolved it") +eq(endA.match, "ABC234-m1", "match_end carries the match token") +eq(endA.winnerId, "a1", "match_end carries the winner's seat id") +eq(endA.youWon, true, "the winner is told it won") +eq(endB.youWon, false, "the loser is told it did not") +eq(endC and endC.youWon, false, "a spectator never wins") +eq(ClientA.room().stage, "waiting", "the room returns to waiting after a result") + +ClientA.ready(partyRed, "digestA") +ClientB.ready(partyBlue, "digestB") +relay:pump() +ClientA.update(0); ClientB.update(0) +eq(ClientA.match(), "ABC234-m2", "a rematch takes a fresh match token") + +endA, endB = nil, nil +ClientB.forfeit() +local forfeitMsg +for _, m in ipairs(seatB.transport.outbox) do + if m.type == "forfeit" then forfeitMsg = m end +end +check(forfeitMsg ~= nil, "forfeit sends a top-level forfeit") +eq(forfeitMsg.match, "ABC234-m2", "forfeit carries the live match token") +relay:pump() +ClientA.update(0); ClientB.update(0) +eq(endA and endA.how, "forfeit", "a forfeit resolves as a forfeit") +eq(endA and endA.youWon, true, "the forfeiter's opponent wins") + +ClientA.ready(partyRed, "digestA") +ClientB.ready(partyBlue, "digestB") +relay:pump() +ClientA.update(0); ClientB.update(0) +local thirdMatch = ClientA.match() +eq(thirdMatch, "ABC234-m3", "a third match takes another token") +endA, endB = nil, nil +ClientA.report("error") +local errForfeit +for _, m in ipairs(seatA.transport.outbox) do + if m.type == "forfeit" then errForfeit = m end +end +check(errForfeit ~= nil, "report with a non-result forfeits instead") +eq(errForfeit and errForfeit.match, thirdMatch, + "the forfeit names the live match") +relay:pump() +ClientA.update(0); ClientB.update(0) +eq(endA and endA.how, "forfeit", "the relay resolves a reported non-result") +eq(endB and endB.youWon, true, "the other seat wins it") + +local staleBefore = relay.staleReports or 0 +ClientA.report("win") +relay:pump() +eq(relay.staleReports or 0, staleBefore, + "a report after a result still names the match the relay knows") +ClientA.update(0) + +ClientA.kick("b1") +relay:pump() +eq(relay.kicked, "b1", "kick sends room_kick with the target id") + +relay:to(seatA, { type = "room_closed", reason = "closed", code = "ABC234" }) +local closedErr +ClientA.on("error", function(e) closedErr = e end) +ClientA.update(0) +eq(ClientA.room(), nil, "room_closed clears the room model") +eq(ClientA.match(), nil, "room_closed clears the match token") +eq(ClientA.roomSession(), nil, "room_closed drops the room session") +eq(closedErr and closedErr.reason, "closed", "room_closed surfaces its reason") +eq(ClientA.closeRoom(), false, "closeRoom with no room is a no-op") + +check(ClientC.closeRoom(), "closeRoom sends room_close while in a room") +relay:pump() +eq(relay.closedBy, "c1", "the relay saw room_close") +ClientC.update(0) +eq(ClientC.room(), nil, "the room_close broadcast clears the room") + + +-- ------------------------------------------------- reports across a drop + +do + local rr = newRelay() + local sh = rr:seat("h1", "HOST") + local sg = rr:seat("g1", "GUEST") + local CH, CG = newClientModule(), newClientModule() + connectTo(CH, sh, "HOST") + connectTo(CG, sg, "GUEST") + local function pump() + rr:pump() + CH.update(0) + CG.update(0) + end + local function countOut(seat, kind) + local n = 0 + for _, m in ipairs(seat.transport.outbox) do + if m.type == kind then n = n + 1 end + end + return n + end + local function drop(client, seat) + seat.transport.closed = true + client.update(0) + end + local function relink(client, seat) + seat.transport = newTransport() + client.configure({ connect = function() return seat.transport end }) + CLOCK = CLOCK + 30 + client.update(0) + end + pump() + + local party = { { species = "PIKACHU", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + CH.createRoom({ profile = PROFILE }) + pump() + CG.joinRoom("ABC234", "player", PROFILE) + pump() + CH.ready(party, "dh") + CG.ready(party, "dg") + pump() + local m1 = CH.match() + eq(m1, "ABC234-m1", "the drop scenario opens on a live match") + + local endH + CH.on("match_end", function(p) endH = p end) + + drop(CH, sh) + eq(CH.state(), "reconnecting", "the reporting seat drops to reconnecting") + eq(CH.report("win"), false, "a report sent while the socket is down waits") + check(CH.pendingReport() ~= nil and CH.pendingReport().match == m1, + "the report is queued against the match it belongs to") + relink(CH, sh) + rr:pump() + CH.update(0) + eq(CH.state(), "online", "the resume lands") + eq(countOut(sh, "room_report"), 1, + "the queued report goes out once the session is back") + local queuedReport + for _, m in ipairs(sh.transport.outbox) do + if m.type == "room_report" then queuedReport = m end + end + eq(queuedReport and queuedReport.match, m1, + "the delivered report still names its own match") + eq(CH.pendingReport(), nil, "the queue empties once it is sent") + CH.update(0) + eq(countOut(sh, "room_report"), 1, "and it is never sent twice") + pump() + CG.report("lose") + pump() + eq(endH and endH.how, "agreed", "the relay resolves on the resumed report") + + CH.ready(party, "dh") + CG.ready(party, "dg") + pump() + local m2 = CH.match() + check(m2 ~= m1, "a rematch takes a fresh token") + drop(CH, sh) + CH.report("win") + check(CH.pendingReport() ~= nil, "the second report queues too") + relink(CH, sh) + rr:pump() + rr:to(sh, { type = "room_result", match = m2, winner = "GUEST", + winnerId = "g1", how = "stall" }) + CH.update(0) + eq(countOut(sh, "room_report"), 0, + "a room_result that arrives first cancels the queued report") + eq(CH.pendingReport(), nil, "and clears the queue") + eq(endH and endH.match, m2, "the client still reports the match as ended") + eq(endH and endH.code, "ABC234", "room_result carries the room code") + endH = nil + rr:to(sh, { type = "room_result", match = m2, code = "ZZZ234", + winnerId = "g1", how = "stall" }) + CH.update(0) + eq(endH and endH.code, "ZZZ234", "a room_result with a code uses that code") + + CH.ready(party, "dh") + CG.ready(party, "dg") + pump() + local m3 = CH.match() + drop(CH, sh) + CH.report("win") + eq(CH.pendingReport().match, m3, "the report is queued for match A") + rr:resolve("stall", "guest") + rr:startMatch() + local mB = rr.room.match + check(mB ~= m3, "the relay moved on to match B while the seat was away") + local staleBefore = rr.staleReports or 0 + relink(CH, sh) + rr:pump() + CH.update(0) + rr:pump() + eq(countOut(sh, "room_report"), 0, + "a report queued for match A is dropped once the room is on match B") + eq(rr.staleReports or 0, staleBefore, + "and the relay never sees a stale report") + eq(CH.pendingReport(), nil, "the queue is empty after the move") + eq(CH.match(), mB, "the client follows the relay to match B") + + local sess = CG.roomSession() + check(sess ~= nil, "the guest still holds a room session") + local before = CG.unackedCount() + for i = 1, 600 do sess:send({ type = "hash", turn = i, value = "v" }) end + eq(CG.unackedCount(), 512, "unacked is capped at the relay's replay bound") + eq(CG.unackedDropped(), before + 600 - 512, + "the overflow is counted as dropped") + rr:pump() + CH.update(0) + local hs = CH.roomSession() + if hs then hs:poll() end + local highest = 0 + for _, e in ipairs(rr.room.log) do + if e.clientSeq and e.clientSeq > highest then highest = e.clientSeq end + end + rr:to(sg, { type = "room_replay", from = 0, msgs = {}, + yourSeq = highest - 40 }) + CG.update(0) + eq(CG.unackedCount(), 40, "room_replay yourSeq trims what the relay acked") + + local lost + CG.on("error", function(e) lost = e end) + rr:to(sg, { type = "room_replay", from = 0, msgs = {}, yourSeq = 1 }) + CG.update(0) + eq(lost and lost.reason, "resume_incomplete", + "a resume past the replay bound gives up on the room") + eq(CG.room(), nil, "and clears the room") + eq(countOut(sg, "room_leave") >= 1, true, "and tells the relay it left") + + local CX = newClientModule() + local sx = rr:seat("x1", "EXPIRED") + connectTo(CX, sx, "EXPIRED") + rr:pump() + CX.update(0) + eq(CX.state(), "online", "the expiring seat connects") + drop(CX, sx) + relink(CX, sx) + eq(countOut(sx, "resume"), 1, "the reconnect asks to resume") + rr:to(sx, { type = "join_error", reason = "resume_expired" }) + CX.update(0) + eq(countOut(sx, "lobby_hello"), 1, + "a refused resume falls back to a fresh hello") + eq(CX.room(), nil, "and holds no room") +end + +-- ---------------------------------------------------------- tournament run + +do + local tr = newRelay() + local s1 = tr:seat("t1", "ONE") + local s2 = tr:seat("t2", "TWO") + local s3 = tr:seat("t3", "THREE") + local s4 = tr:seat("t4", "WATCH") + local C1, C2, C3, C4 = newClientModule(), newClientModule(), + newClientModule(), newClientModule() + local seatsOf = { [C1] = s1, [C2] = s2, [C3] = s3, [C4] = s4 } + connectTo(C1, s1, "ONE"); connectTo(C2, s2, "TWO") + connectTo(C3, s3, "THREE"); connectTo(C4, s4, "WATCH") + local function pump() + tr:pump() + C1.update(0); C2.update(0); C3.update(0); C4.update(0) + end + local function outbox(client, kind) + local found + for _, m in ipairs(seatsOf[client].transport.outbox) do + if m.type == kind then found = m end + end + return found + end + pump() + + local tourEvents = 0 + C1.on("tournament", function() tourEvents = tourEvents + 1 end) + local playNext, watchNext, byeSeen, overSeen = nil, nil, nil, nil + C1.on("tour_match", function(p) playNext = p end) + C3.on("tour_bye", function(p) byeSeen = p end) + C4.on("tour_spectate", function(p) watchNext = p end) + C1.on("tour_over", function(p) overSeen = p end) + local startedC1, startedC4 + C1.on("match_start", function(p) startedC1 = p end) + C4.on("match_start", function(p) startedC4 = p end) + + local party1 = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + local teamless = C1.createTournament({ profile = PROFILE, playing = true, + shotClock = 6 }) + pump() + check(teamless.done and teamless.reason == "party_ineligible", + "a playing creator without a team is refused") + eq(C1.tournament(), nil, "and holds no tournament") + + local made = C1.createTournament({ profile = PROFILE, playing = true, + shotClock = 6, maxSpectators = 16, + party = party1, partyDigest = "d1", + public = true, note = "open bracket" }) + local createMsg = outbox(C1, "tour_create") + eq(createMsg and createMsg.public, true, + "createTournament forwards the public flag") + eq(createMsg and createMsg.note, "open bracket", + "createTournament forwards the note") + eq(createMsg and createMsg.shotClock, 6, "tour_create carries the shot clock") + eq(createMsg and createMsg.playing, true, "tour_create says the creator plays") + eq(createMsg and createMsg.party[1].species, "CHARIZARD", + "a playing creator sends its team with tour_create") + eq(createMsg and createMsg.partyDigest, "d1", "and its digest") + pump() + check(made.done and made.code == "TRN234", "createTournament yields a code") + eq(C1.tournament().code, "TRN234", "the tournament model holds the code") + eq(C1.tournament().stage, "registering", "a fresh tournament is registering") + eq(C1.tournament().creator, "t1", "the creator is named") + eq(#C1.tournament().players, 1, "the playing creator is a player") + eq(tourEvents, 1, "tour_state emits one tournament event") + + local party2 = { { species = "BLASTOISE", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + local party3 = { { species = "VENUSAUR", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + C2.joinTournament("TRN234", "player", party2, "d2") + local joinMsg2 = outbox(C2, "tour_join") + eq(joinMsg2 and joinMsg2.code, "TRN234", "tour_join carries the code") + eq(joinMsg2 and joinMsg2.as, "player", "tour_join names the seat kind") + eq(joinMsg2 and joinMsg2.party[1].species, "BLASTOISE", + "a player sends its team up front") + eq(joinMsg2 and joinMsg2.partyDigest, "d2", "and its digest") + C3.joinTournament("TRN234", "player", party3, "d3") + C4.joinTournament("TRN234", "spectator") + pump() + eq(#C1.tournament().players, 3, "three players are registered") + eq(#C1.tournament().spectators, 1, "the outside spectator is registered") + eq(C4.tournament().code, "TRN234", "a spectator holds the same model") + + local refused = C2.joinTournament("ZZZZZZ", "player", party2, "d2") + pump() + check(refused.done and refused.reason == "tour_not_found", + "a bad tournament code answers join_error") + eq(Protocol2.joinErrorText({ reason = "tour_started" }), + "That tournament has already started.", + "the new join_error reasons have human text") + + eq(C2.startTournament(), true, "a non-creator may still send tour_start") + pump() + eq(C1.tournament().stage, "registering", + "but the relay refuses it: the bracket has not started") + + C1.startTournament() + pump() + local tour = C1.tournament() + eq(tour.stage, "running", "tour_start moves the tournament to running") + eq(#tour.bracket, 1, "the first round is drawn") + eq(#tour.bracket[1].matches, 2, "three players make two first-round matches") + eq(tour.bracket[1].matches[2].state, "bye", "the odd player gets a bye") + check(byeSeen ~= nil and byeSeen.match == "TRN234-r1-m2", + "the walkover player is told about its bye") + + check(playNext ~= nil, "the paired player gets tour_match") + eq(playNext.code, "CHA234", "tour_match names the child room") + eq(playNext.match, "TRN234-r1-m1", "tour_match names the bracket match") + check(watchNext ~= nil, "the outside spectator gets tour_match_spectate") + eq(watchNext.code, "CHA234", "the spectator gets the same child room") + + eq(C1.room().code, "CHA234", "the client switches to the child room") + eq(C1.roomSession().code, "CHA234", "roomSession binds to the child code") + eq(C1.match(), "TRN234-r1-m1", "the match token is the bracket match") + check(startedC1 ~= nil, "the child room fires match_start") + eq(startedC1.match, "TRN234-r1-m1", "match_start carries the match") + eq(startedC1.role, "host", "the first seat hosts the child room") + eq(startedC1.code, "CHA234", "match_start names the child room") + check(startedC4 ~= nil, "the spectator boots too") + eq(startedC4.role, "spectator", "and does so as a spectator") + eq(C4.roomSession().code, "CHA234", + "the spectator's session binds to the child room") + eq(C3.room() and C3.room().code, "CHA234", + "the waiting player watches the live match") + + eq(C1.tournament().deadlines.shot, 7000, + "tour_deadline lands in the tournament model by kind") + eq(C1.room().deadlines.shot, 7000, "and in the child room's deadlines") + + local sess = C1.roomSession() + sess:send({ type = "action", kind = "move", slot = 1 }) + tr:pump() + C2.update(0) + eq(#C2.roomSession():poll(), 1, "the child room carries the lockstep stream") + + playNext = nil + C1.report("win") + C2.report("lose") + pump() + eq(C1.tournament().code, "TRN234", "the client stays in the tournament") + local advanced = C1.tournament() + eq(advanced.bracket[1].matches[1].state, "done", "the bracket advances") + eq(advanced.bracket[1].matches[1].winner, "t1", "and records the winner") + eq(advanced.bracket[1].matches[1].how, "agreed", "and how it was decided") + eq(#advanced.bracket, 2, "the second round is drawn") + eq(advanced.round, 2, "the tournament is on round 2") + check(playNext ~= nil and playNext.code == "CHB234", + "the next match uses a fresh child room") + eq(C1.room().code, "CHB234", "the client follows to the next child room") + eq(C1.roomSession().code, "CHB234", "and rebinds its session") + + local eliminated + for _, p in ipairs(advanced.players) do + if p.eliminated then eliminated = p.id end + end + eq(eliminated, "t2", "the loser is marked eliminated") + + C3.report("error") + local tourForfeit = outbox(C3, "forfeit") + check(tourForfeit ~= nil, "a non-result in a tournament match forfeits") + eq(tourForfeit and tourForfeit.match, "TRN234-r2-m1", + "the forfeit names the bracket match") + pump() + check(overSeen ~= nil, "tour_over reaches the finalists") + eq(overSeen and overSeen.championId, "t1", "tour_over names the champion") + eq(C1.tournament().stage, "finished", + "the model stays finished until leaveTournament") + eq(C1.room(), nil, "the child room is cleared after the final") + + check(C1.leaveTournament(), "leaveTournament reports success") + eq(C1.tournament(), nil, "leaveTournament drops the model") + tr:pump() + eq(tr.tourLeft, "t1", "the relay saw tour_leave") + eq(C1.leaveTournament(), false, "leaving twice is a no-op") + + eq(C4.tournament().stage, "finished", + "the spectator's model is finished too") + C4.leaveTournament() + C1.disconnect(); C2.disconnect(); C3.disconnect(); C4.disconnect() +end + + +-- ------------------------------------------------------- tournament closes + +do + local tr = newRelay() + local sa = tr:seat("u1", "HOST") + local sb = tr:seat("u2", "GUEST") + local CA, CB = newClientModule(), newClientModule() + connectTo(CA, sa, "HOST"); connectTo(CB, sb, "GUEST") + local function pump() + tr:pump(); CA.update(0); CB.update(0) + end + pump() + local party = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + CA.createTournament({ profile = PROFILE, playing = true, shotClock = 3, + party = party, partyDigest = "d1" }) + pump() + CB.joinTournament("TRN234", "player", party, "d2") + pump() + eq(#CA.tournament().players, 2, "two players are registered") + + local closedB + CB.on("error", function(e) closedB = e end) + eq(CB.kickFromTournament("u1"), true, + "a non-creator may still send tour_kick") + pump() + eq(#CA.tournament().players, 2, "but the relay refuses it") + + CA.kickFromTournament("u2") + pump() + eq(tr.tourKicked, "u2", "tour_kick names its target") + eq(CB.tournament(), nil, "a kicked player's tournament model is cleared") + eq(closedB and closedB.scope, "tournament", + "tour_closed surfaces as a tournament error") + eq(closedB and closedB.reason, "kicked", "and keeps its reason") + eq(Protocol2.tourClosedText({ reason = "kicked" }), + "The creator removed you from the tournament.", + "tour_closed has human text") + eq(#CA.tournament().players, 1, "the creator sees the shrunken field") + + local closedA + CA.on("error", function(e) closedA = e end) + CA.closeTournament() + pump() + eq(tr.tourClosedBy, "u1", "the relay saw tour_close") + eq(CA.tournament(), nil, "closing drops the creator's model too") + eq(closedA and closedA.reason, "closed", "with the closed reason") + eq(CA.closeTournament(), false, "closing twice is a no-op") + eq(CA.startTournament(), false, "starting without a tournament is a no-op") + eq(CA.kickFromTournament("u2"), false, "so is kicking") + CA.disconnect(); CB.disconnect() +end + +-- ---------------------------------------------------------------- robustness + +local droppedBefore = ClientB.dropped() +relay:to(seatB, { type = "room_state", players = {} }) +relay:to(seatB, { type = "join_error" }) +relay:to(seatB, { notatype = true }) +relay:to(seatB, "a bare string") +ClientB.update(0) +check(ClientB.dropped() >= droppedBefore + 3, + "malformed server messages are dropped and counted") +eq(ClientB.state(), "online", "malformed messages do not throw or change state") + +local errClient = newClientModule() +errClient.configure({ connect = function() return nil, "no relay" end }) +local ok, err = errClient.connect({ name = "RED" }) +eq(ok, false, "a failed connect reports failure") +eq(errClient.state(), "error", "a failed connect lands in error") +eq(errClient.error(), "no relay", "the failure reason is kept") +errClient.disconnect() +eq(errClient.state(), "offline", "disconnect returns to offline") +errClient.disconnect() +eq(errClient.state(), "offline", "disconnect is idempotent") +errClient.update(1 / 60) +eq(errClient.state(), "offline", "update on an offline client is a no-op") + +local throwing = newClientModule() +local seatT = relay:seat("t1", "TRIP") +throwing.on("state", function() error("boom") end) +connectTo(throwing, seatT, "TRIP") +relay:pump() +throwing.update(0) +eq(throwing.state(), "online", "a throwing event handler does not stop the client") + +ClientA.disconnect() +ClientB.disconnect() +ClientC.disconnect() +throwing.disconnect() + +do + local r2 = newRelay() + local seat = r2:seat("z1", "ZED") + local Stranded = newClientModule() + connectTo(Stranded, seat, "ZED") + r2:pump(); Stranded.update(0) + Stranded.createRoom({ intent = "battle", profile = PROFILE }) + r2:pump(); Stranded.update(0) + local sess = Stranded.roomSession() + check(type(sess.update) == "function", + "a room session exposes update for Game:step") + check(pcall(function() sess:update(1 / 60) end), + "room session update does not throw while connected") + Stranded.disconnect() + check(pcall(function() sess:update(1 / 60) end), + "room session update does not throw with no connection") + eq(sess.closed, true, "a room session closes when the client disconnects") + check(pcall(function() + sess:send({ type = "bye" }) + sess:poll() + sess:take("bye") + sess:close() + end), "a stranded room session still answers send/poll/take/close") +end + + +-- ---------------------------------------------------------------- lockstep + +local dataOk = pcall(function() + local Data = require("src.core.Data") + if not Data.pokemon then Data:load() end + require("src.render.Font").load(Data) +end) + +if not dataOk then + print("skip lockstep smoke (data/generated is not built in this checkout)") +else + local Data = require("src.core.Data") + local Input = require("src.core.Input") + local LinkBattle = require("src.link.LinkBattle") + local Pokemon = require("src.pokemon.Pokemon") + local Protocol = require("src.link.Protocol") + Input:init() + + local function makeGame(name, species) + local save = require("src.core.SaveData").newGame() + save.player.name = name + table.insert(save.party, Pokemon.new(Data, species, 50)) + local stack = { list = {} } + function stack:push(s, ...) + table.insert(self.list, s) + if s.enter then s:enter(...) end + end + function stack:pop() return table.remove(self.list) end + function stack:top() return self.list[#self.list] end + function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end + end + return { data = Data, input = Input, stack = stack, save = save } + end + + local lockRelay = newRelay() + local hostSeat = lockRelay:seat("h", "RED") + local guestSeat = lockRelay:seat("g", "BLUE") + local Host = newClientModule() + local Guest = newClientModule() + local hostStart, guestStart + Host.on("match_start", function(p) hostStart = p end) + Guest.on("match_start", function(p) guestStart = p end) + + connectTo(Host, hostSeat, "RED") + connectTo(Guest, guestSeat, "BLUE") + lockRelay:pump(); Host.update(0); Guest.update(0) + Host.createRoom({ intent = "battle", profile = PROFILE }) + lockRelay:pump(); Host.update(0) + Guest.joinRoom("ABC234", "player") + lockRelay:pump(); Host.update(0); Guest.update(0) + + local gameH = makeGame("RED", "CHARIZARD") + local gameG = makeGame("BLUE", "BLASTOISE") + local packedH = Protocol.packParty(gameH.save.party) + local packedG = Protocol.packParty(gameG.save.party) + Host.ready(packedH, "dh") + Guest.ready(packedG, "dg") + lockRelay:pump(); Host.update(0); Guest.update(0) + + check(hostStart ~= nil and guestStart ~= nil, + "both ends of the lockstep smoke get match_start") + + local sessHost = Host.roomSession() + local sessGuest = Guest.roomSession() + local battleH = LinkBattle.newHost(gameH, sessHost, { + myParty = packedH, theirParty = hostStart.theirParty, + theirName = hostStart.peerName, seed = hostStart.seed, + ruleset = hostStart.ruleset, keepNetOpen = true, + }) + local battleG = LinkBattle.newGuest(gameG, sessGuest, { + myParty = packedG, theirParty = guestStart.theirParty, + theirName = guestStart.peerName, seed = guestStart.seed, + ruleset = guestStart.ruleset, keepNetOpen = true, + }) + check(battleH ~= nil and battleG ~= nil, + "LinkBattle builds over a room session unchanged") + + local resH, resG + battleH.onFinish = function(r) resH = r end + battleG.onFinish = function(r) resG = r end + gameH.stack:push(battleH) + gameG.stack:push(battleG) + + local guard = 0 + while (resH == nil or resG == nil) and guard < 60000 do + guard = guard + 1 + lockRelay:pump() + Input.pressed = { a = true } + gameH.stack:update(1 / 60) + gameG.stack:update(1 / 60) + end + check(resH ~= nil and resG ~= nil, + ("the room-session lockstep battle completes (%s / %s)") + :format(tostring(resH), tostring(resG))) + check((resH == "win" and resG == "lose") or (resH == "lose" and resG == "win") + or (resH == "draw" and resG == "draw"), + "both room-session simulations agree on the outcome") + eq(battleH.player.mon.hp, battleG.enemy.mon.hp, + "host mon HP identical across the relay") + eq(battleH.enemy.mon.hp, battleG.player.mon.hp, + "guest mon HP identical across the relay") + + sessHost:close() + eq(sessHost.closed, true, "closing a room session marks it closed") + local sawLeave = false + for _, m in ipairs(hostSeat.transport.outbox) do + if m.type == "room_leave" then sawLeave = true end + end + check(sawLeave, "closing a room session sends room_leave") + Host.disconnect() + Guest.disconnect() +end + +-- ---------------------------------------------------------------- real relay + +love.timer.getTime = function() return os.clock() end + +local hasSocket = pcall(require, "socket") +local nodeCheck = os.execute("command -v node >/dev/null 2>&1") +local hasNode = nodeCheck == true or nodeCheck == 0 +local serverFile = io.open("../pokeserver/server.js", "r") +if serverFile then serverFile:close() end + +if not hasSocket then + print("skip real relay v2 (luasocket not available under this interpreter)") +elseif not hasNode then + print("skip real relay v2 (node not on PATH to spawn pokeserver)") +elseif not serverFile then + print("skip real relay v2 (../pokeserver/server.js is not checked out)") +else + local PORT = 17780 + local pidFile = os.tmpname() + os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_v2_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 v2 (couldn't reach the spawned pokeserver)") + else + local Live = newClientModule() + Live.configure({ relayAddress = "127.0.0.1:" .. PORT }) + Live.connect({ name = "RED", profiles = { PROFILE } }) + local Peer = newClientModule() + Peer.configure({ relayAddress = "127.0.0.1:" .. PORT }) + Peer.connect({ name = "BLUE", profiles = { PROFILE } }) + + local function pump() + Live.update(1 / 60) + Peer.update(1 / 60) + end + local function waitFor(fn, seconds) + local deadline = os.clock() + (seconds or 3) + while os.clock() < deadline do + pump() + if fn() then return true end + end + return false + end + + if not waitFor(function() + return Live.state() == "online" and Peer.state() == "online" + end, 3) then + print("skip real relay v2 (the server did not answer lobby_hello in 3 s: " + .. tostring(Live.state()) .. " " .. tostring(Live.error()) .. ")") + else + check(Live.you() ~= nil, "the real relay welcomes a v2 client") + local room = Live.createRoom({ intent = "battle", profile = PROFILE }) + waitFor(function() return room.done end, 3) + check(room.done and room.code ~= nil, + "the real relay creates a v2 room: " .. tostring(room.error)) + if room.code then + local joined = Peer.joinRoom(room.code, "player", PROFILE) + waitFor(function() return joined.done end, 3) + check(joined.done and joined.error == nil, + "the real relay admits a matching profile: " .. tostring(joined.error)) + + local badPeer = newClientModule() + badPeer.configure({ relayAddress = "127.0.0.1:" .. PORT }) + badPeer.connect({ name = "GREEN", profiles = { PROFILE } }) + local badProfile = copy(PROFILE) + badProfile.fingerprint = "deadbeef" + local refused + local badDeadline = os.clock() + 3 + while os.clock() < badDeadline do + badPeer.update(1 / 60) + if badPeer.state() == "online" and not refused then + refused = badPeer.joinRoom(room.code, "spectator", badProfile) + end + if refused and refused.done then break end + end + check(refused and refused.reason == "profile_mismatch", + "the real relay refuses a mismatched spectator profile: " + .. tostring(refused and refused.reason)) + badPeer.disconnect() + + local startLive, startPeer, endLive + Live.on("match_start", function(p) startLive = p end) + Peer.on("match_start", function(p) startPeer = p end) + Live.on("match_end", function(p) endLive = p end) + local party = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + Live.ready(party, "dl") + Peer.ready(party, "dp") + waitFor(function() return startLive and startPeer end, 3) + check(startLive ~= nil and startPeer ~= nil, + "the real relay starts the match on both seats") + if startLive and startPeer then + eq(startLive.role, "host", "the real relay makes the creator the host") + eq(startPeer.role, "guest", "the real relay makes the joiner the guest") + eq(startLive.match, startPeer.match, + "both seats share the real relay's match token") + check(startLive.seed ~= nil and startLive.seed == startPeer.seed, + "both seats share the real relay's seed") + Live.report("win") + Peer.report("lose") + waitFor(function() return endLive ~= nil end, 5) + check(endLive ~= nil, "the real relay answers room_report") + if endLive then + eq(endLive.match, startLive.match, "room_result names the match") + eq(endLive.how, "agreed", "the real relay agrees the two reports") + eq(endLive.youWon, true, "the reported winner is told it won") + end + end + end + + + Live.leaveRoom() + Peer.leaveRoom() + local Third = newClientModule() + Third.configure({ relayAddress = "127.0.0.1:" .. PORT }) + Third.connect({ name = "GOLD", profiles = { PROFILE } }) + local function pumpAll() + Live.update(1 / 60); Peer.update(1 / 60); Third.update(1 / 60) + end + local function waitAll(fn, seconds) + local deadline = os.clock() + (seconds or 3) + while os.clock() < deadline do + pumpAll() + if fn() then return true end + end + return false + end + waitAll(function() return Third.state() == "online" end, 3) + + local hostParty = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + local made = Live.createTournament({ profile = PROFILE, playing = true, + shotClock = 3, maxSpectators = 8, + party = hostParty, + partyDigest = "dl" }) + waitAll(function() return made.done or Live.tournament() ~= nil end, 3) + local mine = Live.tournament() + if not mine then + print("skip real relay tournaments (no tour_create support yet: " + .. tostring(made.reason or made.error or "no reply") .. ")") + else + check(mine.code ~= nil, "the real relay creates a tournament") + local code = mine.code + local tourParty = { { species = "CHARIZARD", level = 50, hp = 10, + moves = { { id = "TACKLE", pp = 35 } } } } + Peer.joinTournament(code, "player", tourParty, "dp") + Third.joinTournament(code, "spectator") + waitAll(function() + local t = Live.tournament() + return t ~= nil and #t.players >= 2 and #t.spectators >= 1 + end, 3) + local t = Live.tournament() + check(t ~= nil and #t.players >= 2, + "the real relay registers the second player") + check(t ~= nil and #t.spectators >= 1, + "the real relay registers the outside spectator") + + local liveStart, peerStart, specStart, overSeen + Live.on("match_start", function(p) liveStart = p end) + Peer.on("match_start", function(p) peerStart = p end) + Third.on("match_start", function(p) specStart = p end) + Live.on("tour_over", function(p) overSeen = p end) + Live.startTournament() + waitAll(function() return liveStart ~= nil and peerStart ~= nil end, 5) + check(liveStart ~= nil and peerStart ~= nil, + "the real relay starts the first bracket match") + if liveStart and peerStart then + eq(liveStart.match, peerStart.match, + "both finalists share the bracket match token") + check(Live.room() ~= nil and Live.room().code == liveStart.code, + "the client follows the relay into the child room") + check(specStart == nil or specStart.role == "spectator", + "an outside spectator boots as a spectator") + Live.report("win") + Peer.report("lose") + waitAll(function() return overSeen ~= nil end, 5) + check(overSeen ~= nil, "a two-player bracket finishes") + if overSeen then + eq(Live.tournament().stage, "finished", + "the model stays finished until we leave") + end + end + Live.leaveTournament() + Peer.leaveTournament() + Third.leaveTournament() + end + Third.disconnect() + end + Peer.disconnect() + Live.disconnect() + 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 + +love.timer.getTime = savedGetTime + +if failures > 0 then + print(("\n%d online client check(s) failed"):format(failures)) + os.exit(1) +end +print("\nonline client tests passed") +return failures diff --git a/tests/online_convert.lua b/tests/online_convert.lua new file mode 100644 index 00000000..86837254 --- /dev/null +++ b/tests/online_convert.lua @@ -0,0 +1,587 @@ +-- luajit tests/online_convert.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +local Convert = require("src.online.Convert") + +-------------------------------------------------------------------------- +-- Synthetic datasets +-------------------------------------------------------------------------- + +local gen1Data = { + pokemon = { + TESTMON = { + id = "TESTMON", name = "TESTMON", index = 1, dex = 1, + types = { "GRASS", "POISON" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + special = 65 }, + catchRate = 45, growthRate = "MEDIUM_SLOW", + }, + MAGNETIC = { + id = "MAGNETIC", name = "MAGNETIC", index = 2, dex = 2, + types = { "ELECTRIC" }, + baseStats = { hp = 25, attack = 35, defense = 70, speed = 45, + special = 95 }, + catchRate = 190, growthRate = "MEDIUM_FAST", + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + VINE_WHIP = { id = "VINE_WHIP", name = "VINE WHIP", pp = 10 }, + }, +} + +local gen2Data = { + pokemon = { + TESTMON = { + id = "TESTMON", name = "TESTMON", + types = { "GRASS", "POISON" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, + specialAttack = 65, specialDefense = 65 }, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 0x1f, + }, + SPLITMON = { + id = "SPLITMON", name = "SPLITMON", + types = { "WATER" }, + baseStats = { hp = 60, attack = 60, defense = 60, speed = 60, + specialAttack = 90, specialDefense = 40 }, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 0x7f, + }, + MAGNETIC = { + id = "MAGNETIC", name = "MAGNETIC", + types = { "ELECTRIC", "STEEL" }, + baseStats = { hp = 25, attack = 35, defense = 70, speed = 45, + specialAttack = 95, specialDefense = 55 }, + growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 0xff, + }, + JOHTOMON = { + id = "JOHTOMON", name = "JOHTOMON", + types = { "FIRE" }, + baseStats = { hp = 50, attack = 65, defense = 64, speed = 45, + specialAttack = 60, specialDefense = 50 }, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 0x1f, + }, + growthRates = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, + GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1 }, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + VINE_WHIP = { id = "VINE_WHIP", name = "VINE WHIP", pp = 10 }, + SHADOW_BALL = { id = "SHADOW_BALL", name = "SHADOW BALL", pp = 15 }, + }, + items = { + LEFTOVERS = { id = "LEFTOVERS", name = "LEFTOVERS" }, + METAL_COAT = { id = "METAL_COAT", name = "METAL COAT" }, + FLOWER_MAIL = { id = "FLOWER_MAIL", name = "FLOWER MAIL" }, + }, +} + +local DVS = { attack = 15, defense = 14, speed = 13, special = 12 } +local ZERO_EXP = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + +local Stats = require("src.pokemon.Stats") +local Growth = require("src.pokemon.Growth") +local Mon = require("src.battle.gen2.Mon") + +local function gen1Mon(species, level, over) + species = species or "TESTMON" + level = level or 30 + local def = gen1Data.pokemon[species] + local dvs = {} + for k, v in pairs(DVS) do dvs[k] = v end + dvs.hp = Mon.hpDV(dvs) + local stats = Stats.calc(def, level, dvs, ZERO_EXP) + local mon = { + species = species, level = level, + exp = Growth.expForLevel(def.growthRate, level), + dvs = dvs, statExp = { hp = 0, attack = 0, defense = 0, speed = 0, + special = 0 }, + stats = stats, hp = stats.hp, catchRate = def.catchRate, status = nil, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 }, + { id = "VINE_WHIP", pp = 10, ppUps = 0 } }, + nickname = "SPROUT", ot = "RED", otId = 1234, traded = false, + } + for k, v in pairs(over or {}) do mon[k] = v end + return mon +end + +local function gen2Mon(species, level, over) + species = species or "TESTMON" + level = level or 30 + local def = gen2Data.pokemon[species] + local dvs = {} + for k, v in pairs(DVS) do dvs[k] = v end + dvs.hp = Mon.hpDV(dvs) + local statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + local stats = Mon.stats(def.baseStats, dvs, level, statExp) + local mon = { + species = species, level = level, + experience = Mon.experienceForLevel( + Mon.growthFor(gen2Data, def.growthRate), level), + dvs = dvs, statExp = statExp, stats = stats, + hp = stats.hp, maxHp = stats.hp, + item = nil, status = nil, happiness = 128, pokerus = 0, + caughtLevel = 5, + moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "VINE_WHIP", pp = 10, maxPp = 10 } }, + nickname = "SPROUT", ot = "GOLD", otName = "GOLD", otId = 4321, + traded = false, isEgg = false, + } + for k, v in pairs(over or {}) do mon[k] = v end + return mon +end + +local function has(lines, needle) + for _, line in ipairs(lines) do + if line:find(needle, 1, true) then return true end + end + return false +end + +local function kinds(list) + local out = {} + for _, row in ipairs(list) do out[row.kind] = row end + return out +end + +-------------------------------------------------------------------------- +-- Gen 1 -> Gen 2 +-------------------------------------------------------------------------- + +do + local src = gen1Mon("TESTMON", 30) + local out, report = Convert.toGen2(src, gen1Data, gen2Data) + T.check(out ~= nil, "1->2 converts a Kanto mon") + T.eq(out.species, "TESTMON", "1->2 keeps the species") + T.eq(out.level, 30, "1->2 keeps the level") + T.eq(out.experience, + Mon.experienceForLevel(Mon.growthFor(gen2Data, "GROWTH_MEDIUM_SLOW"), 30), + "1->2 recomputes experience on the Gen 2 curve") + T.eq(out.dvs.hp, Mon.hpDV(src.dvs), "1->2 derives the HP DV") + T.eq(out.happiness, 70, "1->2 stamps happiness 70") + T.eq(out.pokerus, 0, "1->2 zeroes pokerus") + T.eq(out.item, nil, "1->2 carries no held item") + T.eq(out.caughtLevel, 30, "1->2 meets at the current level") + T.eq(out.isEgg, false, "1->2 is never an egg") + T.eq(out.nickname, "SPROUT", "1->2 keeps the nickname") + T.eq(out.ot, "RED", "1->2 keeps the OT") + T.eq(out.otName, "RED", "1->2 mirrors otName") + T.eq(out.otId, 1234, "1->2 keeps the OT id") + T.eq(out.maxHp, out.stats.hp, "1->2 sets maxHp from the new stats") + T.eq(out.hp, out.stats.hp, "1->2 keeps a full-HP mon full") + T.eq(out.stats.specialAttack, + Mon.stats(gen2Data.pokemon.TESTMON.baseStats, out.dvs, 30, + out.statExp).specialAttack, + "1->2 recomputes stats through Mon.stats") + T.eq(out.stats.specialAttack, out.stats.specialDefense, + "1->2 splits one Special into two equal stats when the bases agree") + T.eq(out.moves[1].maxPp, 35, "1->2 gives moves a maxPp from move data") + T.eq(#report.lost, 0, "1->2 loses nothing") + T.check(kinds(report.changed).happiness ~= nil, "1->2 reports the friendship") +end + +do + local src = gen1Mon("TESTMON", 50) + src.species = "SPLITMON" + src.stats = Stats.calc( + { baseStats = { hp = 60, attack = 60, defense = 60, speed = 60, + special = 65 } }, 50, src.dvs, ZERO_EXP) + src.hp = src.stats.hp + local out, report = Convert.toGen2(src, gen1Data, gen2Data) + T.check(out.stats.specialAttack ~= out.stats.specialDefense, + "1->2 splits Special apart when the Gen 2 bases differ") + local row = kinds(report.changed).special_split + T.check(row ~= nil, "1->2 reports the Special split") + T.eq(row.text, ("SPECIAL SPLIT %d -> %d/%d"):format(src.stats.special, + out.stats.specialAttack, out.stats.specialDefense), + "1->2 spells the Special split in the game's voice") +end + +do + local src = gen1Mon("TESTMON", 30, { hp = 1 }) + src.status = "PSN" + local out = Convert.toGen2(src, gen1Data, gen2Data) + T.eq(out.status, "poison", "1->2 maps PSN to poison") + T.check(out.hp >= 1 and out.hp <= out.maxHp, + "1->2 keeps a hurt mon alive after scaling") +end + +do + for from, to in pairs({ SLP = "sleep", PSN = "poison", BRN = "burn", + PAR = "paralyze", FRZ = "freeze" }) do + local out = Convert.toGen2(gen1Mon("TESTMON", 20, { status = from }), + gen1Data, gen2Data) + T.eq(out.status, to, "1->2 status " .. from) + end +end + +do + local src = gen1Mon("TESTMON", 60) + src.hp = math.floor(src.stats.hp / 2) + local out = Convert.toGen2(src, gen1Data, gen2Data) + local want = math.floor(src.hp * out.maxHp / src.stats.hp + 0.5) + T.eq(out.hp, want, "1->2 scales HP proportionally to the new maximum") + local fainted = Convert.toGen2(gen1Mon("TESTMON", 60, { hp = 0 }), + gen1Data, gen2Data) + T.eq(fainted.hp, 0, "1->2 keeps a fainted mon fainted") +end + +do + local src = gen1Mon("TESTMON", 30) + src.moves = { { id = "TACKLE", pp = 42, ppUps = 1 } } + local out = Convert.toGen2(src, gen1Data, gen2Data) + T.eq(out.moves[1].maxPp, 35 + 7, "1->2 honours PP Ups in maxPp") + T.eq(out.moves[1].pp, 42, "1->2 keeps current PP under the new cap") +end + +do + local shinyDvs = { attack = 15, defense = 10, speed = 10, special = 10 } + local src = gen1Mon("TESTMON", 30, { dvs = shinyDvs }) + local out = Convert.toGen2(src, gen1Data, gen2Data) + T.check(out.shiny == true, "1->2 derives shininess from the DVs") + T.eq(out.gender, "male", "1->2 derives gender from the Attack DV") + local plain = Convert.toGen2(gen1Mon("TESTMON", 30, + { dvs = { attack = 0, defense = 3, speed = 5, special = 7 } }), + gen1Data, gen2Data) + T.check(plain.shiny == false, "1->2 leaves an ordinary mon unshiny") + T.eq(plain.gender, "female", "1->2 reads a low Attack DV as female") +end + +-------------------------------------------------------------------------- +-- Gen 2 -> Gen 1 +-------------------------------------------------------------------------- + +do + local src = gen2Mon("TESTMON", 30) + local out, report = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out ~= nil, "2->1 converts a Kanto mon") + T.eq(out.level, 30, "2->1 keeps the level") + T.eq(out.exp, Growth.expForLevel("MEDIUM_SLOW", 30), + "2->1 recomputes exp on the Gen 1 curve") + T.eq(out.catchRate, 45, + "2->1 stamps the Gen 1 species' own catch rate") + T.eq(out.stats.special, + Stats.calc(gen1Data.pokemon.TESTMON, 30, out.dvs, out.statExp).special, + "2->1 folds Special back through the Gen 1 base Special") + T.eq(out.stats.specialAttack, nil, "2->1 has no specialAttack") + T.eq(out.nickname, "SPROUT", "2->1 keeps the nickname") + T.eq(out.ot, "GOLD", "2->1 keeps the OT") + T.eq(out.otId, 4321, "2->1 keeps the OT id") + T.eq(out.moves[1].ppUps, 0, "2->1 derives PP Ups from maxPp") + local lost = kinds(report.lost) + T.check(lost.happiness ~= nil, "2->1 reports friendship lost") + T.check(lost.caught_level ~= nil, "2->1 reports the met data lost") + T.check(lost.item == nil, "2->1 reports no item when none is held") +end + +do + local src = gen2Mon("TESTMON", 40, { item = "LEFTOVERS", pokerus = 0xf1 }) + local out, report = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out ~= nil, "2->1 accepts a mon holding an ordinary item") + T.eq(out.item, nil, "2->1 drops the held item") + T.eq(out.catchRate, 45, + "2->1 does not put the item byte in the catch rate slot") + local lost = kinds(report.lost) + T.eq(lost.item.text, "HELD ITEM LOST: LEFTOVERS", + "2->1 names the item it dropped") + T.check(lost.pokerus ~= nil, "2->1 reports pokerus lost") +end + +do + for from, to in pairs({ sleep = "SLP", poison = "PSN", toxic = "PSN", + burn = "BRN", paralyze = "PAR", freeze = "FRZ" }) do + local out = Convert.toGen1(gen2Mon("TESTMON", 20, { status = from }), + gen2Data, gen1Data) + T.eq(out.status, to, "2->1 status " .. from) + end +end + +do + local src = gen2Mon("SPLITMON", 50) + local out, report = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out == nil, "2->1 refuses a species the Gen 1 dataset does not know") + T.eq(report, "species_too_new", "2->1 gives the species refusal reason") +end + +do + local src = gen2Mon("JOHTOMON", 20) + local out, reason, info = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out == nil, "2->1 refuses a Johto species") + T.eq(reason, "species_too_new", "2->1 names the Johto refusal") + T.eq(info.species, "JOHTOMON", "2->1 says which species was refused") +end + +do + local src = gen2Mon("TESTMON", 30) + src.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, + { id = "SHADOW_BALL", pp = 15, maxPp = 15 } } + local out, reason, info = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out == nil, "2->1 refuses a move past STRUGGLE") + T.eq(reason, "move_too_new", "2->1 names the move refusal") + T.eq(info.move, "SHADOW_BALL", "2->1 says which move was refused") +end + +do + local src = gen2Mon("TESTMON", 30, { item = "FLOWER_MAIL" }) + local out, reason = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out == nil, "2->1 refuses a mon holding mail") + T.eq(reason, "has_mail", "2->1 names the mail refusal") + local carried = gen2Mon("TESTMON", 30, + { mail = { message = "HI", author = "GOLD" } }) + local out2, reason2 = Convert.toGen1(carried, gen2Data, gen1Data) + T.check(out2 == nil, "2->1 refuses a mon carrying a mail record") + T.eq(reason2, "has_mail", "2->1 names the mail-record refusal") +end + +do + local src = gen2Mon("TESTMON", 30, { isEgg = true }) + local out, reason = Convert.toGen1(src, gen2Data, gen1Data) + T.check(out == nil, "2->1 refuses an egg") + T.eq(reason, "is_egg", "2->1 names the egg refusal") +end + +do + local src = gen2Mon("TESTMON", 60) + src.hp = math.floor(src.maxHp / 2) + local out = Convert.toGen1(src, gen2Data, gen1Data) + T.eq(out.hp, math.floor(src.hp * out.stats.hp / src.maxHp + 0.5), + "2->1 scales HP proportionally") +end + +do + local src = gen2Mon("TESTMON", 30) + src.moves = { { id = "TACKLE", pp = 40, maxPp = 42 } } + local out = Convert.toGen1(src, gen2Data, gen1Data) + T.eq(out.moves[1].ppUps, 1, "2->1 reads one PP Up out of maxPp") + T.eq(out.moves[1].pp, 40, "2->1 keeps current PP under the Gen 1 cap") +end + +-------------------------------------------------------------------------- +-- Round trip +-------------------------------------------------------------------------- + +do + local src = gen1Mon("TESTMON", 44) + local mid = Convert.toGen2(src, gen1Data, gen2Data) + local back = Convert.toGen1(mid, gen2Data, gen1Data) + T.check(back ~= nil, "round trip comes back") + T.eq(back.level, src.level, "round trip keeps the level") + T.eq(back.exp, src.exp, "round trip keeps the exp") + T.eq(back.catchRate, src.catchRate, "round trip keeps the catch rate") + for _, key in ipairs({ "hp", "attack", "defense", "speed", "special" }) do + T.eq(back.stats[key], src.stats[key], "round trip keeps stat " .. key) + T.eq(back.dvs[key], src.dvs[key], "round trip keeps DV " .. key) + end + T.eq(back.hp, src.hp, "round trip keeps current HP") + T.eq(#back.moves, #src.moves, "round trip keeps the move count") + for i, mv in ipairs(src.moves) do + T.eq(back.moves[i].id, mv.id, "round trip keeps move " .. i) + T.eq(back.moves[i].pp, mv.pp, "round trip keeps PP " .. i) + T.eq(back.moves[i].ppUps, mv.ppUps, "round trip keeps PP Ups " .. i) + end + T.eq(back.nickname, src.nickname, "round trip keeps the nickname") + T.eq(back.ot, src.ot, "round trip keeps the OT") + T.eq(back.otId, src.otId, "round trip keeps the OT id") +end + +-------------------------------------------------------------------------- +-- ValidateOTTrademon +-------------------------------------------------------------------------- + +do + local ok = Convert.validateArrival( + { species = "TESTMON", level = 30, types = { "GRASS", "POISON" } }, + gen1Data) + T.check(ok, "arrival accepts matching types") + + local bad, why = Convert.validateArrival( + { species = "TESTMON", level = 30, types = { "FIRE" } }, gen1Data) + T.check(not bad, "arrival refuses a type disagreement") + T.eq(why, "types", "arrival names the type disagreement") + + local mag = Convert.validateArrival( + { species = "MAGNEMITE", level = 30, types = { "ELECTRIC" } }, + { pokemon = { MAGNEMITE = { types = { "ELECTRIC", "STEEL" } } } }) + T.check(mag, "arrival carves out MAGNEMITE") + local magneton = Convert.validateArrival( + { species = "MAGNETON", level = 30, types = { "ELECTRIC" } }, + { pokemon = { MAGNETON = { types = { "ELECTRIC", "STEEL" } } } }) + T.check(magneton, "arrival carves out MAGNETON") + local other = Convert.validateArrival( + { species = "MAGNETIC", level = 30, types = { "ELECTRIC" } }, gen2Data) + T.check(not other, "arrival refuses another mon claiming the wrong types") + + T.check(not Convert.validateArrival( + { species = "TESTMON", level = 255 }, gen1Data), + "arrival refuses a level past 100") + T.check(not Convert.validateArrival( + { species = "NOSUCHMON", level = 5 }, gen1Data), + "arrival refuses an unknown species") + T.check(Convert.validateArrival({ species = "TESTMON", level = 30 }, + gen1Data), "arrival accepts a record that claims no types") +end + +-------------------------------------------------------------------------- +-- Previews +-------------------------------------------------------------------------- + +do + local lines, ok = Convert.preview(gen2Mon("TESTMON", 30, + { item = "LEFTOVERS" }), 2, 1, gen2Data, gen1Data) + T.check(ok, "preview of a legal 2->1 mon is ok") + T.check(has(lines, "HELD ITEM LOST: LEFTOVERS"), + "preview names the lost held item") + + local bad, badOk = Convert.preview(gen2Mon("JOHTOMON", 30), 2, 1, + gen2Data, gen1Data) + T.check(not badOk, "preview of a Johto mon is not ok") + T.eq(bad[1], "SPECIES NOT IN GEN 1: JOHTOMON", + "preview spells the species refusal") + + local moveMon = gen2Mon("TESTMON", 30) + moveMon.moves = { { id = "SHADOW_BALL", pp = 15, maxPp = 15 } } + local mv, mvOk = Convert.preview(moveMon, 2, 1, gen2Data, gen1Data) + T.check(not mvOk, "preview of an illegal move is not ok") + T.eq(mv[1], "MOVE ILLEGAL IN GEN 1: SHADOW BALL", + "preview spells the move refusal") + + local mailMon = gen2Mon("TESTMON", 30, { item = "FLOWER_MAIL" }) + local ml, mlOk = Convert.preview(mailMon, 2, 1, gen2Data, gen1Data) + T.check(not mlOk, "preview of a mail holder is not ok") + T.eq(ml[1], "MON IS HOLDING MAIL", "preview spells the mail refusal") + + local eggLines = Convert.preview(gen2Mon("TESTMON", 5, { isEgg = true }), + 2, 1, gen2Data, gen1Data) + T.eq(eggLines[1], "EGGS CANNOT TRAVEL", "preview spells the egg refusal") + + local splitSrc = gen1Mon("TESTMON", 50) + splitSrc.species = "SPLITMON" + splitSrc.stats = Stats.calc( + { baseStats = { hp = 60, attack = 60, defense = 60, speed = 60, + special = 65 } }, 50, splitSrc.dvs, ZERO_EXP) + splitSrc.hp = splitSrc.stats.hp + local up, upOk = Convert.preview(splitSrc, 1, 2, gen1Data, gen2Data) + T.check(upOk, "preview of a 1->2 mon is ok") + T.check(has(up, "SPECIAL SPLIT "), "preview shows the Special split") +end + +-------------------------------------------------------------------------- +-- Party mapping with mixed legality +-------------------------------------------------------------------------- + +do + local illegalMove = gen2Mon("TESTMON", 30) + illegalMove.moves = { { id = "SHADOW_BALL", pp = 15, maxPp = 15 } } + local party = { + gen2Mon("TESTMON", 30), + gen2Mon("JOHTOMON", 30), + illegalMove, + gen2Mon("MAGNETIC", 30, { item = "METAL_COAT" }), + } + local converted, results = Convert.partyToGen1(party, gen2Data, gen1Data) + T.eq(#converted, 2, "party mapping converts only the legal mons") + T.eq(converted[1].species, "TESTMON", "party mapping keeps order") + T.eq(converted[2].species, "MAGNETIC", "party mapping keeps the second") + T.eq(#results, 4, "party mapping reports every slot") + T.check(results[1].ok, "slot 1 is legal") + T.check(not results[2].ok, "slot 2 is refused") + T.eq(results[2].reason, "species_too_new", "slot 2 refusal reason") + T.eq(results[2].index, 2, "slot 2 carries its index") + T.eq(results[3].reason, "move_too_new", "slot 3 refusal reason") + T.eq(results[3].info.move, "SHADOW_BALL", "slot 3 names the move") + T.check(results[4].ok, "slot 4 is legal with the item dropped") + T.check(has(results[4].preview, "HELD ITEM LOST: METAL COAT"), + "slot 4 preview names the dropped METAL COAT") + T.eq(#Convert.refusals(results), 2, "two refusals collected") + + local up, upResults = Convert.partyToGen2( + { gen1Mon("TESTMON", 10), gen1Mon("MAGNETIC", 12) }, gen1Data, gen2Data) + T.eq(#up, 2, "every Kanto mon is legal going up") + T.check(upResults[1].ok and upResults[2].ok, "no refusals going up") + T.eq(#Convert.refusals(upResults), 0, "no refusals collected going up") +end + +-------------------------------------------------------------------------- +-- Real data +-------------------------------------------------------------------------- + +local function realDataset(version) + local roots = { ("%s/data/generated/"):format(version), } + if version == "red" then roots[#roots + 1] = "data/generated/" end + for _, root in ipairs(roots) do + local probe = io.open(root .. "pokemon.lua", "r") + if probe then + probe:close() + local function load(name) + local chunk = loadfile(root .. name .. ".lua") + if not chunk then return nil end + local ok, value = pcall(chunk) + return ok and value or nil + end + local data = { pokemon = load("pokemon"), moves = load("moves"), + items = load("items") } + if data.pokemon and data.moves then return data end + end + end + return nil +end + +local red = realDataset("red") +local gold = realDataset("gold") + +if red and gold then + local function goldMon(species, level, over) + local mon = Mon.new(gold, species, level, { dvs = { attack = 15, + defense = 14, speed = 13, special = 12 } }) + for k, v in pairs(over or {}) do mon[k] = v end + return mon + end + + local bulb = goldMon("BULBASAUR", 30) + local down = Convert.toGen1(bulb, gold, red) + T.check(down ~= nil, "real BULBASAUR converts down") + T.eq(down.catchRate, red.pokemon.BULBASAUR.catchRate, + "real BULBASAUR takes Red's catch rate") + + local magnemite = goldMon("MAGNEMITE", 30) + local mDown = Convert.toGen1(magnemite, gold, red) + T.check(mDown ~= nil, "real MAGNEMITE converts down") + T.check(Convert.validateArrival( + { species = "MAGNEMITE", level = 30, + types = red.pokemon.MAGNEMITE.types }, gold), + "real MAGNEMITE survives arrival validation despite the STEEL type") + + local scyther = goldMon("SCYTHER", 40, { item = "METAL_COAT" }) + local sDown, sReport = Convert.toGen1(scyther, gold, red) + T.check(sDown ~= nil, "real SCYTHER with METAL COAT converts down") + T.check(kinds(sReport.lost).item ~= nil, "real METAL COAT is reported lost") + + local totodile = goldMon("TOTODILE", 15) + local tDown, tReason = Convert.toGen1(totodile, gold, red) + T.check(tDown == nil and tReason == "species_too_new", + "real TOTODILE is refused as a Johto species") + + local shadow = goldMon("GASTLY", 40) + shadow.moves = { { id = "SHADOW_BALL", pp = 15, maxPp = 15 } } + local shDown, shReason = Convert.toGen1(shadow, gold, red) + T.check(shDown == nil and shReason == "move_too_new", + "real SHADOW BALL is refused") + + local mailed = goldMon("BULBASAUR", 30, { item = "FLOWER_MAIL" }) + local mlDown, mlReason = Convert.toGen1(mailed, gold, red) + T.check(mlDown == nil and mlReason == "has_mail", + "real FLOWER MAIL is refused") + + local upBulb = Convert.toGen2(down, red, gold) + T.check(upBulb ~= nil, "real BULBASAUR converts back up") + T.eq(upBulb.level, bulb.level, "real round trip keeps the level") +else + T.check(true, "real-data cases skipped (no red and gold caches)") +end + +T.finish("online_convert") diff --git a/tests/online_panel.lua b/tests/online_panel.lua new file mode 100644 index 00000000..a367fb32 --- /dev/null +++ b/tests/online_panel.lua @@ -0,0 +1,1648 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +love = love or require("tests.love_stub") + +local OnlinePanel = require("src.import.OnlinePanel") +local SyncState = require("src.sync.SyncState") +local Client = require("src.online.Client") + +-- ------------------------------------------------------------- names + +T.eq(OnlinePanel.sanitizeName("RED"), "RED", "a plain name survives") +T.eq(OnlinePanel.sanitizeName('RD&"x\''), "REDx", + "the relay's five banned characters are dropped") +T.eq(OnlinePanel.sanitizeName(" RED "), "RED", "outer space is trimmed") +T.eq(#OnlinePanel.sanitizeName(("A"):rep(40)), OnlinePanel.NAME_MAX, + "a long name is capped at 16") + +T.check(OnlinePanel.nameValid("RED#417"), "RED#417 is a valid display name") +T.check(not OnlinePanel.nameValid("AB"), "two characters is too short") +T.check(not OnlinePanel.nameValid(("A"):rep(17)), "seventeen is too long") +T.check(not OnlinePanel.nameValid("RE