From 680db777a383a7d3dbd6674807e2c572da95b628 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 28 Aug 2026 10:14:44 -0400 Subject: [PATCH] big online energy --- .github/workflows/release.yml | 2 +- CONTRIBUTING-mods.md | 18 +- README.md | 13 + docs/architecture.md | 42 + docs/link-security.md | 294 +- docs/mod-api-gen2-compat.md | 23 +- docs/new-features.md | 3 +- main.lua | 67 +- src/battle/BattleState.lua | 3 +- src/battle/gen2/Battle.lua | 354 ++- src/core/DiscordPresence.lua | 39 +- src/core/Game.lua | 56 +- src/core/Game2.lua | 21 +- src/core/IssueReport.lua | 2 +- src/core/Version.lua | 65 + src/core/gen2/Evolution.lua | 5 +- src/import/LauncherSettings.lua | 17 + src/import/LauncherView.lua | 235 +- src/import/OnlinePanel.lua | 2927 ++++++++++++++++++++ src/import/RomImporter.lua | 307 +- src/import/online/Home.lua | 142 + src/import/online/PcPicker.lua | 92 + src/import/online/Play.lua | 188 ++ src/import/online/Room.lua | 225 ++ src/import/online/Tournaments.lua | 214 ++ src/import/online/TradeScreen.lua | 318 +++ src/import/online/Ui.lua | 137 + src/import/online/Watch.lua | 98 + src/import/online/Wizard.lua | 519 ++++ src/link/Fingerprint.lua | 14 + src/link/Handshake.lua | 46 +- src/link/LinkBattle.lua | 40 +- src/link/LinkBattle2.lua | 599 ++++ src/link/LinkState.lua | 221 +- src/link/Net.lua | 147 +- src/link/Protocol.lua | 16 +- src/link/Session.lua | 20 +- src/link/Tournament.lua | 651 ----- src/link/Wire.lua | 501 +++- src/mods/Loader.lua | 118 +- src/online/ArenaBoot.lua | 250 ++ src/online/ArenaData.lua | 260 ++ src/online/Client.lua | 1019 +++++++ src/online/Convert.lua | 435 +++ src/online/OnlineSprites.lua | 234 ++ src/online/Protocol2.lua | 381 +++ src/online/TeamPick.lua | 241 ++ src/online/Trade.lua | 681 +++++ src/sync/SyncClient.lua | 11 + src/sync/SyncState.lua | 1 + src/ui/ArenaState.lua | 100 + src/ui/StartMenu.lua | 12 +- src/ui/gen2/ArenaState.lua | 137 + src/ui/gen2/BattleState.lua | 34 + src/ui/kit/Kit.lua | 4 + src/ui/kit/Transition.lua | 125 + src/update/PatchNotes.lua | 6 +- src/update/check_worker.lua | 2 +- tests/drivers/arena_boot_gen2_loopback.lua | 179 ++ tests/drivers/arena_boot_gen2_spec.lua | 61 + tests/drivers/arena_boot_loopback.lua | 149 + tests/drivers/arena_boot_spec.lua | 56 + tests/drivers/discord_join_test.lua | 49 - tests/drivers/online_demo_common.lua | 294 ++ tests/drivers/online_demo_guest.lua | 16 + tests/drivers/online_demo_host.lua | 17 + tests/drivers/online_match_host.lua | 148 - tests/drivers/online_match_join.lua | 133 - tests/drivers/online_play_test.lua | 53 - tests/drivers/online_relay_smoke.lua | 309 +++ tests/drivers/online_shot.lua | 551 ++++ tests/drivers/online_tour_smoke.lua | 746 +++++ tests/drivers/online_walkthrough.lua | 240 ++ tests/drivers/tournament_guest_test.lua | 72 - tests/drivers/tournament_host_test.lua | 57 - tests/engine/arena_boot.lua | 210 ++ tests/engine/crystal_version_test.lua | 8 +- tests/engine/discord_join_requested.lua | 71 +- tests/engine/gate_fingerprint.lua | 13 + tests/engine/gate_gen2_mod_api.lua | 7 + tests/engine/gate_strings_coverage.lua | 2 +- tests/engine/gen2_evolution_stat_exp.lua | 105 + tests/engine/gen2_new_seams.lua | 73 +- tests/engine/kit_transition.lua | 112 + tests/engine/launcher_skins_tab.lua | 28 +- tests/engine/link_session.lua | 18 - tests/engine/online_arena_data.lua | 3 + tests/engine/online_convert.lua | 3 + tests/engine/online_panel.lua | 3 + tests/engine/online_trade.lua | 3 + tests/engine/version_dev_identity_test.lua | 109 + tests/goldens/fixture_fingerprint.txt | 2 +- tests/goldens/vanilla_fingerprint.txt | 2 +- tests/link2_desync_fuzz.lua | 442 +++ tests/link2_lockstep.lua | 511 ++++ tests/link_desync_fuzz.lua | 34 +- tests/link_hostile.lua | 68 +- tests/link_tournament16.lua | 8 +- tests/mod_ui_tests.lua | 2 +- tests/online_arena_data.lua | 523 ++++ tests/online_client.lua | 2081 ++++++++++++++ tests/online_convert.lua | 587 ++++ tests/online_panel.lua | 1648 +++++++++++ tests/online_trade.lua | 785 ++++++ tests/run_link_tests.lua | 240 +- 105 files changed, 21808 insertions(+), 1755 deletions(-) create mode 100644 src/import/OnlinePanel.lua create mode 100644 src/import/online/Home.lua create mode 100644 src/import/online/PcPicker.lua create mode 100644 src/import/online/Play.lua create mode 100644 src/import/online/Room.lua create mode 100644 src/import/online/Tournaments.lua create mode 100644 src/import/online/TradeScreen.lua create mode 100644 src/import/online/Ui.lua create mode 100644 src/import/online/Watch.lua create mode 100644 src/import/online/Wizard.lua create mode 100644 src/link/LinkBattle2.lua delete mode 100644 src/link/Tournament.lua create mode 100644 src/online/ArenaBoot.lua create mode 100644 src/online/ArenaData.lua create mode 100644 src/online/Client.lua create mode 100644 src/online/Convert.lua create mode 100644 src/online/OnlineSprites.lua create mode 100644 src/online/Protocol2.lua create mode 100644 src/online/TeamPick.lua create mode 100644 src/online/Trade.lua create mode 100644 src/ui/ArenaState.lua create mode 100644 src/ui/gen2/ArenaState.lua create mode 100644 src/ui/kit/Transition.lua create mode 100644 tests/drivers/arena_boot_gen2_loopback.lua create mode 100644 tests/drivers/arena_boot_gen2_spec.lua create mode 100644 tests/drivers/arena_boot_loopback.lua create mode 100644 tests/drivers/arena_boot_spec.lua delete mode 100644 tests/drivers/discord_join_test.lua create mode 100644 tests/drivers/online_demo_common.lua create mode 100644 tests/drivers/online_demo_guest.lua create mode 100644 tests/drivers/online_demo_host.lua delete mode 100644 tests/drivers/online_match_host.lua delete mode 100644 tests/drivers/online_match_join.lua delete mode 100644 tests/drivers/online_play_test.lua create mode 100644 tests/drivers/online_relay_smoke.lua create mode 100644 tests/drivers/online_shot.lua create mode 100644 tests/drivers/online_tour_smoke.lua create mode 100644 tests/drivers/online_walkthrough.lua delete mode 100644 tests/drivers/tournament_guest_test.lua delete mode 100644 tests/drivers/tournament_host_test.lua create mode 100644 tests/engine/arena_boot.lua create mode 100644 tests/engine/gen2_evolution_stat_exp.lua create mode 100644 tests/engine/kit_transition.lua create mode 100644 tests/engine/online_arena_data.lua create mode 100644 tests/engine/online_convert.lua create mode 100644 tests/engine/online_panel.lua create mode 100644 tests/engine/online_trade.lua create mode 100644 tests/engine/version_dev_identity_test.lua create mode 100644 tests/link2_desync_fuzz.lua create mode 100644 tests/link2_lockstep.lua create mode 100644 tests/online_arena_data.lua create mode 100644 tests/online_client.lua create mode 100644 tests/online_convert.lua create mode 100644 tests/online_panel.lua create mode 100644 tests/online_trade.lua 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/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..9645ffcb 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 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/src/battle/BattleState.lua b/src/battle/BattleState.lua index 53984c2e..3b4681ef 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4905,7 +4905,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/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..f18ca3c1 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -602,14 +602,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 +632,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/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..3ae86ffd 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -205,6 +205,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" @@ -886,7 +892,9 @@ function Game2:writeSave() return Save.save(save) end -function Game2:load() +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 +998,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 @@ -1072,7 +1085,9 @@ function Game2:load() -- 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 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/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/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 071b60fa..9c7d17ba 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -714,6 +714,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..29a8ad6c 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 / @@ -1292,13 +1304,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 +1342,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 @@ -1467,7 +1495,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 @@ -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, { @@ -5810,6 +5914,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 @@ -5894,6 +6004,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 +6076,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 +6149,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/RomImporter.lua b/src/import/RomImporter.lua index e1dcd808..f1876a64 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 " .. @@ -1447,7 +1465,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 @@ -2819,6 +2854,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 @@ -2848,6 +2887,7 @@ function RomImporter:update(dt) self:_pumpFindThumbs() self:_pumpSkinFetch() self:_pumpSync(dt) + self:_pumpOnline(dt) self:_pumpModCheck() self:_pumpModInstall() self:_pumpCartInstall() @@ -2861,6 +2901,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 +2929,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 +2973,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") @@ -3090,7 +3177,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 @@ -3173,7 +3260,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 @@ -3314,6 +3402,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 +3434,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 @@ -3665,10 +3763,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 +4092,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 +4313,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 @@ -4352,6 +4477,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 @@ -4501,6 +4627,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 +4687,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 @@ -5124,7 +5289,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 +5391,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) @@ -6015,6 +6231,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 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/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..9274914d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -2,7 +2,6 @@ -- the other joins by typing that address in. Direct peer-to-peer over -- lua-enet (bundled with LÖVE), no relay server. -local CodeEntry = require("src.link.CodeEntry") local DiscordPresence = require("src.core.DiscordPresence") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") @@ -48,7 +47,7 @@ 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) @@ -94,30 +93,12 @@ function LinkState.new(game) 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 +131,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 +202,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 +219,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 +239,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 @@ -511,7 +354,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 +367,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 +428,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 +583,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) @@ -828,8 +640,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/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/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..d48634e9 --- /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/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/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/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/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/BattleState.lua b/src/ui/gen2/BattleState.lua index c22733f4..cdd1fcf2 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -251,6 +251,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 @@ -2004,6 +2005,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 +2022,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 +2422,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 +2464,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 +2563,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 +2601,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 +2634,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 @@ -2956,6 +2989,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 diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index fe1a9424..85ca06ec 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. 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/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/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/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/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/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/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/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/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_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_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/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_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/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/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/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/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